import { useCallback, useEffect, useState } from "react"; import { TabBar } from "../shared/TabBar.tsx"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { DumpCard } from "../dump/DumpCard.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx"; import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts"; import { useFeedCache } from "../../hooks/feed/useFeedCache.ts"; import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts"; import { useScrollSave } from "../../hooks/feed/useScrollSave.ts"; import { deserializeDump, type Dump, hydrateDump, type PaginatedData, type RawDump, type User, } from "../../model.ts"; import { friendlyFetchError } from "../../utils/shared/api.ts"; type FeedState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean; }; /** Page of a followed feed. Throws the server's own message on an error * envelope, so it isn't mistaken for a lost connection. */ async function readPage(res: Response): Promise> { const body = await res.json(); if (!body.success) { throw new Error(body.error?.message ?? `HTTP ${res.status}`); } return body.data as PaginatedData; } type FollowedSection = "users" | "playlists"; interface FollowedFeedProps { categorySlug?: string; voteCounts: Record; myVotes: Set; user: User; castVote: (id: string, shownCount?: number) => void; removeVote: (id: string, shownCount?: number) => void; deletedDumpIds: Set; } // ── FollowedSubFeed ─────────────────────────────────────────────────────────── interface FollowedSubFeedProps { state: FeedState; voteCounts: Record; myVotes: Set; user: User; castVote: (id: string, shownCount?: number) => void; removeVote: (id: string, shownCount?: number) => void; deletedDumpIds: Set; emptyMessage: string; onLoadMore: () => void; } function FollowedSubFeed({ state, voteCounts, myVotes, user, castVote, removeVote, deletedDumpIds, emptyMessage, onLoadMore, }: FollowedSubFeedProps) { const enabled = state.status === "loaded" && state.hasMore && !state.loadingMore; const sentinelRef = useInfiniteScroll(onLoadMore, enabled); if (state.status === "loading") { return (

Loading…

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

{emptyMessage}

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

Loading more…

)} {!state.hasMore && state.dumps.length > 0 && (

You've reached the end.

)} ); } // ── FollowedFeed ────────────────────────────────────────────────────────────── export function FollowedFeed({ categorySlug, voteCounts, myVotes, user, castVote, removeVote, deletedDumpIds, }: FollowedFeedProps) { const { token } = useAuth(); // Scope key + query suffix applied to every followed-feed request. const scope = categorySlug ?? "all"; const catQuery = categorySlug ? `&category=${encodeURIComponent(categorySlug)}` : ""; const { cached: cachedUsers, saveState: saveUsers } = useFeedCache( `feed:followed-users:${scope}:${user.id}`, hydrateDump, ); const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache< Dump >( `feed:followed-playlists:${scope}:${user.id}`, hydrateDump, ); const [usersState, setUsersState] = useState(() => cachedUsers ? { status: "loaded", dumps: cachedUsers.items, hasMore: cachedUsers.hasMore, page: cachedUsers.page, loadingMore: false, } : { status: "loading" } ); const [playlistsState, setPlaylistsState] = useState(() => cachedPlaylists ? { status: "loaded", dumps: cachedPlaylists.items, hasMore: cachedPlaylists.hasMore, page: cachedPlaylists.page, loadingMore: false, } : { status: "loading" } ); const [section, setSection] = useState("users"); // WS sync const setUsersItems = useCallback( (fn: (prev: Dump[]) => Dump[]) => setUsersState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) } ), [], ); useDumpListSync(setUsersItems); const setPlaylistsItems = useCallback( (fn: (prev: Dump[]) => Dump[]) => setPlaylistsState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) } ), [], ); useDumpListSync(setPlaylistsItems); // Fetch on mount if not cached useEffect(() => { if (!token) return; if (usersState.status === "loading") { fetch( `${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, { headers: { Authorization: `Bearer ${token}` } }, ) .then(readPage) .then(({ items, hasMore }) => { setUsersState({ status: "loaded", dumps: items.map(deserializeDump), hasMore, page: 1, loadingMore: false, }); }) .catch((err) => setUsersState({ status: "error", error: friendlyFetchError(err) }) ); } if (playlistsState.status === "loading") { fetch( `${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, { headers: { Authorization: `Bearer ${token}` } }, ) .then(readPage) .then(({ items, hasMore }) => { setPlaylistsState({ status: "loaded", dumps: items.map(deserializeDump), hasMore, page: 1, loadingMore: false, }); }) .catch((err) => setPlaylistsState({ status: "error", error: friendlyFetchError(err), }) ); } }, [token, usersState.status, playlistsState.status, catQuery]); // Scroll save useScrollSave( usersState.status === "loaded", useCallback((y) => { if (usersState.status !== "loaded") return; saveUsers(usersState.dumps, usersState.page, usersState.hasMore, y); }, [usersState, saveUsers]), ); useScrollSave( playlistsState.status === "loaded", useCallback((y) => { if (playlistsState.status !== "loaded") return; savePlaylists( playlistsState.dumps, playlistsState.page, playlistsState.hasMore, y, ); }, [playlistsState, savePlaylists]), ); // Load-more callbacks const loadMoreUsers = useCallback(() => { if ( usersState.status !== "loaded" || !usersState.hasMore || usersState.loadingMore || !token ) return; const nextPage = usersState.page + 1; setUsersState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s ); fetch( `${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, { headers: { Authorization: `Bearer ${token}` } }, ) .then(readPage) .then(({ items, hasMore }) => { setUsersState((s) => s.status === "loaded" ? { ...s, dumps: [...s.dumps, ...items.map(deserializeDump)], hasMore, page: nextPage, loadingMore: false, } : s ); }) .catch(() => setUsersState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s ) ); }, [usersState, token, catQuery]); const loadMorePlaylists = useCallback(() => { if ( playlistsState.status !== "loaded" || !playlistsState.hasMore || playlistsState.loadingMore || !token ) return; const nextPage = playlistsState.page + 1; setPlaylistsState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s ); fetch( `${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, { headers: { Authorization: `Bearer ${token}` } }, ) .then(readPage) .then(({ items, hasMore }) => { setPlaylistsState((s) => s.status === "loaded" ? { ...s, dumps: [...s.dumps, ...items.map(deserializeDump)], hasMore, page: nextPage, loadingMore: false, } : s ); }) .catch(() => setPlaylistsState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s ) ); }, [playlistsState, token, catQuery]); return (
From people }, { key: "playlists", label: From playlists }, ]} activeTab={section} onChange={setSection} innerClassName="followed-sub-nav" /> {section === "users" && ( )} {section === "playlists" && ( )}
); }