import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { Link, useLocation } from "react-router"; import { Avatar } from "../components/Avatar.tsx"; import { DumpCard } from "../components/DumpCard.tsx"; import { AppHeader } from "../components/AppHeader.tsx"; import { API_URL } from "../config/api.ts"; import { deserializeDump, type Dump, type PaginatedData, type RawDump, type User, } from "../model.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; const PAGE_SIZE = 20; // After JSON roundtrip, createdAt is a string — re-parse it const hydrateDump = (raw: Dump): Dump => deserializeDump(raw as unknown as RawDump); type DumpsState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean; }; type FeedTab = "hot" | "new" | "followed"; type FollowedSection = "users" | "playlists"; function hotScore(dump: Dump): number { const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000; return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5); } // ── FollowedSubFeed ────────────────────────────────────────────────────────── interface FollowedSubFeedProps { state: DumpsState; voteCounts: Record; myVotes: Set; user: User | null; castVote: (id: string) => void; removeVote: (id: string) => void; deletedDumpIds: Set; emptyMessage: string; onLoadMore: () => void; } function FollowedSubFeed({ state, voteCounts, myVotes, user, castVote, removeVote, deletedDumpIds, emptyMessage, onLoadMore, }: FollowedSubFeedProps) { const hasMore = state.status === "loaded" && state.hasMore && !state.loadingMore; const sentinelRef = useInfiniteScroll(onLoadMore, hasMore); if (state.status === "loading") { return

Loading…

