/** * Renders the home page, which shows every dump, or one category's, in the * "hot", "new", "journal" and "followed" tabs. * * @module */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useLocation, useParams } from "react-router"; import { AppHeader } from "../components/layout/AppHeader.tsx"; import { PresenceRow } from "../components/feed/PresenceRow.tsx"; import { FeedTabBar } from "../components/feed/FeedTabBar.tsx"; import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab, } from "../config/feedTabs.ts"; import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts"; import type { Dump } from "../../shared/dump.ts"; import type { PaginatedData, Wire } from "../../shared/api.ts"; import { friendlyFetchError } from "../utils/shared/api.ts"; import { useFeedCache } from "../hooks/feed/useFeedCache.ts"; import { useScrollSave } from "../hooks/feed/useScrollSave.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useAuth } from "../hooks/auth/useAuth.ts"; import { useWS } from "../hooks/ws/useWS.ts"; import { useCategories } from "../hooks/category/useCategories.ts"; import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts"; import { DumpFeed, type MainFeedProps } from "../components/feed/DumpFeed.tsx"; import { JournalFeed } from "../components/feed/JournalFeed.tsx"; import { FollowedFeed } from "../components/feed/FollowedFeed.tsx"; import { NotFound } from "./NotFound.tsx"; type DumpsState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean; }; /** * Passed to {@link Index} by the router, from the `:categorySlug` of the URL. */ export interface IndexProps { /** Without it, the feed holds every dump. An unknown slug renders 404. */ categorySlug?: string; } /** * Renders `/~/:feedTab` and `/:categorySlug/:feedTab`, for anyone, the * "followed" tab for a signed-in user only. The feed comes from * `GET /api/dumps/`, or `GET /api/categories/:slug/dumps`, a page at a time, * and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps * posted since, the live vote counts, and who's online. The router keys the * page by category, so switching tabs keeps the feed and switching categories * refetches it. It also opens the dump creation modal on a link shared from * Android (`?share_url=`). */ export function Index( { categorySlug }: IndexProps, ): React.JSX.Element { 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(); 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, ); const { feedTab: rawTab } = useParams(); const [preferredTab] = useDefaultFeedTab(); const defaultTab: FeedTab = preferredTab === "followed" && !user ? DEFAULT_FEED_TAB : preferredTab; // A guest on "followed", from a shared link or after signing out, gets the // default tab rather than an empty page. const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "") && (rawTab !== "followed" || !!user) ? (rawTab as FeedTab) : defaultTab; 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); // The Web Share Target: Android's share sheet opens `/?share_url=…`, which // the redirect from `/` keeps. The query is dropped from the URL at once, so // a reload doesn't share the link a second time. 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]); 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]); const setDumpsItems = useCallback( (fn: (prev: Dump[]) => Dump[]) => setDumpsState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) } ), [], ); useDumpListSync(setDumpsItems); // Dumps posted while the socket was down never arrived. On reconnect, the // first page is fetched again and whatever is new goes on top, keeping the // pages loaded and the 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]); 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]); 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]); 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; // A category's feed takes only the new dumps filed under it. 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]); // Dumps deleted since loading are dropped. `justDeletedId` never matches, // though: DumpEdit navigates to `/`, whose redirect drops the state. 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, }; if (categoryNotFound) return ; return (
} disableNew={dumpsState.status === "error"} initialDumpUrl={shareUrl || undefined} />
{(tab === "hot" || tab === "new") && ( )} {tab === "journal" && } {tab === "followed" && user && ( )} ); }