Files
gerbeur/src/pages/Index.tsx

280 lines
7.8 KiB
TypeScript

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<Dump>(
`feed:index:${user?.id ?? "guest"}`,
hydrateDump,
);
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
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<RawDump>;
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<RawDump>;
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 (
<div className="index-page">
<AppHeader
centerSlot={
<div className="header-center-slot">
<PresenceRow />
<FeedTabBar />
<SearchBar collapsible />
</div>
}
disableNew={dumpsState.status === "error"}
initialDumpUrl={shareUrl || undefined}
/>
<div className="index-below-header">
<FeedTabBar />
<PresenceRow />
</div>
{tab === "hot" && <HotFeed {...mainFeedProps} />}
{tab === "new" && <NewFeed {...mainFeedProps} />}
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
{tab === "followed" && user && (
<FollowedFeed
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
/>
)}
</div>
);
}