nanobot/webui/src/components/thread/ThreadComposer.tsx

445 lines
14 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import {
ArrowUp,
ImageIcon,
Loader2,
Paperclip,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
useAttachedImages,
type AttachedImage,
type AttachmentError,
MAX_IMAGES_PER_MESSAGE,
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage } from "@/hooks/useNanobotStream";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
* deliberately excluded to avoid an embedded-script XSS surface. */
const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
interface ThreadComposerProps {
onSend: (content: string, images?: SendImage[]) => void;
disabled?: boolean;
placeholder?: string;
modelLabel?: string | null;
variant?: "thread" | "hero";
}
export function ThreadComposer({
onSend,
disabled,
placeholder,
modelLabel = null,
variant = "thread",
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [inlineError, setInlineError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const isHero = variant === "hero";
const resolvedPlaceholder =
placeholder ?? t("thread.composer.placeholderThread");
const { images, enqueue, remove, clear, encoding, full } =
useAttachedImages();
const formatRejection = useCallback(
(reason: AttachmentError): string => {
const key = `thread.composer.imageRejected.${reason}`;
return t(key, { max: MAX_IMAGES_PER_MESSAGE });
},
[t],
);
const addFiles = useCallback(
(files: File[]) => {
if (files.length === 0) return;
const { rejected } = enqueue(files);
if (rejected.length > 0) {
setInlineError(formatRejection(rejected[0].reason));
} else {
setInlineError(null);
}
},
[enqueue, formatRejection],
);
const {
isDragging,
onPaste,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
} = useClipboardAndDrop(addFiles);
useEffect(() => {
if (disabled) return;
const el = textareaRef.current;
if (!el) return;
const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id);
}, [disabled]);
const readyImages = useMemo(
() => images.filter((img): img is AttachedImage & { dataUrl: string } =>
img.status === "ready" && typeof img.dataUrl === "string",
),
[images],
);
const hasErrors = images.some((img) => img.status === "error");
const canSend =
!disabled
&& !encoding
&& !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0);
const submit = useCallback(() => {
if (!canSend) return;
const trimmed = value.trim();
// Share the same normalized ``data:`` URL with both the wire payload and
// the optimistic bubble preview: data URLs are self-contained (no blob
// lifetime, safe under React StrictMode double-mount) and keep the
// bubble in sync with whatever the backend actually sees.
const payload: SendImage[] | undefined =
readyImages.length > 0
? readyImages.map((img) => ({
media: {
data_url: img.dataUrl,
name: img.file.name,
},
preview: { url: img.dataUrl, name: img.file.name },
}))
: undefined;
onSend(trimmed, payload);
setValue("");
setInlineError(null);
// Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message.
clear();
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.focus();
}
});
}, [canSend, clear, onSend, readyImages, value]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
};
const onInput: React.FormEventHandler<HTMLTextAreaElement> = (e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
};
const onFilePick: React.ChangeEventHandler<HTMLInputElement> = (e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
addFiles(files);
};
const removeChip = useCallback(
(id: string) => {
const { nextFocusId } = remove(id);
setInlineError(null);
requestAnimationFrame(() => {
const el = nextFocusId ? chipRefs.current.get(nextFocusId) : null;
if (el) {
el.focus();
} else {
textareaRef.current?.focus();
}
});
},
[remove],
);
const onChipKey = useCallback(
(id: string) => (e: ReactKeyboardEvent<HTMLButtonElement>) => {
if (
e.key === "Delete" ||
e.key === "Backspace" ||
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault();
removeChip(id);
}
},
[removeChip],
);
const attachButtonDisabled = disabled || full;
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
>
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
isHero
? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card shadow-[0_10px_30px_rgba(0,0,0,0.10)]"
: "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card",
"focus-within:ring-1 focus-within:ring-foreground/8",
disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
)}
>
{images.length > 0 ? (
<div
className="flex flex-wrap gap-2 px-3 pt-3"
aria-label={t("thread.composer.attachImage")}
>
{images.map((img) => (
<AttachmentChip
key={img.id}
image={img}
labelRemove={t("thread.composer.remove")}
labelEncoding={t("thread.composer.encoding")}
normalizedHint={(orig, current) =>
t("thread.composer.normalizedSizeHint", {
orig: formatBytes(orig),
current: formatBytes(current),
})
}
formatError={formatRejection}
onRemove={() => removeChip(img.id)}
onKeyDown={onChipKey(img.id)}
registerRef={(el) => {
if (el) chipRefs.current.set(img.id, el);
else chipRefs.current.delete(img.id);
}}
/>
))}
</div>
) : null}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
rows={1}
placeholder={resolvedPlaceholder}
disabled={disabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-sm",
"placeholder:text-muted-foreground",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
/>
{inlineError ? (
<div
role="alert"
className={cn(
"mx-3 mb-1 rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
"text-[11.5px] font-medium text-destructive",
)}
>
{inlineError}
</div>
) : null}
<div
className={cn(
"flex items-center justify-between gap-2",
isHero ? "px-3.5 pb-3.5" : "px-3 pb-2",
)}
>
<div className="flex min-w-0 items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_ATTR}
multiple
hidden
onChange={onFilePick}
/>
<Button
type="button"
size="icon"
variant="ghost"
disabled={attachButtonDisabled}
aria-label={t("thread.composer.attachImage")}
onClick={() => fileInputRef.current?.click()}
className={cn(
"rounded-full text-muted-foreground hover:text-foreground",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
)}
>
<Paperclip className={cn(isHero ? "h-4 w-4" : "h-3.5 w-3.5")} />
</Button>
{modelLabel ? (
<span
title={modelLabel}
className={cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
isHero ? "text-[11px]" : "text-[10.5px]",
)}
>
<span
aria-hidden
className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-500/80"
/>
<span className="truncate">{modelLabel}</span>
</span>
) : null}
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
{t("thread.composer.sendHint")}
</span>
</div>
<span className="sm:hidden" aria-hidden />
<Button
type="submit"
size="icon"
disabled={!canSend}
aria-label={t("thread.composer.send")}
className={cn(
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
canSend && "hover:scale-[1.03] active:scale-95",
)}
>
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
</Button>
</div>
</div>
</form>
);
}
interface AttachmentChipProps {
image: AttachedImage;
labelRemove: string;
labelEncoding: string;
normalizedHint: (origBytes: number, currentBytes: number) => string;
formatError: (reason: AttachmentError) => string;
onRemove: () => void;
onKeyDown: (e: ReactKeyboardEvent<HTMLButtonElement>) => void;
registerRef: (el: HTMLButtonElement | null) => void;
}
function AttachmentChip({
image,
labelRemove,
labelEncoding,
normalizedHint,
formatError,
onRemove,
onKeyDown,
registerRef,
}: AttachmentChipProps) {
const sizeLabel =
image.status === "ready" && image.normalized && image.encodedBytes
? normalizedHint(image.file.size, image.encodedBytes)
: formatBytes(image.file.size);
const tone =
image.status === "error"
? "border-destructive/40 bg-destructive/5 text-destructive"
: "border-border/70 bg-muted/60";
return (
<div
className={cn(
"group relative flex items-center gap-2 rounded-[12px] border px-2 py-1.5",
"transition-colors motion-reduce:transition-none",
tone,
)}
data-testid="composer-chip"
>
<div className="relative h-10 w-10 overflow-hidden rounded-md bg-background">
{image.previewUrl ? (
<img
src={image.previewUrl}
alt=""
aria-hidden
loading="eager"
draggable={false}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" aria-hidden />
</div>
)}
{image.status === "encoding" ? (
<div
className="absolute inset-0 flex items-center justify-center bg-background/60"
aria-label={labelEncoding}
>
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden />
</div>
) : null}
</div>
<div className="flex min-w-0 flex-col text-[11.5px] leading-4">
<span className="truncate max-w-[14rem] font-medium" title={image.file.name}>
{image.file.name}
</span>
<span className="truncate text-muted-foreground">
{image.status === "error" && image.error
? formatError(image.error)
: sizeLabel}
</span>
</div>
<button
type="button"
ref={registerRef}
onClick={onRemove}
onKeyDown={onKeyDown}
aria-label={labelRemove}
className={cn(
"ml-1 grid h-5 w-5 flex-none place-items-center rounded-full",
"text-muted-foreground/80 hover:bg-foreground/8 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/30",
)}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
);
}