"use client"; // Composer (spec "Composer", ADR 0001). Styles live in src/styles/composer.css. import { useEffect, useRef, useState, type ChangeEvent, type FocusEvent, type FormEvent, type KeyboardEvent, } from "react"; import { FlipButton, type FlipState } from "./Buttons"; import { HexAvatar } from "./HexAvatar"; import { attachmentMeta, COMPOSER_EMOJI, insertBullets } from "./composer-text"; export interface ComposerProps { value: string; busy: boolean; error: boolean; /** The selected image or video file, when the pickers are wired. */ file?: File | null; onChange: (value: string) => void; /** When present, the composer shows image and video pickers and reports the choice. */ onFile?: (file: File | null) => void; onSubmit: () => void; /** The signed-in person, shown as a hex avatar beside the field. */ person?: { name: string; email?: string; pictureUrl?: string | null } | null; } // Content types the picker offers (spec: allowed image types). const IMAGE_ACCEPT = "image/jpeg,image/jpg,image/png,image/gif,image/webp,image/heic"; const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm"; const iconProps = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true, }; /** True for Ctrl + Enter or Cmd + Enter. */ export function isPublishShortcut(e: { key: string; ctrlKey: boolean; metaKey: boolean }): boolean { return e.key === "Enter" && (e.ctrlKey || e.metaKey); } export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit, person }: ComposerProps) { const empty = value.trim().length === 0; // A post needs either text or a file. const canSubmit = (!empty || !!file) && !busy; const [preview, setPreview] = useState(null); const [focused, setFocused] = useState(false); const [emojiOpen, setEmojiOpen] = useState(false); const text = useRef(null); const imageInput = useRef(null); const videoInput = useRef(null); const emojiWrap = useRef(null); // Caret to restore after a toolbar edit re-renders the text. const pendingSelection = useRef<[number, number] | null>(null); // Build a preview URL client-side only, and revoke it when the file changes. useEffect(() => { if (!file) { setPreview(null); return; } const url = URL.createObjectURL(file); setPreview(url); return () => URL.revokeObjectURL(url); }, [file]); useEffect(() => { const sel = pendingSelection.current; if (!sel || !text.current) return; pendingSelection.current = null; text.current.focus(); text.current.setSelectionRange(sel[0], sel[1]); }, [value]); // Close the emoji popover on an outside click or Escape. useEffect(() => { if (!emojiOpen) return; function onDown(e: MouseEvent) { if (emojiWrap.current && !emojiWrap.current.contains(e.target as Node)) setEmojiOpen(false); } function onKey(e: globalThis.KeyboardEvent) { if (e.key === "Escape") setEmojiOpen(false); } document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [emojiOpen]); useEffect(() => { if (busy) setEmojiOpen(false); }, [busy]); function submit(e: FormEvent) { e.preventDefault(); if (canSubmit) onSubmit(); } function onKeyDown(e: KeyboardEvent) { if (!isPublishShortcut(e)) return; e.preventDefault(); if (canSubmit) onSubmit(); } function pick(e: ChangeEvent) { const chosen = e.target.files && e.target.files[0] ? e.target.files[0] : null; onFile?.(chosen); // One file per post: picking one kind clears the other input. e.target.value = ""; } function remove() { onFile?.(null); } function addEmoji(emoji: string) { // Spec: the emoji goes at the end of the text. const next = value + emoji; pendingSelection.current = [next.length, next.length]; onChange(next); setEmojiOpen(false); } function addBullets() { const el = text.current; const start = el ? el.selectionStart : value.length; const end = el ? el.selectionEnd : value.length; const edit = insertBullets(value, start, end); pendingSelection.current = [edit.selectionStart, edit.selectionEnd]; if (edit.text !== value) onChange(edit.text); else el?.focus(); } function onFocus() { setFocused(true); } function onBlur(e: FocusEvent) { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); } const isVideo = !!file && file.type.startsWith("video/"); const open = focused || !empty || !!file; const flip: FlipState = busy ? "posting" : canSubmit ? "ready" : "disabled"; return (
{person ? : null}