fix(comments) : show linked conversations in the normal comment list
This commit is contained in:
@@ -0,0 +1,148 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act, useState } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CommentItem, CommentPage } from "@/lib/comments/types";
|
||||||
|
|
||||||
|
const events = vi.hoisted(() => ({ refresh: undefined as undefined | ((changed: boolean) => Promise<unknown>) }));
|
||||||
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||||
|
vi.mock("./use-comment-events", () => ({ useCommentEvents: (_scope: unknown, refresh: (changed: boolean) => Promise<unknown>) => { events.refresh = refresh; } }));
|
||||||
|
vi.mock("./comment-card", () => ({ CommentCard: ({ item, defaultExpanded, focusReplyId, onChange }: {
|
||||||
|
item: CommentItem; defaultExpanded: boolean; focusReplyId?: string; onChange: () => Promise<unknown>;
|
||||||
|
}) => {
|
||||||
|
const [expanded] = useState(defaultExpanded);
|
||||||
|
return <article id={`comment-${item.id}`} data-expanded={expanded} data-focus-reply={focusReplyId}>{item.text}<button onClick={() => void onChange()}>Update {item.id}</button></article>;
|
||||||
|
} }));
|
||||||
|
|
||||||
|
import { CommentBoard } from "./comment-board";
|
||||||
|
|
||||||
|
const target = "guide:test";
|
||||||
|
const selectedId = "00000000-0000-4000-8000-000000000001";
|
||||||
|
const replyId = "00000000-0000-4000-8000-000000000002";
|
||||||
|
const secondId = "00000000-0000-4000-8000-000000000003";
|
||||||
|
function comment(id: string, text = id): CommentItem {
|
||||||
|
return {
|
||||||
|
id, rootId: null, replyToId: null, replyToName: null,
|
||||||
|
authorName: "Author", authorImage: null, authorAdmin: false, text, images: [], version: 1,
|
||||||
|
createdAt: "2026-10-08T12:00:00Z", editedAt: "2026-10-08T12:00:00Z",
|
||||||
|
hidden: false, ownHidden: false, deleted: false, hearted: false, likes: 0, reaction: 0,
|
||||||
|
replyCount: 1, hasReplies: true, canEdit: false, canInteract: true, canModerate: false,
|
||||||
|
target, targetName: "Test", targetImage: null, href: "/test/comment",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function page(items: CommentItem[], nextCursor: string | null = null): CommentPage { return { items, nextCursor, viewer: null }; }
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let inbox: CommentPage;
|
||||||
|
let nextPage: CommentPage;
|
||||||
|
let selected: CommentItem;
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
window.history.replaceState(null, "", `/test/comment?reply=${replyId}#comment-${selectedId}`);
|
||||||
|
selected = comment(selectedId, "Selected conversation");
|
||||||
|
inbox = page([comment("newer", "Newer conversation")]);
|
||||||
|
nextPage = page([]);
|
||||||
|
fetchMock.mockReset().mockImplementation(async (input) => {
|
||||||
|
const url = new URL(String(input), "https://guide.sudloh.com");
|
||||||
|
return Response.json(url.pathname === "/api/comments" ? url.searchParams.has("cursor") ? nextPage : inbox : { item: selected, viewer: null });
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
async function mount(initial = inbox) { await act(async () => root.render(<CommentBoard target={target} initial={initial} />)); }
|
||||||
|
function cards() { return [...container.querySelectorAll("article[id]")].map(item => item.id); }
|
||||||
|
function singleRequests() { return fetchMock.mock.calls.filter(([url]) => String(url) === `/api/comments/${selectedId}`); }
|
||||||
|
async function click(label: string) {
|
||||||
|
const button = [...container.querySelectorAll("button")].find(item => item.textContent?.trim() === label || item.getAttribute("aria-label") === label);
|
||||||
|
expect(button).toBeDefined(); await act(async () => button!.click());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("linked public comment conversations", () => {
|
||||||
|
it("moves a loaded root first, expands it, and passes the exact reply to focus without another lookup", async () => {
|
||||||
|
await mount(page([...inbox.items, selected]));
|
||||||
|
expect(cards()).toEqual([`comment-${selectedId}`, "comment-newer"]);
|
||||||
|
const card = container.querySelector(`#comment-${selectedId}`);
|
||||||
|
expect(card?.getAttribute("data-expanded")).toBe("true");
|
||||||
|
expect(card?.getAttribute("data-focus-reply")).toBe(replyId);
|
||||||
|
expect(singleRequests()).toHaveLength(0);
|
||||||
|
expect(container.textContent).toContain("ความคิดเห็น");
|
||||||
|
expect(container.textContent).not.toContain("ปิดการสนทนา");
|
||||||
|
expect(container.querySelector('section[aria-label="การสนทนาที่เลือก"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads an older root and deduplicates it when more comments include it", async () => {
|
||||||
|
await mount(page(inbox.items, "next-page"));
|
||||||
|
expect(cards()).toEqual([`comment-${selectedId}`, "comment-newer"]);
|
||||||
|
nextPage = page([comment(selectedId, "Updated page content"), comment("oldest")]);
|
||||||
|
await click("ดูความคิดเห็นเพิ่มเติม");
|
||||||
|
expect(cards()).toEqual([`comment-${selectedId}`, "comment-newer", "comment-oldest"]);
|
||||||
|
expect(container.textContent).toContain("Updated page content");
|
||||||
|
expect(singleRequests()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes older roots after interactions, manual refresh, and live changes", async () => {
|
||||||
|
await mount();
|
||||||
|
selected = comment(selectedId, "After interaction"); await click(`Update ${selectedId}`);
|
||||||
|
expect(container.textContent).toContain("After interaction");
|
||||||
|
selected = comment(selectedId, "After refresh"); await click("รีเฟรชความคิดเห็น");
|
||||||
|
expect(container.textContent).toContain("After refresh");
|
||||||
|
selected = comment(selectedId, "After live change"); await act(async () => { await events.refresh!(true); });
|
||||||
|
expect(container.textContent).toContain("After live change");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the selected conversation and reply when sorting while preserving other URL parameters", async () => {
|
||||||
|
window.history.replaceState(null, "", `/stygian/comment?schedule=123&reply=${replyId}#comment-${selectedId}`);
|
||||||
|
await mount(); inbox = page([comment("sorted")]); await click("ล่าสุด");
|
||||||
|
expect(cards()).toEqual(["comment-sorted"]);
|
||||||
|
expect(window.location.hash).toBe("");
|
||||||
|
expect(window.location.search).toBe("?schedule=123");
|
||||||
|
expect(singleRequests()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an old lookup when the hash points to a different root", async () => {
|
||||||
|
let resolve!: (response: Response) => void;
|
||||||
|
fetchMock.mockImplementationOnce(() => new Promise<Response>(done => { resolve = done; }));
|
||||||
|
await mount();
|
||||||
|
const signal = singleRequests()[0][1]?.signal;
|
||||||
|
selected = comment(secondId, "Second selected conversation");
|
||||||
|
await act(async () => {
|
||||||
|
window.history.replaceState(null, "", `/test/comment#comment-${secondId}`);
|
||||||
|
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||||
|
});
|
||||||
|
expect(signal?.aborted).toBe(true);
|
||||||
|
await act(async () => resolve(Response.json({ item: comment(selectedId), viewer: null })));
|
||||||
|
expect(cards()).toEqual([`comment-${secondId}`, "comment-newer"]);
|
||||||
|
expect(container.querySelector(`#comment-${secondId}`)?.hasAttribute("data-focus-reply")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["missing", "wrong target", "reply"])("shows an inline error for a %s root and supports retry through refresh", async (failure) => {
|
||||||
|
if (failure === "missing") fetchMock.mockResolvedValueOnce(Response.json({ error: "comment-not-found" }, { status: 404 }));
|
||||||
|
else selected = { ...selected, ...(failure === "reply" ? { rootId: secondId } : { target: "guide:other" }) };
|
||||||
|
await mount();
|
||||||
|
expect(container.querySelector('[role="alert"]')).not.toBeNull();
|
||||||
|
expect(cards()).toEqual(["comment-newer"]);
|
||||||
|
selected = comment(selectedId); await click("รีเฟรชความคิดเห็น");
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||||
|
expect(cards()).toEqual([`comment-${selectedId}`, "comment-newer"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps loading feedback without claiming the board is empty", async () => {
|
||||||
|
fetchMock.mockImplementationOnce(() => new Promise<Response>(() => {}));
|
||||||
|
await mount(page([]));
|
||||||
|
expect(container.querySelector('[role="status"]')).not.toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("ยังไม่มีความคิดเห็น");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves normal order without a comment hash", async () => {
|
||||||
|
window.history.replaceState(null, "", "/test/comment");
|
||||||
|
await mount(page([...inbox.items, selected]));
|
||||||
|
expect(cards()).toEqual(["comment-newer", `comment-${selectedId}`]);
|
||||||
|
expect(singleRequests()).toHaveLength(0);
|
||||||
|
expect(container.querySelector(`#comment-${selectedId}`)?.getAttribute("data-expanded")).toBe("false");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,10 +9,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
||||||
import type { CommentPage } from "@/lib/comments/types";
|
import type { CommentItem, CommentPage } from "@/lib/comments/types";
|
||||||
import { CommentComposer } from "./comment-composer";
|
import { CommentComposer } from "./comment-composer";
|
||||||
import { CommentCard, CommentThread } from "./comment-card";
|
import { CommentCard } from "./comment-card";
|
||||||
import { commentSignInHref } from "./client";
|
import { commentRequest, commentSignInHref } from "./client";
|
||||||
import { CommentSkeleton } from "./comment-skeleton";
|
import { CommentSkeleton } from "./comment-skeleton";
|
||||||
import { useCommentEvents } from "./use-comment-events";
|
import { useCommentEvents } from "./use-comment-events";
|
||||||
import { useCommentFeed } from "./use-comment-feed";
|
import { useCommentFeed } from "./use-comment-feed";
|
||||||
@@ -22,39 +22,64 @@ export function CommentBoard({ target, initial }: { target: string; initial: Com
|
|||||||
const [sort, setSort] = useState("top");
|
const [sort, setSort] = useState("top");
|
||||||
const [linkedReply, setLinkedReply] = useState<string | null>(null);
|
const [linkedReply, setLinkedReply] = useState<string | null>(null);
|
||||||
const [linkedThread, setLinkedThread] = useState<string | null>(null);
|
const [linkedThread, setLinkedThread] = useState<string | null>(null);
|
||||||
|
const [linkedResult, setLinkedResult] = useState<{ rootId: string; target: string; item: CommentItem | null; error: string } | null>(null);
|
||||||
const feed = useCommentFeed(`/api/comments?target=${encodeURIComponent(target)}&sort=${sort}`, initial);
|
const feed = useCommentFeed(`/api/comments?target=${encodeURIComponent(target)}&sort=${sort}`, initial);
|
||||||
|
const loadedThread = feed.page.items.find((item) => item.id === linkedThread && item.target === target && !item.rootId);
|
||||||
|
const threadLoaded = Boolean(loadedThread);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!linkedThread || threadLoaded) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
void Promise.resolve().then(async () => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setLinkedResult((current) => ({ rootId: linkedThread, target, item: current?.rootId === linkedThread && current.target === target ? current.item : null, error: "" }));
|
||||||
|
try {
|
||||||
|
const result = await commentRequest<{ item: CommentItem }>(`/api/comments/${linkedThread}`, { signal: controller.signal });
|
||||||
|
if (!result.item || result.item.id !== linkedThread || result.item.target !== target || result.item.rootId) throw new Error("ไม่พบการสนทนาที่เลือก");
|
||||||
|
if (!controller.signal.aborted) setLinkedResult({ rootId: linkedThread, target, item: result.item, error: "" });
|
||||||
|
} catch (cause) {
|
||||||
|
if (!controller.signal.aborted) setLinkedResult({ rootId: linkedThread, target, item: null, error: cause instanceof Error ? cause.message : "ไม่สามารถโหลดการสนทนาได้" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [linkedThread, target, threadLoaded, feed.revision]);
|
||||||
|
const result = linkedResult?.rootId === linkedThread && linkedResult.target === target ? linkedResult : null;
|
||||||
|
const selectedItem = linkedThread ? loadedThread ?? result?.item : null;
|
||||||
|
const selectedError = linkedThread && !threadLoaded ? result?.error : "";
|
||||||
|
const selectedBusy = Boolean(linkedThread && !selectedItem && !selectedError);
|
||||||
|
const items = selectedItem ? [selectedItem, ...feed.page.items.filter((item) => item.id !== linkedThread)] : feed.page.items;
|
||||||
useCommentEvents({ target }, (changed) => feed.refresh({ silent: true, forceRevision: changed }));
|
useCommentEvents({ target }, (changed) => feed.refresh({ silent: true, forceRevision: changed }));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const showThread = () => {
|
const showThread = () => {
|
||||||
const match = /^#comment-([0-9a-f-]{36})$/i.exec(window.location.hash);
|
const match = /^#comment-([0-9a-f-]{36})$/i.exec(window.location.hash);
|
||||||
if (match) {
|
setLinkedThread(match?.[1] ?? null);
|
||||||
setLinkedThread(match[1]);
|
const reply = new URLSearchParams(window.location.search).get("reply");
|
||||||
const reply = new URLSearchParams(window.location.search).get("reply");
|
setLinkedReply(match && reply && /^[0-9a-f-]{36}$/i.test(reply) ? reply : null);
|
||||||
setLinkedReply(reply && /^[0-9a-f-]{36}$/i.test(reply) ? reply : null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
showThread();
|
showThread();
|
||||||
window.addEventListener("hashchange", showThread);
|
window.addEventListener("hashchange", showThread);
|
||||||
return () => window.removeEventListener("hashchange", showThread);
|
return () => window.removeEventListener("hashchange", showThread);
|
||||||
}, []);
|
}, []);
|
||||||
return <div className="flex flex-col gap-6 py-2">
|
return <div className="flex flex-col gap-6 py-2">
|
||||||
<div className="flex items-start gap-3"><Avatar className="size-10 shrink-0"><AvatarImage src={feed.page.viewer?.image ?? undefined} alt={feed.page.viewer?.name ?? "โปรไฟล์"} /><AvatarFallback>{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}</AvatarFallback></Avatar><div className="min-w-0 flex-1">{feed.page.viewer ? <CommentComposer target={target} onSaved={() => void feed.refresh()} />
|
<div className="flex items-start gap-3"><Avatar className="size-10 shrink-0"><AvatarImage src={feed.page.viewer?.image ?? undefined} alt={feed.page.viewer?.name ?? "โปรไฟล์"} /><AvatarFallback>{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}</AvatarFallback></Avatar><div className="min-w-0 flex-1">{feed.page.viewer ? <CommentComposer target={target} onSaved={() => void feed.refresh({ silent: true, forceRevision: true })} />
|
||||||
: <div className="flex flex-col items-start gap-2"><p className="text-sm text-muted-foreground">{"เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ"}</p><Button onClick={() => router.push(commentSignInHref())}>{"เข้าสู่ระบบ"}</Button></div>}</div></div>
|
: <div className="flex flex-col items-start gap-2"><p className="text-sm text-muted-foreground">{"เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ"}</p><Button onClick={() => router.push(commentSignInHref())}>{"เข้าสู่ระบบ"}</Button></div>}</div></div>
|
||||||
<div className="order-first flex flex-wrap items-center gap-3"><h2 className="font-heading text-xl font-semibold">{"ความคิดเห็น"}</h2>
|
<div className="order-first flex flex-wrap items-center gap-3"><h2 className="font-heading text-xl font-semibold">{"ความคิดเห็น"}</h2>
|
||||||
<ToggleGroup value={[sort]} onValueChange={(values) => { if (values[0]) setSort(values[0]); }} variant="outline" aria-label={"เรียงความคิดเห็น"}>
|
<ToggleGroup value={[sort]} onValueChange={(values) => { if (values[0] && values[0] !== sort) {
|
||||||
|
setLinkedThread(null); setLinkedReply(null); setSort(values[0]);
|
||||||
|
const url = new URL(window.location.href); url.hash = ""; url.searchParams.delete("reply");
|
||||||
|
window.history.replaceState(null, "", url.pathname + url.search);
|
||||||
|
} }} variant="outline" aria-label={"เรียงความคิดเห็น"}>
|
||||||
<ToggleGroupItem value="newest">{"ล่าสุด"}</ToggleGroupItem><ToggleGroupItem value="top">{"ยอดนิยม"}</ToggleGroupItem>
|
<ToggleGroupItem value="newest">{"ล่าสุด"}</ToggleGroupItem><ToggleGroupItem value="top">{"ยอดนิยม"}</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
<Button variant="ghost" className="ml-auto rounded-full" aria-label={"รีเฟรชความคิดเห็น"} size="icon" disabled={feed.busy} onClick={() => void feed.refresh()}><RefreshCwIcon /></Button>
|
<Button variant="ghost" className="ml-auto rounded-full" aria-label={"รีเฟรชความคิดเห็น"} size="icon" disabled={feed.busy} onClick={() => void feed.refresh({ silent: false, forceRevision: true })}><RefreshCwIcon /></Button>
|
||||||
</div>
|
</div>
|
||||||
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
|
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
|
||||||
{feed.page.items.filter((item) => item.id !== linkedThread).map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} onChange={() => feed.refresh({ silent: true })} revision={feed.revision} />)}
|
{selectedError && <Alert variant="destructive"><AlertDescription>{selectedError}</AlertDescription></Alert>}
|
||||||
{!feed.page.items.length && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>{"ยังไม่มีความคิดเห็น"}</EmptyTitle><EmptyDescription>{"เป็นคนแรกที่ร่วมพูดคุยเกี่ยวกับไกด์นี้"}</EmptyDescription></EmptyHeader></Empty>}
|
{selectedBusy && <CommentSkeleton count={1} />}
|
||||||
|
{items.map((item) => <CommentCard key={item.id === linkedThread ? `${item.id}:linked:${linkedReply ?? ""}` : item.id} item={item} viewer={feed.page.viewer}
|
||||||
|
defaultExpanded={item.id === linkedThread} focusReplyId={item.id === linkedThread ? linkedReply ?? undefined : undefined}
|
||||||
|
onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision} />)}
|
||||||
|
{!items.length && !feed.busy && !feed.error && !selectedBusy && !selectedError && <Empty><EmptyHeader><EmptyTitle>{"ยังไม่มีความคิดเห็น"}</EmptyTitle><EmptyDescription>{"เป็นคนแรกที่ร่วมพูดคุยเกี่ยวกับไกด์นี้"}</EmptyDescription></EmptyHeader></Empty>}
|
||||||
{feed.busy && <CommentSkeleton count={feed.page.items.length ? 1 : 3} />}
|
{feed.busy && <CommentSkeleton count={feed.page.items.length ? 1 : 3} />}
|
||||||
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>{"ดูความคิดเห็นเพิ่มเติม"}</Button>}
|
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>{"ดูความคิดเห็นเพิ่มเติม"}</Button>}
|
||||||
{linkedThread && <section aria-label={"การสนทนาที่เลือก"} className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between gap-2"><h3 className="font-semibold">{"การสนทนา"}</h3><Button variant="ghost" onClick={() => { setLinkedThread(null); setLinkedReply(null); window.history.replaceState(null, "", window.location.pathname + window.location.search); }}>{"ปิดการสนทนา"}</Button></div>
|
|
||||||
<CommentThread rootId={linkedThread} focusReplyId={linkedReply ?? undefined} expectedTarget={target} onChange={() => feed.refresh({ silent: true })} />
|
|
||||||
</section>}
|
|
||||||
|
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
|||||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
import { CommentSkeleton, CommentThreadSkeleton } from "./comment-skeleton";
|
import { CommentSkeleton } from "./comment-skeleton";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { TableCell, TableRow } from "@/components/ui/table";
|
import { TableCell, TableRow } from "@/components/ui/table";
|
||||||
import type { CommentHistoryItem, CommentImage, CommentItem, CommentViewer } from "@/lib/comments/types";
|
import type { CommentHistoryItem, CommentImage, CommentItem, CommentViewer } from "@/lib/comments/types";
|
||||||
import { CommentImageView } from "./comment-image";
|
import { CommentImageView } from "./comment-image";
|
||||||
import { CommentComposer } from "./comment-composer";
|
import { CommentComposer } from "./comment-composer";
|
||||||
import { commentRequest, commentSignInHref } from "./client";
|
import { commentRequest, commentSignInHref } from "./client";
|
||||||
import { useCommentEvents } from "./use-comment-events";
|
|
||||||
import { useCommentFeed } from "./use-comment-feed";
|
import { useCommentFeed } from "./use-comment-feed";
|
||||||
|
|
||||||
// A single minute clock for all timestamps, with a stable SSR snapshot.
|
// A single minute clock for all timestamps, with a stable SSR snapshot.
|
||||||
@@ -55,7 +54,7 @@ function CommentTimestamp({ value }: { value: string }) {
|
|||||||
}
|
}
|
||||||
return <time dateTime={value} title={value} className="text-xs text-muted-foreground">{now > 0
|
return <time dateTime={value} title={value} className="text-xs text-muted-foreground">{now > 0
|
||||||
? relativeTime()
|
? relativeTime()
|
||||||
: `${value.slice(0, 16).replace("T", " ")} UTC`}</time>;
|
: `${value.slice(0, 16).replace("T")}`}</time>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommentImages({ images }: { images: CommentImage[] }) {
|
export function CommentImages({ images }: { images: CommentImage[] }) {
|
||||||
@@ -87,7 +86,7 @@ export function CommentImages({ images }: { images: CommentImage[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CommentText({ text }: { text: string }) {
|
function CommentText({ text }: { text: string }) {
|
||||||
return <p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{text.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) =>
|
return <p className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed">{text.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) =>
|
||||||
/^https?:\/\//.test(part) ? <a key={i} href={part} rel="nofollow ugc noopener noreferrer" target="_blank" className="text-primary underline underline-offset-4">{part}</a> : part)}</p>;
|
/^https?:\/\//.test(part) ? <a key={i} href={part} rel="nofollow ugc noopener noreferrer" target="_blank" className="text-primary underline underline-offset-4">{part}</a> : part)}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +232,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
|||||||
{item.version > 1 && canReadHistory && <Button variant="link" size="xs" onClick={() => setHistory(true)}>{"แก้ไขแล้ว"}</Button>}
|
{item.version > 1 && canReadHistory && <Button variant="link" size="xs" onClick={() => setHistory(true)}>{"แก้ไขแล้ว"}</Button>}
|
||||||
</div>
|
</div>
|
||||||
{/* Inbox replies reach across the 280px content column and 24px cell padding. */}
|
{/* Inbox replies reach across the 280px content column and 24px cell padding. */}
|
||||||
{!inbox && optionsMenu && <div className={cn("shrink-0", inboxReply && "md:absolute md:top-2 md:right-[-304px]")}>{optionsMenu}</div>}
|
{!inbox && optionsMenu && <div className={cn("shrink-0", inboxReply && "md:absolute md:top-2 md:-right-76")}>{optionsMenu}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("contents", fullWidthMobile && "max-sm:col-span-2 max-sm:flex max-sm:min-w-0 max-sm:flex-col max-sm:gap-2")}>
|
<div className={cn("contents", fullWidthMobile && "max-sm:col-span-2 max-sm:flex max-sm:min-w-0 max-sm:flex-col max-sm:gap-2")}>
|
||||||
{item.replyToName && <p className="text-xs text-muted-foreground">{"ตอบกลับ"} {item.replyToName}</p>}
|
{item.replyToName && <p className="text-xs text-muted-foreground">{"ตอบกลับ"} {item.replyToName}</p>}
|
||||||
@@ -339,32 +338,3 @@ function CommentReplyTree({ items, viewer, onChange, focusReplyId, moderationDet
|
|||||||
}
|
}
|
||||||
return <div className="flex flex-col gap-4">{branch(null)}</div>;
|
return <div className="flex flex-col gap-4">{branch(null)}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommentThread({ rootId, expectedTarget, onChange, focusReplyId }: { focusReplyId?: string; rootId: string; expectedTarget: string; onChange: () => void | Promise<unknown> }) {
|
|
||||||
const [data, setData] = useState<{ item: CommentItem; viewer: CommentViewer | null } | null>(null);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [revision, setRevision] = useState(0);
|
|
||||||
const lastThread = useRef("");
|
|
||||||
const load = useCallback(async (forceReplies = false) => {
|
|
||||||
try {
|
|
||||||
const result = await commentRequest<{ item: CommentItem; viewer: CommentViewer | null }>(`/api/comments/${rootId}`);
|
|
||||||
if (result.item.target !== expectedTarget) throw new Error("การสนทนานี้เป็นของไกด์อื่น");
|
|
||||||
const payload = JSON.stringify(result);
|
|
||||||
if (lastThread.current !== payload) {
|
|
||||||
lastThread.current = payload; setData(result); setRevision((value) => value + 1);
|
|
||||||
} else if (forceReplies) setRevision((value) => value + 1);
|
|
||||||
setError("");
|
|
||||||
} catch (cause) { lastThread.current = ""; setData(null); setError(cause instanceof Error ? cause.message : "ไม่สามารถโหลดการสนทนาได้"); }
|
|
||||||
}, [rootId, expectedTarget]);
|
|
||||||
useCommentEvents({ target: expectedTarget }, load);
|
|
||||||
useEffect(() => {
|
|
||||||
void Promise.resolve().then(() => load());
|
|
||||||
|
|
||||||
}, [load]);
|
|
||||||
return <div className="flex min-w-0 flex-col gap-3" aria-busy={!data && !error}>
|
|
||||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
|
||||||
{data && <CommentCard item={data.item} viewer={data.viewer} moderationDetails={Boolean(data.viewer?.admin)} defaultExpanded focusReplyId={focusReplyId} revision={revision} onChange={async () => { await load(true); await onChange(); }} />}
|
|
||||||
{!data && !error && <CommentThreadSkeleton />}
|
|
||||||
{error && <Button variant="outline" onClick={() => void load()}>{"ลองใหม่"}</Button>}
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,10 +59,3 @@ export function CommentBoardSkeleton() {
|
|||||||
<CommentSkeleton />
|
<CommentSkeleton />
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommentThreadSkeleton() {
|
|
||||||
return <div className="flex flex-col gap-4">
|
|
||||||
<CommentSkeleton count={1} />
|
|
||||||
<div className="ml-5 border-l border-border pl-8"><CommentSkeleton count={2} /></div>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user