| 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<HTMLTextAreaElement>(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 ( |
| <aside className="cg-record-comments" aria-label="Record comments"> |
| <div className="cg-record-comments-head"> |
| <h2>Comments</h2> |
| <span>{comments.length.toLocaleString()}</span> |
| </div> |
| <div className="cg-record-comment-feed" aria-live="polite"> |
| {/* wave17 GRID — item 3 / R6. The feed is `aria-live="polite"`, so the spinner's |
| `aria-label` is what it announces; dropping it would make the wait silent. */} |
| {loading && ( |
| <div className="cg-record-comment-state"> |
| <span className="lp-spin" role="status" aria-label="Loading" /> |
| </div> |
| )} |
| {!loading && comments.length === 0 && !error && ( |
| <div className="cg-record-comment-state"> |
| No comments yet. Add context for the team here. |
| </div> |
| )} |
| {comments.map((comment) => { |
| const canDelete = |
| viewer?.isAdmin === true || |
| viewer?.name?.trim().toLowerCase() === comment.authorKey.toLowerCase(); |
| return ( |
| <article className="cg-record-comment" key={comment.id}> |
| <div className="cg-record-comment-avatar" aria-hidden> |
| {initials(comment.author)} |
| </div> |
| <div className="cg-record-comment-content"> |
| <div className="cg-record-comment-meta"> |
| <strong>{comment.author}</strong> |
| <time dateTime={comment.createdAt}>{when(comment.createdAt)}</time> |
| {canDelete && ( |
| <button |
| type="button" |
| className="cg-record-comment-delete" |
| aria-label={`Delete comment by ${comment.author}`} |
| title="Delete comment" |
| onClick={() => void remove(comment)} |
| > |
| × |
| </button> |
| )} |
| </div> |
| <p>{comment.body}</p> |
| </div> |
| </article> |
| ); |
| })} |
| {error && <div className="cg-record-comment-error">{error}</div>} |
| </div> |
| <form className="cg-record-comment-compose" onSubmit={(event) => void post(event)}> |
| <label htmlFor={`cg-comment-${pid}`}>Add a comment</label> |
| <textarea |
| ref={textareaRef} |
| id={`cg-comment-${pid}`} |
| value={draft} |
| maxLength={4000} |
| placeholder="Share an update or leave context…" |
| onChange={(event) => |
| dispatch({ type: "draft", pid, draft: event.target.value }) |
| } |
| onKeyDown={(event) => { |
| if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { |
| event.preventDefault(); |
| event.currentTarget.form?.requestSubmit(); |
| } |
| }} |
| /> |
| <div className="cg-record-comment-compose-foot"> |
| <span>Ctrl/⌘ + Enter to post</span> |
| <button type="submit" disabled={posting || !draft.trim()}> |
| {posting ? "Posting…" : "Comment"} |
| </button> |
| </div> |
| </form> |
| </aside> |
| ); |
| } |
|
|