; } if (state.status === "error") { return

{state.error}

; } const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id)); if (visible.length === 0) { return

{emptyMessage}

; } return ( <>
    {visible.map((dump) => ( ))}
{state.loadingMore &&

Loading more…

} ); } // ── Index ──────────────────────────────────────────────────────────────────── export function Index() { const location = useLocation(); const justDeletedId = (location.state as { deletedDumpId?: string } | null) ?.deletedDumpId; const { user, token } = useAuth(); const { onlineUsers, voteCounts, myVotes, recentDumps, deletedDumpIds, castVote, removeVote, } = useWS(); // Main feed 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); // Followed feeds const { cached: cachedFollowedUsers, saveState: saveFollowedUsers } = useFeedCache( `feed:followed-users:${user?.id ?? "guest"}`, hydrateDump, ); const { cached: cachedFollowedPlaylists, saveState: saveFollowedPlaylists } = useFeedCache( `feed:followed-playlists:${user?.id ?? "guest"}`, hydrateDump, ); const [followedUsersDumps, setFollowedUsersDumps] = useState({ status: "loading", }); const [followedPlaylistsDumps, setFollowedPlaylistsDumps] = useState< DumpsState >({ status: "loading" }); const [tab, setTab] = useState("hot"); const [followedSection, setFollowedSection] = useState( "users", ); // When the logo is clicked it navigates to / with state { tab: "hot" }, producing // a new location.key even if already on /. React to that to reset the active tab. useEffect(() => { const st = location.state as { tab?: string } | null; if (st?.tab === "hot" || st?.tab === "new" || st?.tab === "followed") { setTab(st.tab as FeedTab); } }, [location]); // ── Main feed fetch ── useEffect(() => { if (mainFetchDone.current || cached) return; mainFetchDone.current = true; (async () => { try { const res = await fetch( `${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`, { 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) { setDumpsState({ status: "error", error: err instanceof Error ? err.message : "Failed to load", }); } })(); }, [cached, token]); // ── Followed feeds fetch (lazy, on first tab open) ── useEffect(() => { if (tab !== "followed" || !user || !token) return; if (followedUsersDumps.status === "loading") { if (cachedFollowedUsers) { setFollowedUsersDumps({ status: "loaded", dumps: cachedFollowedUsers.items, hasMore: cachedFollowedUsers.hasMore, page: cachedFollowedUsers.page, loadingMore: false, }); } else { fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${PAGE_SIZE}`, { headers: { Authorization: `Bearer ${token}` }, }) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; setFollowedUsersDumps({ status: "loaded", dumps: items.map(deserializeDump), hasMore, page: 1, loadingMore: false, }); }) .catch((err) => setFollowedUsersDumps({ status: "error", error: err instanceof Error ? err.message : "Failed to load", }) ); } } if (followedPlaylistsDumps.status === "loading") { if (cachedFollowedPlaylists) { setFollowedPlaylistsDumps({ status: "loaded", dumps: cachedFollowedPlaylists.items, hasMore: cachedFollowedPlaylists.hasMore, page: cachedFollowedPlaylists.page, loadingMore: false, }); } else { fetch( `${API_URL}/api/follows/feed/playlists?page=1&limit=${PAGE_SIZE}`, { headers: { Authorization: `Bearer ${token}` }, }, ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; setFollowedPlaylistsDumps({ status: "loaded", dumps: items.map(deserializeDump), hasMore, page: 1, loadingMore: false, }); }) .catch((err) => setFollowedPlaylistsDumps({ status: "error", error: err instanceof Error ? err.message : "Failed to load", }) ); } } }, [ tab, user?.id, token, cachedFollowedUsers, cachedFollowedPlaylists, followedUsersDumps.status, followedPlaylistsDumps.status, ]); // ── Load-more callbacks ── 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=${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]); const loadMoreFollowedUsers = useCallback(() => { if ( followedUsersDumps.status !== "loaded" || !followedUsersDumps.hasMore || followedUsersDumps.loadingMore || !token ) return; const nextPage = followedUsersDumps.page + 1; setFollowedUsersDumps((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s ); fetch( `${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${PAGE_SIZE}`, { headers: { Authorization: `Bearer ${token}` }, }, ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; setFollowedUsersDumps((s) => s.status === "loaded" ? { ...s, dumps: [...s.dumps, ...items.map(deserializeDump)], hasMore, page: nextPage, loadingMore: false, } : s ); }) .catch(() => setFollowedUsersDumps((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s ) ); }, [followedUsersDumps, token]); const loadMoreFollowedPlaylists = useCallback(() => { if ( followedPlaylistsDumps.status !== "loaded" || !followedPlaylistsDumps.hasMore || followedPlaylistsDumps.loadingMore || !token ) return; const nextPage = followedPlaylistsDumps.page + 1; setFollowedPlaylistsDumps((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s ); fetch( `${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${PAGE_SIZE}`, { headers: { Authorization: `Bearer ${token}` }, }, ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; setFollowedPlaylistsDumps((s) => s.status === "loaded" ? { ...s, dumps: [...s.dumps, ...items.map(deserializeDump)], hasMore, page: nextPage, loadingMore: false, } : s ); }) .catch(() => setFollowedPlaylistsDumps((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s ) ); }, [followedPlaylistsDumps, token]); // ── Scroll save effects ── const sentinelRef = useInfiniteScroll( loadMore, dumpsState.status === "loaded" && dumpsState.hasMore && !dumpsState.loadingMore, ); useEffect(() => { if (dumpsState.status !== "loaded") return; let timer: ReturnType; const onScroll = () => { clearTimeout(timer); timer = setTimeout(() => { if (dumpsState.status === "loaded") { saveState( dumpsState.dumps, dumpsState.page, dumpsState.hasMore, globalThis.scrollY, ); } }, 100); }; globalThis.addEventListener("scroll", onScroll, { passive: true }); return () => { globalThis.removeEventListener("scroll", onScroll); clearTimeout(timer); }; }, [dumpsState, saveState]); useEffect(() => { if (followedUsersDumps.status !== "loaded") return; let timer: ReturnType; const onScroll = () => { clearTimeout(timer); timer = setTimeout(() => { if (followedUsersDumps.status === "loaded") { saveFollowedUsers( followedUsersDumps.dumps, followedUsersDumps.page, followedUsersDumps.hasMore, globalThis.scrollY, ); } }, 100); }; globalThis.addEventListener("scroll", onScroll, { passive: true }); return () => { globalThis.removeEventListener("scroll", onScroll); clearTimeout(timer); }; }, [followedUsersDumps, saveFollowedUsers]); useEffect(() => { if (followedPlaylistsDumps.status !== "loaded") return; let timer: ReturnType; const onScroll = () => { clearTimeout(timer); timer = setTimeout(() => { if (followedPlaylistsDumps.status === "loaded") { saveFollowedPlaylists( followedPlaylistsDumps.dumps, followedPlaylistsDumps.page, followedPlaylistsDumps.hasMore, globalThis.scrollY, ); } }, 100); }; globalThis.addEventListener("scroll", onScroll, { passive: true }); return () => { globalThis.removeEventListener("scroll", onScroll); clearTimeout(timer); }; }, [followedPlaylistsDumps, saveFollowedPlaylists]); // ── Scroll restoration ── 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 = dumpsState.status === "loaded" ? dumpsState.dumps : []; const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore; const restIds = new Set(dumps.map((d) => d.id)); const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps] .filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId); const sortedDumps = [...combined].sort( tab === "new" ? (a, b) => b.createdAt.getTime() - a.createdAt.getTime() : (a, b) => hotScore(b) - hotScore(a), ); // ── Render ── const presenceRow = (
{onlineUsers.map((u) => ( ))}
); const tabBar = (
{user && ( )}
); return (
{presenceRow} {tabBar}
} /> {/* Shown only on narrow viewports */}
{tabBar} {presenceRow}
{/* Hot / New feed */} {tab !== "followed" && ( <> {loading &&

Loading…

} {error &&

{error}

} {!loading && !error && combined.length === 0 && (

No dumps yet. Be the first!

)} {!loading && !error && combined.length > 0 && (
    {sortedDumps.map((dump) => ( ))}
)}
{loadingMore &&

Loading more…

} )} {/* Followed feed */} {tab === "followed" && user && (
{followedSection === "users" && ( )} {followedSection === "playlists" && ( )}
)}
); }