/** * A comment thread, as a card in the sidecar. * * Four states, and the difference between them is who can still act. A draft * has a passage but no thread yet. An open thread takes replies from anyone. * An accepted one is frozen — that is what accepting means — and reads as a * decision. A dismissed one is not rendered at all. * * Accept and dismiss both confirm on a second click, because neither can be * undone: accepting freezes the thread, puts a decision in the document and starts * a turn. That is the same two-click shape `QuestionView` uses for cancelling, * so it is an interaction people have already met here. */ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { ArrowUpIcon, CheckIcon, CloseIcon, MessageIcon } from "@chopin/icons"; import { limits } from "@chopin/dialect"; import { Provenance, SidecarCard, when } from "./card"; import type { KeyboardEvent, ReactNode } from "react"; import type { Comment } from "@chopin/protocol"; import type { ThreadView } from "./threads"; function Who({ handle }: { handle: string }) { return ( @{handle} ); } function Note({ action, note, opening, }: { action?: ReactNode; note: Comment.Note; opening?: boolean; }) { return (
  • {when(note.ts)}
    {action}

    {note.text}

  • ); } function CloseButton({ onClose }: { onClose: () => void }) { return ( ); } function DraftHeader({ onClose, showClose }: { onClose: () => void; showClose: boolean }) { return (
    ); } /** Context is repeated only when its source passage can no longer be reached. */ function Quote({ drifted, text }: { drifted?: boolean; text: string }) { return (
    {text}
    {drifted && (

    This passage has changed since the comment was added.

    )}
    ); } /** * A textarea that submits on Enter. * * Shift-Enter is a newline, which is the convention every chat surface uses and * the one the composer next door already follows. */ function Composer({ autoFocus, busy, label, onCancel, onSend, onTyping, placeholder, insetSend, sendLabel, }: { autoFocus?: boolean; busy?: boolean; label: string; onCancel?: () => void; onSend: (text: string) => void; onTyping?: (writing: boolean) => void; placeholder: string; insetSend?: boolean; sendLabel?: string; }) { let [text, setText] = useState(""); let ref = useRef(null); useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]); useLayoutEffect(() => { let field = ref.current; if (!field) return; field.style.height = "0px"; let height = Math.min(field.scrollHeight, 160); field.style.height = `${height}px`; field.style.overflowY = field.scrollHeight > height ? "auto" : "hidden"; }, [text]); // Whoever is typing stops being told about the moment this goes away, so // an unmount does not leave a caret blinking in somebody else's sidecar. useEffect(() => () => onTyping?.(false), [onTyping]); let send = () => { let value = text.trim(); if (!value || busy) return; setText(""); onTyping?.(false); onSend(value); }; let key = (event: KeyboardEvent) => { if (event.key === "Escape" && onCancel) { event.preventDefault(); return onCancel(); } if (event.key !== "Enter" || event.shiftKey) return; event.preventDefault(); send(); }; return (