367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
/**
|
|
* Renders the home page, which shows every dump, or one category's, in the
|
|
* "hot", "new", "journal" and "followed" tabs.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useLocation, useParams } from "react-router";
|
|
|
|
import { AppHeader } from "../components/layout/AppHeader.tsx";
|
|
import { PresenceRow } from "../components/feed/PresenceRow.tsx";
|
|
import { FeedTabBar } from "../components/feed/FeedTabBar.tsx";
|
|
import {
|
|
DEFAULT_FEED_TAB,
|
|
FEED_TABS,
|
|
type FeedTab,
|
|
} from "../config/feedTabs.ts";
|
|
import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
|
|
|
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
|
|
|
import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts";
|
|
import type { Dump } from "../../shared/dump.ts";
|
|
import type { PaginatedData, Wire } from "../../shared/api.ts";
|
|
|
|
import { friendlyFetchError } from "../utils/shared/api.ts";
|
|
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
|
|
import { useScrollSave } from "../hooks/feed/useScrollSave.ts";
|
|
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
|
import { useAuth } from "../hooks/auth/useAuth.ts";
|
|
import { useWS } from "../hooks/ws/useWS.ts";
|
|
import { useCategories } from "../hooks/category/useCategories.ts";
|
|
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
|
|
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
|
|
|
|
import { DumpFeed, type MainFeedProps } from "../components/feed/DumpFeed.tsx";
|
|
import { JournalFeed } from "../components/feed/JournalFeed.tsx";
|
|
import { FollowedFeed } from "../components/feed/FollowedFeed.tsx";
|
|
import { NotFound } from "./NotFound.tsx";
|
|
|
|
type DumpsState =
|
|
| { status: "loading" }
|
|
| { status: "error"; error: string }
|
|
| {
|
|
status: "loaded";
|
|
dumps: Dump[];
|
|
hasMore: boolean;
|
|
page: number;
|
|
loadingMore: boolean;
|
|
};
|
|
|
|
/**
|
|
* Passed to {@link Index} by the router, from the `:categorySlug` of the URL.
|
|
*/
|
|
export interface IndexProps {
|
|
/** Without it, the feed holds every dump. An unknown slug renders 404. */
|
|
categorySlug?: string;
|
|
}
|
|
|
|
/**
|
|
* Renders `/~/:feedTab` and `/:categorySlug/:feedTab`, for anyone, the
|
|
* "followed" tab for a signed-in user only. The feed comes from
|
|
* `GET /api/dumps/`, or `GET /api/categories/:slug/dumps`, a page at a time,
|
|
* and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps
|
|
* posted since, the live vote counts, and who's online. The router keys the
|
|
* page by category, so switching tabs keeps the feed and switching categories
|
|
* refetches it. It also opens the dump creation modal on a link shared from
|
|
* Android (`?share_url=`).
|
|
*/
|
|
export function Index(
|
|
{ categorySlug }: IndexProps,
|
|
): React.JSX.Element {
|
|
const location = useLocation();
|
|
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
|
?.deletedDumpId;
|
|
|
|
const { user, token } = useAuth();
|
|
const {
|
|
voteCounts,
|
|
myVotes,
|
|
recentDumps,
|
|
deletedDumpIds,
|
|
castVote,
|
|
removeVote,
|
|
connectionEpoch,
|
|
} = useWS();
|
|
|
|
const { categories, isLoaded: categoriesLoaded } = useCategories();
|
|
const category = categorySlug
|
|
? categories.find((c) => c.slug === categorySlug)
|
|
: undefined;
|
|
const categoryNotFound = !!categorySlug && categoriesLoaded && !category;
|
|
|
|
useDocumentTitle(
|
|
categorySlug ? (category ? category.name : undefined) : null,
|
|
);
|
|
|
|
const { feedTab: rawTab } = useParams();
|
|
const [preferredTab] = useDefaultFeedTab();
|
|
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
|
? DEFAULT_FEED_TAB
|
|
: preferredTab;
|
|
// A guest on "followed", from a shared link or after signing out, gets the
|
|
// default tab rather than an empty page.
|
|
const tab: FeedTab =
|
|
(FEED_TABS as readonly string[]).includes(rawTab ?? "") &&
|
|
(rawTab !== "followed" || !!user)
|
|
? (rawTab as FeedTab)
|
|
: defaultTab;
|
|
|
|
const feedUrl = useCallback(
|
|
(page: number) =>
|
|
categorySlug
|
|
? `${API_URL}/api/categories/${
|
|
encodeURIComponent(categorySlug)
|
|
}/dumps?page=${page}&limit=${DEFAULT_PAGE_SIZE}`
|
|
: `${API_URL}/api/dumps/?page=${page}&limit=${DEFAULT_PAGE_SIZE}`,
|
|
[categorySlug],
|
|
);
|
|
|
|
const { cached, saveState } = useFeedCache<Dump>(
|
|
`feed:index:${categorySlug ?? "all"}:${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);
|
|
|
|
// The Web Share Target: Android's share sheet opens `/?share_url=…`, which
|
|
// the redirect from `/` keeps. The query is dropped from the URL at once, so
|
|
// a reload doesn't share the link a second time.
|
|
const searchParams = new URLSearchParams(location.search);
|
|
const shareUrl = searchParams.get("share_url") ??
|
|
searchParams.get("share_text") ?? "";
|
|
|
|
useEffect(() => {
|
|
if (!shareUrl) return;
|
|
globalThis.history.replaceState({}, "", location.pathname);
|
|
}, [shareUrl, location.pathname]);
|
|
|
|
useEffect(() => {
|
|
if (mainFetchDone.current || cached) return;
|
|
mainFetchDone.current = true;
|
|
const controller = new AbortController();
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(feedUrl(1), {
|
|
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<Wire<Dump>>;
|
|
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, feedUrl]);
|
|
|
|
const setDumpsItems = useCallback(
|
|
(fn: (prev: Dump[]) => Dump[]) =>
|
|
setDumpsState((s) =>
|
|
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
|
|
),
|
|
[],
|
|
);
|
|
useDumpListSync(setDumpsItems);
|
|
|
|
// Dumps posted while the socket was down never arrived. On reconnect, the
|
|
// first page is fetched again and whatever is new goes on top, keeping the
|
|
// pages loaded and the scroll position.
|
|
const backfillEpochRef = useRef(connectionEpoch);
|
|
useEffect(() => {
|
|
if (connectionEpoch === backfillEpochRef.current) return;
|
|
backfillEpochRef.current = connectionEpoch;
|
|
const controller = new AbortController();
|
|
fetch(feedUrl(1), {
|
|
signal: controller.signal,
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
})
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
const { items } = body.data as PaginatedData<Wire<Dump>>;
|
|
const fresh = items.map(deserializeDump);
|
|
setDumpsState((s) => {
|
|
if (s.status !== "loaded") return s;
|
|
const known = new Set(s.dumps.map((d) => d.id));
|
|
const newOnes = fresh.filter((d) => !known.has(d.id));
|
|
if (newOnes.length === 0) return s;
|
|
return { ...s, dumps: [...newOnes, ...s.dumps] };
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
return () => controller.abort();
|
|
}, [connectionEpoch, feedUrl, token]);
|
|
|
|
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(feedUrl(nextPage), {
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
})
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
const { items, hasMore } = body.data as PaginatedData<Wire<Dump>>;
|
|
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, feedUrl]);
|
|
|
|
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]);
|
|
|
|
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;
|
|
|
|
// A category's feed takes only the new dumps filed under it.
|
|
const categoryId = category?.id;
|
|
const inScope = useCallback(
|
|
(d: Dump) =>
|
|
categorySlug
|
|
? !!categoryId && !!d.categoryIds?.includes(categoryId)
|
|
: true,
|
|
[categorySlug, categoryId],
|
|
);
|
|
|
|
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
|
|
// Dumps deleted since loading are dropped. `justDeletedId` never matches,
|
|
// though: DumpEdit navigates to `/`, whose redirect drops the state.
|
|
const combined = useMemo(
|
|
() =>
|
|
[...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
|
|
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId),
|
|
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId, inScope],
|
|
);
|
|
|
|
const mainFeedProps: MainFeedProps = {
|
|
dumps: combined,
|
|
loading,
|
|
error,
|
|
hasMore,
|
|
loadingMore,
|
|
sentinelRef,
|
|
voteCounts,
|
|
myVotes,
|
|
user,
|
|
castVote,
|
|
removeVote,
|
|
};
|
|
|
|
if (categoryNotFound) return <NotFound />;
|
|
|
|
return (
|
|
<div className="index-page">
|
|
<AppHeader
|
|
centerSlot={
|
|
<div className="header-center-slot">
|
|
<PresenceRow
|
|
fitWithin=".app-header-center"
|
|
fitBeside=".feed-sort"
|
|
/>
|
|
<FeedTabBar />
|
|
</div>
|
|
}
|
|
disableNew={dumpsState.status === "error"}
|
|
initialDumpUrl={shareUrl || undefined}
|
|
/>
|
|
|
|
<div className="index-below-header">
|
|
<FeedTabBar />
|
|
<PresenceRow fitWithin=".index-below-header" fitBeside=".feed-sort" />
|
|
</div>
|
|
|
|
{(tab === "hot" || tab === "new") && (
|
|
<DumpFeed order={tab} {...mainFeedProps} />
|
|
)}
|
|
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
|
|
{tab === "followed" && user && (
|
|
<FollowedFeed
|
|
categorySlug={categorySlug}
|
|
voteCounts={voteCounts}
|
|
myVotes={myVotes}
|
|
user={user}
|
|
castVote={castVote}
|
|
removeVote={removeVote}
|
|
deletedDumpIds={deletedDumpIds}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|