import { useCallback, useEffect, useState } from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { DumpCard } from "../../components/DumpCard.tsx"; import { ErrorCard } from "../../components/ErrorCard.tsx"; import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { useAuth } from "../../hooks/useAuth.ts"; import { useDumpListSync } from "../../hooks/useDumpListSync.ts"; import { useFeedCache } from "../../hooks/useFeedCache.ts"; import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts"; import { useScrollSave } from "../../hooks/useScrollSave.ts"; import { deserializeDump, type Dump, hydrateDump, type PaginatedData, type RawDump, type User, } from "../../model.ts"; import { friendlyFetchError } from "../../utils/apiError.ts"; type FeedState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean; }; type FollowedSection = "users" | "playlists"; interface FollowedFeedProps { voteCounts: Record; myVotes: Set; user: User; castVote: (id: string) => void; removeVote: (id: string) => void; deletedDumpIds: Set; } // ── FollowedSubFeed ─────────────────────────────────────────────────────────── interface FollowedSubFeedProps { state: FeedState; voteCounts: Record; myVotes: Set; user: User; 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 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.loadingMore && (

Loading more…

)} ); } // ── FollowedFeed ────────────────────────────────────────────────────────────── export function FollowedFeed({ voteCounts, myVotes, user, castVote, removeVote, deletedDumpIds, }: FollowedFeedProps) { const { token } = useAuth(); const { cached: cachedUsers, saveState: saveUsers } = useFeedCache( `feed:followed-users:${user.id}`, hydrateDump, ); const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache< Dump >( `feed:followed-playlists:${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}`, { headers: { Authorization: `Bearer ${token}` } }, ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; 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}`, { headers: { Authorization: `Bearer ${token}` } }, ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; 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]); // 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}`, { headers: { Authorization: `Bearer ${token}` } }, ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; 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]); 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}`, { headers: { Authorization: `Bearer ${token}` } }, ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; 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]); return (
{section === "users" && ( )} {section === "playlists" && ( )}
); }