import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowUp, Check, ChevronsRight, Loader2, Mic, Plus, Square, X } from "lucide-react"; import { cn } from "../lib/cn"; import { motion as m } from "../lib/motion"; import { useLongPress } from "../lib/useLongPress"; import { IconButton } from "./IconButton"; import { useCrispStrings } from "../lib/strings"; export interface ComposerAttachment { id: string; /** Image data URL, or undefined for non-image files (shown as a chip). */ src?: string; name?: string; } export interface ComposerMic { active: boolean; transcribing?: boolean; elapsedMs: number; analyser: AnalyserNode | null; /** The input level from 0 to 1, for a recorder that has no `analyser` in the page. */ level?: number; onStart: () => void; onStop: () => void; onCancel: () => void; disabled?: boolean; } export interface ComposerProps { value: string; onChange: (value: string) => void; onSend: () => void; /** Shown while `sending`; if absent the button just spins. */ onStop?: () => void; /** Empty composer + tap send = let the character continue. */ onContinue?: () => void; /** Long-press / right-click on send. */ onSendAsSystem?: () => void; onAttach?: () => void; attachments?: ComposerAttachment[]; onRemoveAttachment?: (id: string) => void; mic?: ComposerMic; sending?: boolean; disabled?: boolean; placeholder?: string; /** Enter sends on desktop; on touch, Enter is a newline. */ enterSends?: boolean; /** Rendered above the pill — an . */ notice?: ReactNode; /** Slot inside the pill above the input — author's note, quick snippets. */ topSlot?: ReactNode; className?: string; } type Action = "stop" | "send" | "busy" | "continue"; export function Composer({ value, onChange, onSend, onStop, onContinue, onSendAsSystem, onAttach, attachments = [], onRemoveAttachment, mic, sending = false, disabled = false, placeholder, enterSends = true, notice, topSlot, className, }: ComposerProps) { const { strings: t } = useCrispStrings(); const textareaRef = useRef(null); const hasDraft = value.trim().length > 0; const hasContent = hasDraft || attachments.length > 0; const recording = Boolean(mic?.active); const action: Action = sending ? (onStop ? "stop" : "busy") : hasContent ? "send" : "continue"; const actionDisabled = disabled || action === "busy" || (action === "continue" && !onContinue); useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 160)}px`; }, [value]); const longPress = useLongPress(onSendAsSystem, { enabled: action === "send" && !disabled }); const runAction = () => { if (action === "stop") onStop?.(); else if (action === "send") onSend(); else if (action === "continue") onContinue?.(); }; const onKeyDown = (event: KeyboardEvent) => { if (!enterSends || event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; event.preventDefault(); if (!sending && !disabled && hasContent) onSend(); }; const actionStyle: Record = { stop: "bg-danger text-fg", send: "bg-accent text-on-accent", busy: "bg-fill-3 text-fg-3", continue: "border border-accent/30 bg-accent/15 text-accent", }; const actionLabel: Record = { stop: t.composerStop, send: t.composerSend, busy: t.composerSending, continue: t.composerContinue, }; const actionIcon: Record = { stop: , send: , busy: , continue: , }; return (
{notice}
{topSlot &&
{topSlot}
} {attachments.length > 0 && (
{attachments.map((attachment) => (
{attachment.src ? ( {attachment.name ) : ( {attachment.name ?? t.composerFile} )} {onRemoveAttachment && ( )}
))}
)}
{onAttach && ( )} {recording && mic ? ( ) : (