280 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|