361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The message composer.
|
|
*
|
|
* Formatting is deliberately NOT parsed here — the user types `*bold*` and the
|
|
* BFF turns markers into clean text plus spans. Keeping one parser on the
|
|
* server means every client renders identically and no client can dictate how
|
|
* its text is decorated in someone else's thread.
|
|
*
|
|
* Link previews and attachments layer onto this in the next phase; nothing here
|
|
* needs to change to accommodate them.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Paperclip, Sticker, SendHorizonal, X } from "lucide-react";
|
|
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
|
import {
|
|
ComposerAttachments,
|
|
type StagedAttachment,
|
|
} from "@/components/chat/composer-attachments";
|
|
import {
|
|
StickerPicker,
|
|
type StickerAnchor,
|
|
} from "@/components/chat/sticker-picker";
|
|
import { compressImage } from "@/lib/compress-image";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
ACCEPTED_ATTACHMENTS,
|
|
chat,
|
|
firstUrl,
|
|
uploadAttachment,
|
|
type ChatMessage,
|
|
type LinkPreview,
|
|
type UploadedAttachment,
|
|
} from "@/lib/chat";
|
|
|
|
const MAX_ROWS_PX = 140;
|
|
|
|
export function ChatComposer({
|
|
replyTo,
|
|
onCancelReply,
|
|
onSend,
|
|
onTyping,
|
|
conversationId,
|
|
disabled = false,
|
|
placeholder = "Message…",
|
|
replyToName,
|
|
}: {
|
|
conversationId: string;
|
|
replyTo: ChatMessage | null;
|
|
onCancelReply: () => void;
|
|
/** Called on each keystroke; throttled by the caller. */
|
|
onTyping?: () => void;
|
|
onSend: (
|
|
body: string,
|
|
replyToId?: string,
|
|
previewUrl?: string,
|
|
attachments?: UploadedAttachment[],
|
|
/** Reward artwork sent on its own. */
|
|
stickerKey?: string,
|
|
) => void;
|
|
disabled?: boolean;
|
|
placeholder?: string;
|
|
/** Display name of whoever wrote the message being replied to. */
|
|
replyToName: string;
|
|
}) {
|
|
const [value, setValue] = useState("");
|
|
const [preview, setPreview] = useState<LinkPreview | null>(null);
|
|
// URLs the sender dismissed. Kept so re-typing doesn't resurrect a card
|
|
// they've already said no to.
|
|
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
|
const [staged, setStaged] = useState<StagedAttachment[]>([]);
|
|
const ref = useRef<HTMLTextAreaElement>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
// Non-null opens the sticker picker AND positions it: the popover hangs
|
|
// off the button's viewport rect, which only exists at click time.
|
|
const [stickerAnchor, setStickerAnchor] = useState<StickerAnchor | null>(null);
|
|
const lookupSeq = useRef(0);
|
|
|
|
// Grow with the content, up to a cap.
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`;
|
|
}, [value]);
|
|
|
|
// Which link, if any, the draft currently wants a card for.
|
|
const activeUrl = firstUrl(value);
|
|
const wantsPreview = !!activeUrl && !dismissed.has(activeUrl);
|
|
|
|
// Whether to SHOW a card is derived during render rather than cleared from
|
|
// an effect: a stale card for a link the user has since edited away simply
|
|
// stops matching, so there is no state to reset.
|
|
const visiblePreview =
|
|
wantsPreview && preview?.url === activeUrl ? preview : null;
|
|
|
|
// The effect only fetches. Requests are sequenced so a slow earlier response
|
|
// cannot overwrite a newer one, and debounced so a lookup doesn't fire on
|
|
// every keystroke.
|
|
useEffect(() => {
|
|
if (!wantsPreview || !activeUrl) return;
|
|
const seq = ++lookupSeq.current;
|
|
const timer = setTimeout(() => {
|
|
chat
|
|
.linkPreview(activeUrl)
|
|
.then((p) => {
|
|
if (seq === lookupSeq.current) setPreview(p);
|
|
})
|
|
// No card is a normal outcome — many pages carry no metadata.
|
|
.catch(() => {});
|
|
}, 500);
|
|
return () => clearTimeout(timer);
|
|
}, [activeUrl, wantsPreview]);
|
|
|
|
const dismissPreview = useCallback(() => {
|
|
if (activeUrl) setDismissed((prev) => new Set(prev).add(activeUrl));
|
|
setPreview(null);
|
|
}, [activeUrl]);
|
|
|
|
// Starting a reply should put the cursor where the user is about to type.
|
|
useEffect(() => {
|
|
if (replyTo) ref.current?.focus();
|
|
}, [replyTo]);
|
|
|
|
/**
|
|
* Uploads each pick straight to the private bucket, so by the time the user
|
|
* hits send the bytes are already there and sending is just metadata.
|
|
* Images are compressed first, which also strips EXIF — location data
|
|
* shouldn't ride along into someone else's chat.
|
|
*/
|
|
const addFiles = useCallback(
|
|
async (files: FileList) => {
|
|
for (const original of Array.from(files)) {
|
|
const id = crypto.randomUUID();
|
|
const file = original.type.startsWith("image/")
|
|
? await compressImage(original).catch(() => original)
|
|
: original;
|
|
const previewUrl = file.type.startsWith("image/")
|
|
? URL.createObjectURL(file)
|
|
: undefined;
|
|
|
|
setStaged((prev) => [
|
|
...prev,
|
|
{ id, file, previewUrl, status: "uploading" },
|
|
]);
|
|
|
|
uploadAttachment(conversationId, file)
|
|
.then((uploaded) => {
|
|
setStaged((prev) =>
|
|
prev.map((s) =>
|
|
s.id === id ? { ...s, status: "ready", uploaded } : s,
|
|
),
|
|
);
|
|
})
|
|
.catch(() => {
|
|
// One failure shouldn't discard the others — mark it and let the
|
|
// user remove it.
|
|
setStaged((prev) =>
|
|
prev.map((s) => (s.id === id ? { ...s, status: "failed" } : s)),
|
|
);
|
|
});
|
|
}
|
|
},
|
|
[conversationId],
|
|
);
|
|
|
|
const removeStaged = useCallback((id: string) => {
|
|
setStaged((prev) => {
|
|
const target = prev.find((s) => s.id === id);
|
|
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
|
return prev.filter((s) => s.id !== id);
|
|
});
|
|
}, []);
|
|
|
|
// Object URLs are a leak if the composer unmounts mid-compose.
|
|
useEffect(() => {
|
|
return () => {
|
|
staged.forEach((s) => s.previewUrl && URL.revokeObjectURL(s.previewUrl));
|
|
};
|
|
// Intentionally on unmount only; removeStaged handles the per-chip case.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const uploading = staged.some((s) => s.status === "uploading");
|
|
const ready = staged.filter((s) => s.status === "ready" && s.uploaded);
|
|
const canSend =
|
|
!disabled && !uploading && (value.trim() !== "" || ready.length > 0);
|
|
|
|
const submit = () => {
|
|
if (!canSend) return;
|
|
const body = value.trim();
|
|
onSend(
|
|
body,
|
|
replyTo?.id,
|
|
visiblePreview?.url,
|
|
ready.map((s) => s.uploaded as UploadedAttachment),
|
|
);
|
|
staged.forEach((s) => s.previewUrl && URL.revokeObjectURL(s.previewUrl));
|
|
setStaged([]);
|
|
setValue("");
|
|
setPreview(null);
|
|
setDismissed(new Set());
|
|
onCancelReply();
|
|
};
|
|
|
|
return (
|
|
<div className="border-t border-border bg-background">
|
|
{replyTo && (
|
|
<div className="flex items-start gap-2 border-b border-border px-4 py-2">
|
|
<div className="min-w-0 flex-1 border-l-2 border-primary/50 pl-2">
|
|
<p className="text-xs font-medium text-foreground">
|
|
Replying to {replyToName}
|
|
</p>
|
|
<p className="line-clamp-1 break-words text-xs text-muted-foreground">
|
|
{replyTo.body}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onCancelReply}
|
|
aria-label="Cancel reply"
|
|
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{visiblePreview && (
|
|
<div className="px-4 pt-2">
|
|
<LinkPreviewCard
|
|
preview={visiblePreview}
|
|
onDismiss={dismissPreview}
|
|
compact
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<ComposerAttachments items={staged} onRemove={removeStaged} />
|
|
|
|
<div className="flex items-end gap-2 px-4 py-3">
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
multiple
|
|
accept={ACCEPTED_ATTACHMENTS}
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
if (e.target.files?.length) void addFiles(e.target.files);
|
|
// Reset so picking the same file twice still fires a change.
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
{/* One field group: attach on the left, sticker on the right, the
|
|
text between them. The border and background live here rather than
|
|
on the textarea so all three read as a single control, and
|
|
focus-within moves the ring to the group. */}
|
|
<div
|
|
className={cn(
|
|
// Minimal horizontal padding: the icon buttons keep a full-height
|
|
// tap target, but sit close to the group's edges and to the text
|
|
// rather than floating in their own gutters.
|
|
"flex flex-1 items-end gap-0.5 rounded-2xl border border-border bg-muted/40 px-1 py-1",
|
|
"focus-within:ring-1 focus-within:ring-primary/40",
|
|
disabled && "opacity-60",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => fileRef.current?.click()}
|
|
disabled={disabled}
|
|
aria-label="Attach a file"
|
|
className="inline-flex h-8 w-8 mr-1 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
>
|
|
<Paperclip className="size-5" />
|
|
</button>
|
|
|
|
<textarea
|
|
ref={ref}
|
|
rows={1}
|
|
value={value}
|
|
disabled={disabled}
|
|
placeholder={placeholder}
|
|
onChange={(e) => {
|
|
setValue(e.target.value);
|
|
// Fired on every keystroke; the handler throttles itself, so the
|
|
// thread hears "still typing" every few seconds rather than per
|
|
// character.
|
|
onTyping?.();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
// Enter sends on a physical keyboard; Shift+Enter makes a
|
|
// newline. On touch keyboards Enter must insert a newline
|
|
// instead — there is no Shift to reach for — so the send button
|
|
// is the only way there.
|
|
if (e.key !== "Enter" || e.shiftKey) return;
|
|
const touch =
|
|
typeof window !== "undefined" &&
|
|
window.matchMedia("(pointer: coarse)").matches;
|
|
if (touch) return;
|
|
e.preventDefault();
|
|
submit();
|
|
}}
|
|
className={cn(
|
|
// Bare: the group above owns the frame. Only the type size and
|
|
// the growth behaviour belong to the field itself.
|
|
"min-w-0 flex-1 resize-none bg-transparent py-1.5",
|
|
// 16px on touch devices: iOS Safari zooms the whole page when a
|
|
// focused field computes below that, which on a composer means
|
|
// the thread jumps every time the keyboard opens. Pointer-based
|
|
// devices keep the app's 14px chrome.
|
|
"text-sm pointer-coarse:text-base",
|
|
"placeholder:text-muted-foreground focus:outline-none",
|
|
"disabled:opacity-60",
|
|
)}
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|
// Top-centre of the button: the popover opens upward from here.
|
|
setStickerAnchor({ x: r.left + r.width / 2, y: r.top });
|
|
}}
|
|
disabled={disabled}
|
|
aria-label="Send a sticker"
|
|
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
>
|
|
<Sticker className="size-5" />
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={submit}
|
|
disabled={!canSend}
|
|
aria-label="Send message"
|
|
className={cn(
|
|
"inline-flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
|
|
"bg-primary text-primary-foreground disabled:opacity-40 dark:text-white",
|
|
)}
|
|
>
|
|
<SendHorizonal className="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{stickerAnchor && (
|
|
<StickerPicker
|
|
anchor={stickerAnchor}
|
|
// Sent on its own, with no text: a sticker IS the message.
|
|
onPick={(sticker) =>
|
|
onSend("", undefined, undefined, undefined, sticker.key)
|
|
}
|
|
onClose={() => setStickerAnchor(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|