diff --git a/app/src/components/launchpad/Posts.tsx b/app/src/components/launchpad/Posts.tsx index 69f8d90a..0570cca4 100644 --- a/app/src/components/launchpad/Posts.tsx +++ b/app/src/components/launchpad/Posts.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAccount, useConfig } from "wagmi"; import { getWalletClient } from "wagmi/actions"; import WalletAvatar from "@/components/WalletAvatar"; @@ -10,6 +10,7 @@ import { useLive } from "./LiveProvider"; import { toast } from "./TxToasts"; import { btn } from "@/components/ui"; import { buildModMessage, buildPostMessage, buildReportMessage, POST_MAX, REPORT_REASONS, validateBody, type ReportReason } from "@/lib/launchpad/posts"; +import { COMMENTS_PAGE, clearDraft, groupReplies, initialPostsLoadState, loadCommentDraft, nextPostsLoadState, resolveReplyTarget, saveDraft, shouldIgnoreLoad, visibleTopIds } from "@/lib/launchpad/post-threads"; import type { PostRow } from "@/lib/launchpad/postsServer"; import { ago, nowMs } from "@/lib/launchpad/time"; import { CHAINS, CHAIN_SHORT, shortAddr, type ChainKey } from "@/lib/chainPublic"; @@ -51,25 +52,79 @@ export default function TokenComments({ chain, token, symbol, launcher, embedded const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const [now, setNow] = useState(0); + const [shownCount, setShownCount] = useState(COMMENTS_PAGE); + // True once the first load() for the current chain/token has settled. + // A restored replyTo cannot be validated until then: posts starts empty, so + // an empty list must not read as "parent missing" before the fetch, nor as + // "parent exists" for signing. Submit of a restored reply blocks meanwhile. + // loadError arms the Retry button; postsLoaded stays false on failure so a + // restored reply keeps pending and the draft is never silently top-level. + const [{ postsLoaded, loadError }, setLoadState] = useState(initialPostsLoadState); + // Draft restores async after hydration so server and client render the same empty composer first. + // The loaded value is always assigned (even when empty) so text typed for one + // token can never leak into — and be submitted from — another token's composer. + // The reply destination restores with the body: otherwise a reload turns a + // reply draft into a top-level post (parentId: null). + useEffect(() => { + const t = setTimeout(() => { + const d = loadCommentDraft(chain, token); + setBody(d.body.slice(0, POST_MAX)); + setReplyTo(d.parentId); + setShownCount(COMMENTS_PAGE); + setPosts([]); + setLoadState(initialPostsLoadState); + setErr(null); + }, 0); + return () => clearTimeout(t); + }, [chain, token]); const seenPosts = useRef(""); const isCreator = Boolean(address && address.toLowerCase() === launcher.toLowerCase()); + // Guard against stale loads: a fetch for token A resolving after the switch + // to token B must not overwrite B's posts or unblock B's reply validation. + // Same generation + AbortController pattern as PriceChart. + const request = useRef(null); + const generation = useRef(0); const load = useCallback(async () => { + const id = ++generation.current; + request.current?.abort(); + const controller = new AbortController(); + request.current = controller; + const ignored = () => shouldIgnoreLoad(id, generation.current, controller.signal.aborted); try { - const res = await fetch(`/api/posts?chain=${chain}&token=${token}`, { cache: "no-store" }); - if (!res.ok) return; + const res = await fetch(`/api/posts?chain=${chain}&token=${token}`, { cache: "no-store", signal: controller.signal }); + if (ignored()) return; + if (!res.ok) { + // Failure, not proof of anything: keep postsLoaded false so a restored + // reply stays pending (submit blocks) and the draft keeps its destination. + setLoadState((prev) => nextPostsLoadState(prev, { kind: "http-error", status: res.status })); + return; + } const d = (await res.json()) as { posts: PostRow[]; muted: boolean }; + if (!Array.isArray(d.posts)) { + if (ignored()) return; + setLoadState((prev) => nextPostsLoadState(prev, { kind: "invalid" })); + return; + } + if (ignored()) return; setPosts(d.posts); setMuted(d.muted); setNow(nowMs()); + setLoadState((prev) => nextPostsLoadState(prev, { kind: "ok" })); } catch { - /* keep */ + if (ignored()) return; + setLoadState((prev) => nextPostsLoadState(prev, { kind: "network-error" })); } }, [chain, token]); useEffect(() => { - const id = setTimeout(() => void load(), 0); - return () => clearTimeout(id); + const lifetime = generation; + const timer = setTimeout(() => void load(), 0); + return () => { + clearTimeout(timer); + lifetime.current++; + request.current?.abort(); + }; }, [load]); // refresh when the shared poller says there is a newer post on this token useEffect( @@ -85,6 +140,13 @@ export default function TokenComments({ chain, token, symbol, launcher, embedded [subscribe, chain, token, load], ); + // A restored reply target is validated only after the first successful load. + // Before that a restored reply is pending and submit blocks instead of signing + // an unvalidated parent. After load the truncated posts window (latest 100) is + // not authoritative — a missing id there does not mean the parent is gone, so + // we preserve the target and let the server decide (no silent top-level fallback). + const { target: effectiveReplyTo, pending: validationPending } = resolveReplyTarget(replyTo, postsLoaded); + async function submit() { if (!address) return; const v = validateBody(body); @@ -92,18 +154,26 @@ export default function TokenComments({ chain, token, symbol, launcher, embedded setErr(v.error); return; } + // Re-resolve here: posts may have changed since the last render. + const resolved = resolveReplyTarget(replyTo, postsLoaded); + if (resolved.pending) { + setErr("Loading comments — please try again in a moment."); + return; + } + const target = resolved.target; setBusy(true); setErr(null); try { const n = nonce(); const ts = nowMs(); - const message = buildPostMessage({ chain, token, wallet: address, nonce: n, ts, parentId: replyTo, body: v.body }); + const message = buildPostMessage({ chain, token, wallet: address, nonce: n, ts, parentId: target, body: v.body }); const { signature } = await signed(config, chain, message); - const res = await fetch("/api/posts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chain, token, wallet: address, parentId: replyTo, body: v.body, nonce: n, ts, signature }) }); + const res = await fetch("/api/posts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chain, token, wallet: address, parentId: target, body: v.body, nonce: n, ts, signature }) }); const d = (await res.json()) as { ok?: boolean; post?: PostRow; error?: string }; if (!res.ok || !d.post) throw new Error(d.error ?? "post rejected"); setPosts((cur) => [d.post!, ...cur]); setBody(""); + clearDraft(chain, token); setReplyTo(null); } catch (e) { setErr(friendlyError(e)); @@ -145,8 +215,9 @@ export default function TokenComments({ chain, token, symbol, launcher, embedded } } - const top = posts.filter((p) => p.parent_id === null); - const replies = (id: number) => posts.filter((p) => p.parent_id === id).slice().reverse(); + const { top, repliesById } = useMemo(() => groupReplies(posts), [posts]); + const visibleTop = useMemo(() => visibleTopIds(top, shownCount), [top, shownCount]); + const remaining = top.length - visibleTop.length; return (
@@ -172,39 +243,72 @@ export default function TokenComments({ chain, token, symbol, launcher, embedded ) : (
- {replyTo !== null ? ( + {validationPending ? ( +
+

+ Checking reply target… +

+ {replyTo !== null ? ( +

+ Replying to #{replyTo}{" "} + +

+ ) : null} + {loadError ? ( +

+ {loadError}{" "} + +

+ ) : null} +
+ ) : effectiveReplyTo !== null ? (

- Replying to #{replyTo}{" "} -

) : null}