import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useLocation } from "react-router"; import { AppHeader } from "../components/AppHeader.tsx"; import { SearchBar } from "../components/SearchBar.tsx"; import { PresenceRow } from "../components/PresenceRow.tsx"; import { FeedTabBar } from "../components/FeedTabBar.tsx"; import { type FeedTab, VALID_TABS } from "../config/feedTabs.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 { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.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 type { MainFeedProps } from "./index/types.ts"; type DumpsState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean; }; export function Index() { const location = useLocation(); const justDeletedId = (location.state as { deletedDumpId?: string } | null) ?.deletedDumpId; const { user, token } = useAuth(); const { voteCounts, myVotes, recentDumps, deletedDumpIds, castVote, removeVote, } = useWS(); // ── Main feed state ── const { cached, saveState } = useFeedCache( `feed:index:${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); const searchParams = new URLSearchParams(location.search); const rawTab = searchParams.get("tab") ?? "hot"; const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot"; // Web Share Target: Android share sheet navigates to /?share_url=... const shareUrl = searchParams.get("share_url") ?? searchParams.get("share_text") ?? ""; useEffect(() => { if (!shareUrl) return; // Clean share params from the URL so a refresh doesn't re-open the modal const clean = tab !== "hot" ? `?tab=${tab}` : ""; globalThis.history.replaceState({}, "", location.pathname + clean); }, [shareUrl, tab, location.pathname]); // ── Main feed fetch ── useEffect(() => { if (mainFetchDone.current || cached) return; mainFetchDone.current = true; const controller = new AbortController(); (async () => { try { const res = await fetch( `${API_URL}/api/dumps/?page=1&limit=${DEFAULT_PAGE_SIZE}`, { 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]); // ── 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); // ── 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(`${API_URL}/api/dumps/?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, { 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]); // ── 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; const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]); const combined = useMemo( () => [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps].filter( (d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId, ), [recentDumps, restIds, dumps, deletedDumpIds, justDeletedId], ); const mainFeedProps: MainFeedProps = { dumps: combined, loading, error, hasMore, loadingMore, sentinelRef, voteCounts, myVotes, user, castVote, removeVote, }; // ── Render ── return (
} disableNew={dumpsState.status === "error"} initialDumpUrl={shareUrl || undefined} />
{tab === "hot" && } {tab === "new" && } {tab === "journal" && } {tab === "followed" && user && ( )} ); }