import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useLocation, useParams } from "react-router"; import { AppHeader } from "../components/AppHeader.tsx"; import { PresenceRow } from "../components/PresenceRow.tsx"; import { FeedTabBar } from "../components/FeedTabBar.tsx"; import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab, } from "../config/feedTabs.ts"; import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { deserializeDump, type Dump, hydrateDump, type PaginatedData, type RawDump, } from "../model.ts"; import { friendlyFetchError } from "../utils/apiError.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts"; import { useScrollSave } from "../hooks/useScrollSave.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { useCategories } from "../hooks/useCategories.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { HotFeed } from "./index/HotFeed.tsx"; import { NewFeed } from "./index/NewFeed.tsx"; import { JournalFeed } from "./index/JournalFeed.tsx"; import { FollowedFeed } from "./index/FollowedFeed.tsx"; import { NotFound } from "./NotFound.tsx"; import type { MainFeedProps } from "./index/types.ts"; type DumpsState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean; }; /** * The shared feed page. `categorySlug` scopes it to a single category; when * absent it shows every dump. The router keys this component by scope, so each * category gets its own mount/fetch while switching feed tabs reuses it. */ export function Index({ categorySlug }: { categorySlug?: string }) { const location = useLocation(); const justDeletedId = (location.state as { deletedDumpId?: string } | null) ?.deletedDumpId; const { user, token } = useAuth(); const { voteCounts, myVotes, recentDumps, deletedDumpIds, castVote, removeVote, connectionEpoch, } = useWS(); // Resolve the scope to a real category (for its id, name, and 404 handling). const { categories, isLoaded: categoriesLoaded } = useCategories(); const category = categorySlug ? categories.find((c) => c.slug === categorySlug) : undefined; const categoryNotFound = !!categorySlug && categoriesLoaded && !category; useDocumentTitle( categorySlug ? (category ? category.name : undefined) : null, ); // ── Active feed tab (from the URL) ── const { feedTab: rawTab } = useParams(); const [preferredTab] = useDefaultFeedTab(); const defaultTab: FeedTab = preferredTab === "followed" && !user ? DEFAULT_FEED_TAB : preferredTab; // "followed" needs a session: a guest there (a shared link, or signed out // on it) gets the default feed rather than an empty page. const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "") && (rawTab !== "followed" || !!user) ? (rawTab as FeedTab) : defaultTab; // ── Main feed state ── const feedUrl = useCallback( (page: number) => categorySlug ? `${API_URL}/api/categories/${ encodeURIComponent(categorySlug) }/dumps?page=${page}&limit=${DEFAULT_PAGE_SIZE}` : `${API_URL}/api/dumps/?page=${page}&limit=${DEFAULT_PAGE_SIZE}`, [categorySlug], ); const { cached, saveState } = useFeedCache( `feed:index:${categorySlug ?? "all"}:${user?.id ?? "guest"}`, hydrateDump, ); const [dumpsState, setDumpsState] = useState(() => cached ? { status: "loaded", dumps: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false, } : { status: "loading" } ); const mainFetchDone = useRef(false); // Web Share Target: Android share sheet navigates to /?share_url=... const searchParams = new URLSearchParams(location.search); const shareUrl = searchParams.get("share_url") ?? searchParams.get("share_text") ?? ""; useEffect(() => { if (!shareUrl) return; globalThis.history.replaceState({}, "", location.pathname); }, [shareUrl, location.pathname]); // ── Main feed fetch ── useEffect(() => { if (mainFetchDone.current || cached) return; mainFetchDone.current = true; const controller = new AbortController(); (async () => { try { const res = await fetch(feedUrl(1), { signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); const { items, hasMore } = body.data as PaginatedData; setDumpsState({ status: "loaded", dumps: items.map(deserializeDump), hasMore, page: 1, loadingMore: false, }); } catch (err) { if ((err as Error).name === "AbortError") return; setDumpsState({ status: "error", error: friendlyFetchError(err) }); } })(); return () => { mainFetchDone.current = false; controller.abort(); }; }, [cached, token, feedUrl]); // ── WS sync for main feed ── const setDumpsItems = useCallback( (fn: (prev: Dump[]) => Dump[]) => setDumpsState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) } ), [], ); useDumpListSync(setDumpsItems); // ── Reconnect backfill ── // New dumps that arrived while the socket was down weren't delivered. On // reconnect, re-fetch page 1 and merge anything not already present on top, // preserving loaded pages and scroll position. const backfillEpochRef = useRef(connectionEpoch); useEffect(() => { if (connectionEpoch === backfillEpochRef.current) return; backfillEpochRef.current = connectionEpoch; const controller = new AbortController(); fetch(feedUrl(1), { signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }) .then((r) => r.json()) .then((body) => { const { items } = body.data as PaginatedData; const fresh = items.map(deserializeDump); setDumpsState((s) => { if (s.status !== "loaded") return s; const known = new Set(s.dumps.map((d) => d.id)); const newOnes = fresh.filter((d) => !known.has(d.id)); if (newOnes.length === 0) return s; return { ...s, dumps: [...newOnes, ...s.dumps] }; }); }) .catch(() => {}); return () => controller.abort(); }, [connectionEpoch, feedUrl, token]); // ── Load more ── const loadMore = useCallback(() => { if ( dumpsState.status !== "loaded" || !dumpsState.hasMore || dumpsState.loadingMore ) return; const nextPage = dumpsState.page + 1; setDumpsState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s ); fetch(feedUrl(nextPage), { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; setDumpsState((s) => s.status === "loaded" ? { ...s, dumps: [...s.dumps, ...items.map(deserializeDump)], hasMore, page: nextPage, loadingMore: false, } : s ); }) .catch(() => setDumpsState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s ) ); }, [dumpsState, token, feedUrl]); // ── Scroll save / restore ── const sentinelRef = useInfiniteScroll( loadMore, dumpsState.status === "loaded" && dumpsState.hasMore && !dumpsState.loadingMore, ); useScrollSave( dumpsState.status === "loaded", useCallback((y) => { if (dumpsState.status !== "loaded") return; saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, y); }, [dumpsState, saveState]), ); const scrollRestored = useRef(false); useLayoutEffect(() => { if (cached?.scrollY == null || scrollRestored.current) return; if (dumpsState.status === "loaded") { globalThis.scrollTo(0, cached.scrollY); scrollRestored.current = true; } }, [dumpsState.status, cached]); // ── Derived values ── const loading = dumpsState.status === "loading"; const error = dumpsState.status === "error" ? dumpsState.error : null; const dumps = useMemo( () => dumpsState.status === "loaded" ? dumpsState.dumps : [], [dumpsState], ); const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore; const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false; // New dumps arriving over the WS only belong in this feed if they're in scope. const categoryId = category?.id; const inScope = useCallback( (d: Dump) => categorySlug ? !!categoryId && !!d.categoryIds?.includes(categoryId) : true, [categorySlug, categoryId], ); const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]); const combined = useMemo( () => [...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps] .filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId), [recentDumps, restIds, dumps, deletedDumpIds, justDeletedId, inScope], ); const mainFeedProps: MainFeedProps = { dumps: combined, loading, error, hasMore, loadingMore, sentinelRef, voteCounts, myVotes, user, castVote, removeVote, }; // ── Render ── if (categoryNotFound) return ; return (
} disableNew={dumpsState.status === "error"} initialDumpUrl={shareUrl || undefined} />
{tab === "hot" && } {tab === "new" && } {tab === "journal" && } {tab === "followed" && user && ( )} ); }