import { useEffect, useReducer, useRef } from "react"; import type { FormEvent } from "react"; import type { SurfaceScope } from "./apiBridge"; import type { Viewer } from "./types"; import { deleteRecordComment, fetchRecordComments, postRecordComment, } from "./recordCommentsApi"; import type { RecordComment } from "./recordCommentsApi"; import { initialRecordCommentsState, recordCommentsReducer, } from "./recordCommentsState"; function initials(name: string): string { return ( name .trim() .split(/\s+/) .slice(0, 2) .map((part) => part[0]?.toUpperCase()) .join("") || "?" ); } function when(value: string): string { const date = new Date(value); if (!Number.isFinite(date.getTime())) return value; return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short", }).format(date); } export default function RecordComments({ scope, pid, viewer, }: { /** * ⭐ WAVE 19 (owner item 12) — WHICH DATABASE THIS RECORD BELONGS TO. Required, with no * default, for the reason the Insights tab states at `RecordDetail.tsx`'s `scope` prop: a pid * is only meaningful inside one topic, and asking the customer endpoint about a product's * CRC32 hash either 403s on a screen showing a product (the reported bug) or — on a * collision — answers 200 and files the comment against somebody's customer. */ scope: SurfaceScope; pid: number; viewer?: Viewer; }) { const [state, dispatch] = useReducer( recordCommentsReducer, pid, initialRecordCommentsState ); const { comments, draft, loading, posting, error } = state; const textareaRef = useRef(null); useEffect(() => { const controller = new AbortController(); dispatch({ type: "record", pid }); fetchRecordComments(scope, pid, controller.signal) .then((comments) => dispatch({ type: "loaded", pid, comments })) .catch((reason: unknown) => { if (reason instanceof DOMException && reason.name === "AbortError") return; dispatch({ type: "load-failed", pid, error: reason instanceof Error ? reason.message : "Comments could not be loaded.", }); }); return () => controller.abort(); // ⚠ `scope` is in the deps beside `pid`: the reducer's stale-completion guard keys on the // PID alone, so a surface switch that landed on the same pid would otherwise accept the old // topic's feed. The mount is also keyed by scope+pid (RecordDetail), which makes this belt // and braces — kept because the guard here is the one that survives a host that forgets. }, [scope, pid]); const post = async (event: FormEvent) => { event.preventDefault(); const body = draft.trim(); if (!body || posting) return; const requestPid = pid; dispatch({ type: "posting", pid: requestPid }); try { const comment = await postRecordComment(scope, requestPid, body); dispatch({ type: "posted", pid: requestPid, comment }); if (requestPid === pid) textareaRef.current?.focus(); } catch (reason) { dispatch({ type: "post-failed", pid: requestPid, error: reason instanceof Error ? reason.message : "That comment was not saved.", }); } }; const remove = async (comment: RecordComment) => { const requestPid = pid; try { await deleteRecordComment(scope, requestPid, comment.id); dispatch({ type: "deleted", pid: requestPid, commentId: comment.id }); } catch (reason) { dispatch({ type: "delete-failed", pid: requestPid, error: reason instanceof Error ? reason.message : "That comment was not deleted.", }); } }; return (