/** * 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, ChevronIcon, CloseIcon, MessageIcon } from "@chopin/icons"; import { limits } from "@chopin/dialect"; import { Provenance, SidecarCard, when } from "./card"; import { displayName } from "./display-name"; import { Face } from "./face"; import { PRIMARY_COARSE_POINTER_QUERY } from "./pointer"; import type { KeyboardEvent, ReactNode } from "react"; import type { Comment } from "@chopin/protocol"; import type { ThreadView } from "./threads"; /** Chat's author row: face, display name, quiet timestamp. The handle stays in the name. */ export function Author( { handle, textSize = "sm", ts }: { handle: string; textSize?: "xs" | "sm"; ts?: number; }, ) { return ( {displayName(handle)} (@{handle}) {ts !== undefined && ( {when(ts)} )} ); } function Note({ action, note, opening, }: { action?: ReactNode; note: Comment.Note; opening?: boolean; }) { return (
  • {action}

    {note.text}

  • ); } function CloseButton({ onClose }: { onClose: () => void }) { return ( ); } function DraftHeader({ onClose }: { onClose: () => void }) { 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.

    )}
    ); } /** * What a key does in a comment composer. * * Enter sends and Shift-Enter is a newline, the convention the chat composer * follows. A soft keyboard has no Shift-Enter, so on a coarse pointer Enter is a * newline and only the send button sends. Enter that confirms an IME candidate * never sends. */ export function composerKey( event: { key: string; shiftKey: boolean; isComposing: boolean; keyCode?: number }, coarse: boolean, ): "send" | "cancel" | undefined { if (event.key === "Escape") return "cancel"; if (event.key !== "Enter" || event.shiftKey || coarse) return undefined; if (event.isComposing || event.keyCode === 229) return undefined; return "send"; } /** A textarea with its send button inside, shared by new comments and replies. */ function Composer({ autoFocus, busy, label, onCancel, onSend, onTyping, placeholder, sendLabel, }: { autoFocus?: boolean; busy?: boolean; label: string; onCancel?: () => void; onSend: (text: string) => void; onTyping?: (writing: boolean) => void; placeholder: string; 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) => { let action = composerKey( { key: event.key, shiftKey: event.shiftKey, isComposing: event.nativeEvent.isComposing, keyCode: event.keyCode, }, matchMedia(PRIMARY_COARSE_POINTER_QUERY).matches, ); if (action === "cancel" && onCancel) { event.preventDefault(); onCancel(); } else if (action === "send") { event.preventDefault(); send(); } }; return (