302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
|
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
|
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
|
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
|
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
|
import type { ReactNode } from "react";
|
|
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
|
import type { ComposerProps } from "./Composer";
|
|
import { FeedView, type FeedStatus } from "./FeedView";
|
|
import { useCompactLayout } from "./layout-context";
|
|
|
|
const POLL_MS = 45_000;
|
|
|
|
export function Feed({
|
|
onSignedOut,
|
|
feedUserId,
|
|
person,
|
|
featuredExperts,
|
|
trendingMinnects,
|
|
searchPanel,
|
|
}: {
|
|
onSignedOut: () => void;
|
|
feedUserId?: string | null;
|
|
/** The signed-in person, shown beside the composer. */
|
|
person?: ComposerProps["person"];
|
|
/** Shown as a slide after the second post on phones. */
|
|
featuredExperts?: DiscoverExpert[];
|
|
/** Phone Discover sheet: trending minnects and the Find experts panel. */
|
|
trendingMinnects?: DiscoverMinnect[];
|
|
searchPanel?: ReactNode;
|
|
}) {
|
|
const [posts, setPosts] = useState<CardPost[]>([]);
|
|
const [status, setStatus] = useState<FeedStatus>("loading");
|
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [refreshFailed, setRefreshFailed] = useState(false);
|
|
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
|
|
const [pending, setPending] = useState<CardPost[]>([]);
|
|
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
|
|
const [likeErrors, setLikeErrors] = useState<ReadonlySet<string>>(new Set());
|
|
const [deleteBusy, setDeleteBusy] = useState<ReadonlySet<string>>(new Set());
|
|
const [deleteErrors, setDeleteErrors] = useState<ReadonlySet<string>>(new Set());
|
|
const [draft, setDraft] = useState("");
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [posting, setPosting] = useState(false);
|
|
const [postFailed, setPostFailed] = useState(false);
|
|
// Id of a post already created but not yet seen on the first page. A retry
|
|
// re-checks the feed instead of creating a duplicate.
|
|
const unconfirmed = useRef<{ id: string | null }>({ id: null });
|
|
const sentinel = useRef<HTMLDivElement | null>(null);
|
|
const list = useRef<HTMLDivElement | null>(null);
|
|
const busy = useRef(false);
|
|
const shown = useRef<Set<string>>(new Set());
|
|
// The list element changes between the panel and the phone reel; the
|
|
// infinite-scroll observer re-attaches to the new one.
|
|
const compact = useCompactLayout();
|
|
|
|
useEffect(() => {
|
|
shown.current = new Set(posts.map((p) => p.key));
|
|
}, [posts]);
|
|
|
|
const fail = useCallback(
|
|
(e: unknown) => {
|
|
if (e instanceof FeedLoadError && e.signedOut) onSignedOut();
|
|
},
|
|
[onSignedOut],
|
|
);
|
|
|
|
// quiet: a refresh keeps the list on screen instead of swapping in the spinner.
|
|
const loadFirst = useCallback(async (quiet = false) => {
|
|
if (busy.current) return;
|
|
busy.current = true;
|
|
setRefreshFailed(false);
|
|
if (quiet) setRefreshing(true);
|
|
else setStatus("loading");
|
|
setLoadMoreFailed(false);
|
|
try {
|
|
const page = await fetchFeedPage(null);
|
|
setPosts(mergePosts(toCardPosts(page.items)));
|
|
setNextCursor(page.nextCursor);
|
|
setPending([]);
|
|
setStatus("ready");
|
|
} catch (e) {
|
|
fail(e);
|
|
const out = firstLoadFailure(quiet);
|
|
if (out.fullError) setStatus("error");
|
|
setRefreshFailed(out.refreshFailed);
|
|
} finally {
|
|
busy.current = false;
|
|
setRefreshing(false);
|
|
}
|
|
}, [fail]);
|
|
|
|
const loadMore = useCallback(async () => {
|
|
if (busy.current || !nextCursor) return;
|
|
busy.current = true;
|
|
setLoadingMore(true);
|
|
setLoadMoreFailed(false);
|
|
try {
|
|
const page = await fetchFeedPage(nextCursor);
|
|
setPosts((cur) => mergePosts(cur, toCardPosts(page.items)));
|
|
setNextCursor(page.nextCursor);
|
|
} catch (e) {
|
|
fail(e);
|
|
setLoadMoreFailed(true);
|
|
} finally {
|
|
busy.current = false;
|
|
setLoadingMore(false);
|
|
}
|
|
}, [nextCursor, fail]);
|
|
|
|
useEffect(() => {
|
|
void loadFirst();
|
|
}, [loadFirst]);
|
|
|
|
// Infinite scroll. A fresh observer per change re-checks a sentinel that is
|
|
// still in view after a page lands.
|
|
useEffect(() => {
|
|
const el = sentinel.current;
|
|
if (!el || !nextCursor || status !== "ready" || loadMoreFailed) return;
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries.some((e) => e.isIntersecting)) void loadMore();
|
|
},
|
|
// The list scrolls inside its panel, so it is the observer root.
|
|
{ root: list.current, rootMargin: "400px" },
|
|
);
|
|
io.observe(el);
|
|
return () => io.disconnect();
|
|
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length, compact]);
|
|
|
|
// Real-time: poll the first page while the tab is visible.
|
|
useEffect(() => {
|
|
if (status !== "ready") return;
|
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
const poll = async () => {
|
|
try {
|
|
const page = await fetchFeedPage(null);
|
|
const all = toCardPosts(page.items);
|
|
setPosts((cur) => upgradeVideos(cur, all));
|
|
const fresh = all.filter((p) => !shown.current.has(p.key));
|
|
if (fresh.length) setPending((cur) => mergePosts(cur, fresh));
|
|
} catch (e) {
|
|
fail(e);
|
|
}
|
|
};
|
|
const start = () => {
|
|
if (timer === null) timer = setInterval(() => void poll(), POLL_MS);
|
|
};
|
|
const stop = () => {
|
|
if (timer !== null) clearInterval(timer);
|
|
timer = null;
|
|
};
|
|
const onVisibility = () => {
|
|
if (document.visibilityState === "visible") {
|
|
void poll();
|
|
start();
|
|
} else {
|
|
stop();
|
|
}
|
|
};
|
|
if (document.visibilityState === "visible") start();
|
|
document.addEventListener("visibilitychange", onVisibility);
|
|
return () => {
|
|
stop();
|
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
};
|
|
}, [status, fail]);
|
|
|
|
const toggleLike = useCallback(
|
|
async (post: CardPost) => {
|
|
if (!post.id) return;
|
|
const wanted = !post.isLiked;
|
|
const mark = (set: ReadonlySet<string>, on: boolean) => {
|
|
const next = new Set(set);
|
|
if (on) next.add(post.key);
|
|
else next.delete(post.key);
|
|
return next;
|
|
};
|
|
setLikeBusy((s) => mark(s, true));
|
|
setLikeErrors((s) => mark(s, false));
|
|
try {
|
|
const out = await sendLike(post.id, wanted);
|
|
setPosts((cur) => cur.map((p) => (p.key === post.key ? applyLikeOutcome(p, wanted, out) : p)));
|
|
} catch (e) {
|
|
fail(e instanceof LikeError && e.signedOut ? new FeedLoadError(true) : e);
|
|
setLikeErrors((s) => mark(s, true));
|
|
} finally {
|
|
setLikeBusy((s) => mark(s, false));
|
|
}
|
|
},
|
|
[fail],
|
|
);
|
|
|
|
const removePost = useCallback(
|
|
async (post: CardPost) => {
|
|
const mark = (set: ReadonlySet<string>, on: boolean) => {
|
|
const next = new Set(set);
|
|
if (on) next.add(post.key);
|
|
else next.delete(post.key);
|
|
return next;
|
|
};
|
|
setDeleteBusy((s) => mark(s, true));
|
|
setDeleteErrors((s) => mark(s, false));
|
|
try {
|
|
// Removes the card only after the feed answered 200.
|
|
if (!post.id) throw new DeleteError(false);
|
|
await sendDelete(post.id);
|
|
setPosts((cur) => cur.filter((p) => p.key !== post.key));
|
|
setPending((cur) => cur.filter((p) => p.key !== post.key));
|
|
} catch (e) {
|
|
if (e instanceof DeleteError && e.signedOut) onSignedOut();
|
|
setDeleteErrors((s) => mark(s, true));
|
|
} finally {
|
|
setDeleteBusy((s) => mark(s, false));
|
|
}
|
|
},
|
|
[onSignedOut],
|
|
);
|
|
|
|
const publish = useCallback(async () => {
|
|
const text = draft.trim();
|
|
// A post needs either text or a file.
|
|
if ((!text && !file) || posting) return;
|
|
setPosting(true);
|
|
setPostFailed(false);
|
|
try {
|
|
const page = await publishAndConfirm(text, file, unconfirmed.current);
|
|
setPosts(mergePosts(toCardPosts(page.items)));
|
|
setNextCursor(page.nextCursor);
|
|
setPending([]);
|
|
setDraft("");
|
|
setFile(null);
|
|
} catch (e) {
|
|
if (e instanceof PostError && e.signedOut) onSignedOut();
|
|
setPostFailed(true);
|
|
} finally {
|
|
setPosting(false);
|
|
}
|
|
}, [draft, file, posting, onSignedOut]);
|
|
|
|
function changeDraft(v: string) {
|
|
// New text is a new post; forget a stale unconfirmed id.
|
|
if (v !== draft) unconfirmed.current = { id: null };
|
|
setDraft(v);
|
|
}
|
|
|
|
function changeFile(f: File | null) {
|
|
// A different attachment is a new post; forget a stale unconfirmed id.
|
|
unconfirmed.current = { id: null };
|
|
setFile(f);
|
|
}
|
|
|
|
function showNew() {
|
|
setPosts((cur) => mergePosts(cur, pending));
|
|
setPending([]);
|
|
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
|
|
}
|
|
|
|
function refresh() {
|
|
if (busy.current) return;
|
|
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
|
|
void loadFirst(true);
|
|
}
|
|
|
|
return (
|
|
<FeedView
|
|
posts={posts}
|
|
status={status}
|
|
loadingMore={loadingMore}
|
|
loadMoreFailed={loadMoreFailed}
|
|
newCount={pending.length}
|
|
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
|
|
onShowNew={showNew}
|
|
onRefresh={refresh}
|
|
refreshing={refreshing}
|
|
refreshFailed={refreshFailed}
|
|
onToggleLike={(p) => void toggleLike(p)}
|
|
likeBusy={likeBusy}
|
|
likeErrors={likeErrors}
|
|
feedUserId={feedUserId}
|
|
onDelete={(p) => void removePost(p)}
|
|
deleteBusy={deleteBusy}
|
|
deleteErrors={deleteErrors}
|
|
onSignedOut={onSignedOut}
|
|
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
|
featuredExperts={featuredExperts}
|
|
trendingMinnects={trendingMinnects}
|
|
searchPanel={searchPanel}
|
|
hasMore={!!nextCursor}
|
|
sentinelRef={sentinel}
|
|
listRef={list}
|
|
/>
|
|
);
|
|
}
|