From b97326ef18c4cb53d85e29e7a357956717d07038 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 2 May 2026 16:09:24 +0200 Subject: [PATCH] feat(web): MessageInput uses composerStore + transferStore (eager upload, FS handles) --- .../web/src/components/chat/MessageInput.tsx | 653 +++++++++++++----- 1 file changed, 476 insertions(+), 177 deletions(-) diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index f2a346f3..0c65230b 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -1,14 +1,19 @@ import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { useChatStore } from '../../stores/chatStore'; -import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '../../stores/spaceStore'; +import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore'; import { wsSend } from '../../hooks/useWebSocket'; import { MentionPopover } from './MentionPopover'; import { TypingIndicator } from './TypingIndicator'; import { InputPopover, type InputPopoverTab } from './InputPopover'; +import { AttachmentProgress } from './AttachmentProgress'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared'; import { useSettingsStore } from '../../stores/settingsStore'; import { useUIStore } from '../../stores/uiStore'; +import { useComposerStore } from '../../stores/composerStore'; +import { useTransferStore, type Transfer } from '../../stores/transferStore'; +import { usePendingMessageStore } from '../../stores/pendingMessageStore'; +import { putHandle, supportsFsHandles, supportsDnDHandles } from '../../utils/idbHandles'; interface MessageInputProps { channelId: string; @@ -21,22 +26,55 @@ interface MentionState { selectedIndex: number; } +// Default tus expiration window if a transfer doesn't yet have one (24h). +const DEFAULT_TUS_TTL_MS = 24 * 60 * 60 * 1000; + +function makeFileHandleKey(): string { + return `up-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + export function MessageInput({ channelId, channelName }: MessageInputProps) { - const [content, setContent] = useState(''); - const [files, setFiles] = useState([]); - const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState>(new Map()); - const addToast = useUIStore((s) => s.addToast); + // Composer state lives in composerStore (per-channel, persisted) + const composerState = useComposerStore((s) => s.states.get(channelId)) ?? { + draftText: '', + replyTo: null, + stagedTransferIds: [] as string[], + }; + const setDraft = useComposerStore((s) => s.setDraft); + const composerSetReplyTo = useComposerStore((s) => s.setReplyTo); + const attachToComposer = useComposerStore((s) => s.attach); + const removeStaged = useComposerStore((s) => s.removeStaged); + const clearComposer = useComposerStore((s) => s.clear); + + // Transfer state — subscribe to the whole map so progress/state updates re-render + const transfers = useTransferStore((s) => s.transfers); + const startUpload = useTransferStore((s) => s.startUpload); + const pauseUpload = useTransferStore((s) => s.pauseUpload); + const resumeUpload = useTransferStore((s) => s.resumeUpload); + const abortUpload = useTransferStore((s) => s.abortUpload); + + // UI-only state stays local const [mentionState, setMentionState] = useState(null); const [activePopover, setActivePopover] = useState(null); + const fileInputRef = useRef(null); const textareaRef = useRef(null); const inputContainerRef = useRef(null); const popoverAnchorRef = useRef(null); + + // Object URLs for current-session image previews. transferStore doesn't hold + // the raw File, so previews only exist for files picked in this session + // (after reload, persisted transfers fall back to the icon placeholder). + const previewUrlsRef = useRef>(new Map()); + const sendMessage = useChatStore((s) => s.sendMessage); - const replyTo = useChatStore((s) => s.replyTo); - const setReplyTo = useChatStore((s) => s.setReplyTo); + const chatReplyTo = useChatStore((s) => s.replyTo); + const chatSetReplyTo = useChatStore((s) => s.setReplyTo); const members = useSpaceStore((s) => s.members); + + const addToast = useUIStore((s) => s.addToast); + const appendBubble = usePendingMessageStore((s) => s.append); + const typingTimeoutRef = useRef>(); // Feature flags @@ -48,24 +86,87 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES); const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES); + // Derive staged transfers from composerStore staged ids + transferStore map + const stagedTransfers: Transfer[] = useMemo(() => { + const out: Transfer[] = []; + for (const tid of composerState.stagedTransferIds) { + const t = transfers.get(tid); + if (t) out.push(t); + } + return out; + }, [composerState.stagedTransferIds, transfers]); + + const draftText = composerState.draftText; + const remaining = MAX_MESSAGE_LENGTH - draftText.length; + const isOverLimit = remaining < 0; + // Auto-focus textarea on channel navigation useEffect(() => { textareaRef.current?.focus(); }, [channelId]); - // Auto-focus textarea when replying + // Auto-focus textarea when replying (chatStore is the live source of truth) useEffect(() => { - if (replyTo) { + if (chatReplyTo) { textareaRef.current?.focus(); } - }, [replyTo]); + }, [chatReplyTo]); // Close popover on channel change useEffect(() => { setActivePopover(null); + setMentionState(null); }, [channelId]); - // Filter members for the mention popover + // Sync chatStore.replyTo into composerStore so reload restores it. + // chatStore holds the live MessageWithUser; composerStore stores a flat snapshot. + // + // First-mirror-per-channel guard: chatStore is not persisted, so on a fresh + // mount `chatReplyTo` is always null. Without this guard the mirror would + // clobber whatever replyTo was persisted in composerStore. Reverse hydration + // (composer→chat on reload) is not yet implemented; deferred to a future pass + // (it requires the original message to be present in chatStore.messages, + // which may not be loaded at mount). + const mirroredChannelsRef = useRef>(new Set()); + useEffect(() => { + const isFirstMirrorForChannel = !mirroredChannelsRef.current.has(channelId); + mirroredChannelsRef.current.add(channelId); + + if (isFirstMirrorForChannel) { + if (chatReplyTo) { + composerSetReplyTo(channelId, { + id: chatReplyTo.id, + userId: chatReplyTo.userId, + content: chatReplyTo.content ?? null, + }); + } + return; + } + // Already mirrored once for this channel — propagate updates including null. + if (chatReplyTo) { + composerSetReplyTo(channelId, { + id: chatReplyTo.id, + userId: chatReplyTo.userId, + content: chatReplyTo.content ?? null, + }); + } else { + composerSetReplyTo(channelId, null); + } + }, [channelId, chatReplyTo, composerSetReplyTo]); + + // Surface permanent transfer failures as toasts (one toast per id, latched) + const toastedFailuresRef = useRef>(new Set()); + useEffect(() => { + for (const t of stagedTransfers) { + if (t.state === 'failed' && !toastedFailuresRef.current.has(t.id)) { + toastedFailuresRef.current.add(t.id); + const msg = t.error?.message ?? 'Upload failed'; + addToast(`Failed to upload ${t.file.name}: ${msg}`, 'warning'); + } + } + }, [stagedTransfers, addToast]); + + // Filter members for the mention popover (used for keyboard nav clamping) const filteredMembers = useMemo(() => { if (!mentionState) return []; const q = mentionState.query.toLowerCase(); @@ -80,8 +181,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { const handleTyping = useCallback(() => { if (typingTimeoutRef.current) return; - const isDm = isDmChannel(channelId); - if (isDm) { + const dm = isDmChannel(channelId); + if (dm) { wsSend({ type: 'dm_typing_start', dmChannelId: channelId }, getChannelOrigin(channelId)); } else { wsSend({ type: 'typing_start', channelId }, getChannelOrigin(channelId)); @@ -91,104 +192,205 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { }, 3000); }, [channelId]); - const remaining = MAX_MESSAGE_LENGTH - content.length; - const isOverLimit = remaining < 0; - - const handleSubmit = async () => { - const trimmed = content.trim(); - if (!trimmed && files.length === 0) return; - if (isOverLimit) return; - - setIsUploading(true); - setMentionState(null); - setActivePopover(null); - setUploadProgress(new Map()); - try { - // Upload files first — route to the correct instance for this channel - const attachmentIds: string[] = []; - const uploadClient = getApiForOrigin(getChannelOrigin(channelId)); - const failedFiles: string[] = []; - - for (let i = 0; i < files.length; i++) { - const file = files[i]!; + /** + * Eagerly upload a file and stage the resulting transfer in composerStore. + * If a FileSystemFileHandle is provided (drag-drop via getAsFileSystemHandle, + * or FS Access pick), it's persisted in IDB so the upload is resumable + * across reload. + */ + const enqueueFile = useCallback( + async ( + input: { file: File; handle?: FileSystemFileHandle | undefined }, + ): Promise => { + const { file, handle } = input; + let fileHandleId: string | undefined; + if (handle && supportsFsHandles()) { try { - const attachment = await uploadClient.uploads.uploadWithProgress(file, (loaded, total) => { - setUploadProgress(prev => new Map(prev).set(i, Math.round((loaded / total) * 100))); - }); - attachmentIds.push(attachment.id); - setUploadProgress(prev => new Map(prev).set(i, 100)); + fileHandleId = makeFileHandleKey(); + await putHandle(fileHandleId, handle); } catch (err) { - failedFiles.push(file.name); - const msg = err instanceof Error ? err.message : 'Upload failed'; - addToast(`Failed to upload ${file.name}: ${msg}`, 'warning'); + // Persistence failed — proceed without resume capability. + fileHandleId = undefined; + const msg = err instanceof Error ? err.message : 'unknown error'; + console.warn('[MessageInput] putHandle failed:', msg); } } - if (failedFiles.length > 0 && attachmentIds.length === 0 && !trimmed) { - // All uploads failed, no text — nothing to send - return; + try { + const id = await startUpload(file, { + channelId, + tray: true, + origin: getChannelOrigin(channelId), + fileHandleId, + }); + attachToComposer(channelId, id); + // Best-effort image preview for the current session. transferStore + // doesn't retain the File, so this URL only exists in-memory. + if (file.type.startsWith('image/')) { + try { + const url = URL.createObjectURL(file); + previewUrlsRef.current.set(id, url); + } catch { + // ignore — preview is optional + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Upload failed'; + addToast(`Failed to upload ${file.name}: ${msg}`, 'warning'); + } + }, + [channelId, startUpload, attachToComposer, addToast], + ); + + const removeStagedTransfer = useCallback( + (transferId: string) => { + // Revoke any in-session image preview URL. + const previewUrl = previewUrlsRef.current.get(transferId); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + previewUrlsRef.current.delete(transferId); } - await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined); - setContent(''); - setFiles([]); - - // Reset textarea height and re-focus - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.focus(); + const t = useTransferStore.getState().transfers.get(transferId); + if (t?.state === 'completed') { + // User is discarding a fully-uploaded attachment. Drop it entirely so + // no orphan 'aborted'-with-attachmentId record persists. Server-side + // bytes get cleaned by the storage janitor (per docs/systems/uploads.md). + useTransferStore.getState().remove(transferId); + } else { + // abortUpload sets state='aborted' and tears down the live tus instance. + abortUpload(transferId); } + removeStaged(channelId, transferId); + }, + [abortUpload, removeStaged, channelId], + ); - // Clear typing timeout - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = undefined; + // Revoke all preview object URLs on unmount. + useEffect(() => { + const map = previewUrlsRef.current; + return () => { + for (const url of map.values()) URL.revokeObjectURL(url); + map.clear(); + }; + }, []); + + const handleSubmit = async (): Promise => { + const trimmed = draftText.trim(); + if (!trimmed && stagedTransfers.length === 0) return; + if (isOverLimit) return; + + // Block submission when ANY staged transfer is in a non-shippable state + // (failed/aborted) — those would prevent the bubble from ever resolving. + const hasUnshippable = stagedTransfers.some( + (t) => t.state === 'failed' || t.state === 'aborted', + ); + if (hasUnshippable) return; + + setMentionState(null); + setActivePopover(null); + + // Clear typing timeout + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = undefined; + } + + if (stagedTransfers.length === 0) { + // Text-only path — preserve the legacy optimistic-message flow + try { + await sendMessage(channelId, trimmed); + // Clear draft + reply for this channel + clearComposer(channelId); + chatSetReplyTo(null); + // Reset textarea height + focus + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.focus(); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to send message'; + addToast(msg, 'warning'); } - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to send message'; - addToast(msg, 'warning'); - } finally { - setIsUploading(false); - setUploadProgress(new Map()); + return; + } + + // Attachment path — stage a pending bubble. The orchestrator dispatches the + // actual API call when every transfer reaches state='completed'. + const clientId = crypto.randomUUID(); + const replyToId = chatReplyTo?.id ?? null; + + // tusExpiresAt: min across staged transfers, default to 24h from now if absent. + const now = Date.now(); + const fallbackExpires = now + DEFAULT_TUS_TTL_MS; + const expirations = stagedTransfers + .map((t) => t.tusExpiresAt) + .filter((x): x is number => typeof x === 'number' && x > 0); + const tusExpiresAt = expirations.length > 0 ? Math.min(...expirations) : fallbackExpires; + + appendBubble({ + clientId, + channelId, + content: trimmed, + replyToId, + transferIds: stagedTransfers.map((t) => t.id), + createdAtLocal: now, + state: 'sending', + tusExpiresAt, + retryCount: 0, + }); + + // Detach the staged transfers from the composer (they're now owned by the bubble) + // and clear the draft + reply for this channel. + clearComposer(channelId); + chatSetReplyTo(null); + + // Reset textarea height + focus + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.focus(); } }; - const selectMention = useCallback((member: MemberWithUser) => { - if (!mentionState) return; - const textarea = textareaRef.current; - const cursorPos = textarea?.selectionStart ?? content.length; - const before = content.slice(0, mentionState.startIndex); - const after = content.slice(cursorPos); - const insertion = `<@${member.userId}> `; - const newContent = before + insertion + after; - setContent(newContent); - setMentionState(null); + const selectMention = useCallback( + (member: MemberWithUser) => { + if (!mentionState) return; + const textarea = textareaRef.current; + const cursorPos = textarea?.selectionStart ?? draftText.length; + const before = draftText.slice(0, mentionState.startIndex); + const after = draftText.slice(cursorPos); + const insertion = `<@${member.userId}> `; + const newContent = before + insertion + after; + setDraft(channelId, newContent); + setMentionState(null); - // Restore cursor position after React re-renders - const newCursorPos = before.length + insertion.length; - requestAnimationFrame(() => { - if (textarea) { - textarea.focus(); - textarea.selectionStart = newCursorPos; - textarea.selectionEnd = newCursorPos; - } - }); - }, [mentionState, content]); + // Restore cursor position after React re-renders + const newCursorPos = before.length + insertion.length; + requestAnimationFrame(() => { + if (textarea) { + textarea.focus(); + textarea.selectionStart = newCursorPos; + textarea.selectionEnd = newCursorPos; + } + }); + }, + [mentionState, draftText, setDraft, channelId], + ); - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: React.KeyboardEvent): void => { // Mention popover keyboard navigation if (mentionState && filteredMembers.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setMentionState((prev) => - prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, filteredMembers.length - 1) } : null + prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, filteredMembers.length - 1) } : null, ); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setMentionState((prev) => - prev ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } : null + prev ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } : null, ); return; } @@ -208,45 +410,73 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { // Default: Enter to submit if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - handleSubmit(); + void handleSubmit(); } }; - const handlePaste = (e: React.ClipboardEvent) => { + const handlePaste = (e: React.ClipboardEvent): void => { const items = e.clipboardData.items; - const pastedFiles: File[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item && item.type.startsWith('image/')) { const file = item.getAsFile(); - if (file) pastedFiles.push(file); + if (file) void enqueueFile({ file }); } } - if (pastedFiles.length > 0) { - setFiles((prev) => [...prev, ...pastedFiles]); - } }; - const handleDrop = (e: React.DragEvent) => { + const handleDrop = (e: React.DragEvent): void => { e.preventDefault(); + const items = e.dataTransfer.items; + const useDndHandles = supportsDnDHandles(); + + if (useDndHandles && items && items.length > 0) { + // Chrome/Edge: upgrade to FileSystemFileHandle for resume-across-reload. + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item || item.kind !== 'file') continue; + // @ts-ignore — getAsFileSystemHandle is non-standard + const maybeHandle: Promise | undefined = item.getAsFileSystemHandle?.(); + const file = item.getAsFile(); + if (maybeHandle) { + void maybeHandle.then(async (handle) => { + if (handle && handle.kind === 'file') { + const fh = handle as FileSystemFileHandle; + const handleAny = fh as unknown as { getFile?: () => Promise }; + if (typeof handleAny.getFile === 'function') { + try { + const f = await handleAny.getFile(); + void enqueueFile({ file: f, handle: fh }); + return; + } catch { + // fall through to plain-file path + } + } + } + if (file) void enqueueFile({ file }); + }); + } else if (file) { + void enqueueFile({ file }); + } + } + return; + } + + // Fallback: plain Files list (Firefox/Safari) const droppedFiles = Array.from(e.dataTransfer.files); - if (droppedFiles.length > 0) { - setFiles((prev) => [...prev, ...droppedFiles]); + for (const file of droppedFiles) { + void enqueueFile({ file }); } }; - const handleDragOver = (e: React.DragEvent) => { + const handleDragOver = (e: React.DragEvent): void => { e.preventDefault(); }; - const removeFile = (index: number) => { - setFiles((prev) => prev.filter((_, i) => i !== index)); - }; - - const handleChange = (e: React.ChangeEvent) => { + const handleChange = (e: React.ChangeEvent): void => { const value = e.target.value; const cursorPos = e.target.selectionStart; - setContent(value); + setDraft(channelId, value); // Detect @mention trigger const textBeforeCursor = value.slice(0, cursorPos); @@ -258,7 +488,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { const charBefore = atIndex > 0 ? value[atIndex - 1] : undefined; if (charBefore === undefined || charBefore === ' ' || charBefore === '\n') { setMentionState({ - query: mentionMatch[1]!, + query: mentionMatch[1] ?? '', startIndex: atIndex, selectedIndex: 0, }); @@ -277,51 +507,81 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; }; - const handleEmojiSelect = useCallback((emoji: { native: string }) => { - const textarea = textareaRef.current; - if (!textarea) { - setContent((prev) => prev + emoji.native); - return; - } - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const before = content.slice(0, start); - const after = content.slice(end); - const newContent = before + emoji.native + after; - setContent(newContent); + const handleEmojiSelect = useCallback( + (emoji: { native: string }) => { + const textarea = textareaRef.current; + if (!textarea) { + setDraft(channelId, draftText + emoji.native); + return; + } + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const before = draftText.slice(0, start); + const after = draftText.slice(end); + const newContent = before + emoji.native + after; + setDraft(channelId, newContent); - // Restore cursor position after the emoji - const newCursorPos = start + emoji.native.length; - requestAnimationFrame(() => { - textarea.focus(); - textarea.selectionStart = newCursorPos; - textarea.selectionEnd = newCursorPos; - }); - }, [content]); + // Restore cursor position after the emoji + const newCursorPos = start + emoji.native.length; + requestAnimationFrame(() => { + textarea.focus(); + textarea.selectionStart = newCursorPos; + textarea.selectionEnd = newCursorPos; + }); + }, + [draftText, setDraft, channelId], + ); - const handleGifSelect = useCallback((url: string) => { - setActivePopover(null); - sendMessage(channelId, url); - }, [channelId, sendMessage]); + const handleGifSelect = useCallback( + (url: string) => { + // GIF picks bypass the staged-transfer pipeline — they're remote URLs, + // not local files, and ship as plain content. + setActivePopover(null); + void sendMessage(channelId, url); + }, + [channelId, sendMessage], + ); const togglePopover = useCallback((tab: InputPopoverTab) => { - setActivePopover((prev) => prev === tab ? null : tab); + setActivePopover((prev) => (prev === tab ? null : tab)); }, []); - const canSend = (content.trim() || files.length > 0) && !isOverLimit && !isUploading; + // anyActiveOrQueued: a visual indicator something is in flight; pending/paused + // bubbles are still allowed to ship (orchestrator handles them). + const anyActiveOrQueued = stagedTransfers.some( + (t) => t.state === 'active' || t.state === 'queued', + ); + const anyUnshippable = stagedTransfers.some( + (t) => t.state === 'failed' || t.state === 'aborted', + ); + const failedCount = stagedTransfers.filter((t) => t.state === 'failed').length; + + const canSend = + (draftText.trim().length > 0 || stagedTransfers.length > 0) && + !isOverLimit && + !anyUnshippable; if (!canSendMessages) { return ( -
+
- You do not have permission to send messages in this channel + + You do not have permission to send messages in this channel +
); } return ( -
+
{/* Input popover (emoji / gif) */} @@ -337,15 +597,18 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { /> )} - {replyTo && ( + {chatReplyTo && (
Replying to - {replyTo.user.displayName ?? replyTo.user.username} + + {chatReplyTo.user.displayName ?? chatReplyTo.user.username} +
- {/* Send button — appears when there's content to send */} + {/* Send button — appears when there's content/attachments to send */} {canSend && (