import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { useChatStore } from '../../stores/chatStore'; 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'; import { useVisualViewportInset } from '../../hooks/useVisualViewportInset'; interface MessageInputProps { channelId: string; channelName: string; /** * Optional override for the textarea placeholder. When omitted the * placeholder is derived from `channelName` (`'Message @user'` for DMs, * `'Message #channel'` otherwise). DM call sites pass the resolved * placeholder directly so they can collapse the unreadable joined-names * form ("Message #Alice, Bob, Charlie, Dave") to "Message the group" * when the group has no `dm.name` set. */ placeholder?: string; } interface MentionState { query: string; startIndex: number; 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, placeholder }: MessageInputProps) { // 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); // Note: this ref is intentionally typed `HTMLDivElement | null` (mutable // ref shape) rather than the more restrictive `RefObject` // because we assign to `.current` from a callback ref below — the // callback ref bridges the imperative `popoverAnchorRef` consumers // (InputPopover / mention-popover anchoring) and the state-backed // `composerEl` slot used by the clearance-measuring effect. 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 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 const gifEnabled = useSettingsStore((s) => s.gifEnabled); // Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId)); const isDm = isDmChannel(channelId); 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 (chatStore is the live source of truth) useEffect(() => { if (chatReplyTo) { textareaRef.current?.focus(); } }, [chatReplyTo]); // Close popover on channel change useEffect(() => { setActivePopover(null); setMentionState(null); }, [channelId]); // 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(); return members .filter((m) => { const name = (m.user.displayName ?? m.user.username).toLowerCase(); const username = m.user.username.toLowerCase(); return name.includes(q) || username.includes(q); }) .slice(0, 8); }, [members, mentionState]); const handleTyping = useCallback(() => { if (typingTimeoutRef.current) return; const dm = isDmChannel(channelId); if (dm) { wsSend({ type: 'dm_typing_start', dmChannelId: channelId }, getChannelOrigin(channelId)); } else { wsSend({ type: 'typing_start', channelId }, getChannelOrigin(channelId)); } typingTimeoutRef.current = setTimeout(() => { typingTimeoutRef.current = undefined; }, 3000); }, [channelId]); /** * 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 { fileHandleId = makeFileHandleKey(); await putHandle(fileHandleId, handle); } catch (err) { // Persistence failed — proceed without resume capability. fileHandleId = undefined; const msg = err instanceof Error ? err.message : 'unknown error'; console.warn('[MessageInput] putHandle failed:', msg); } } 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); } const t = useTransferStore.getState().transfers.get(transferId); if (t?.state === 'completed') { // Fully-uploaded attachment with a finalized DB row. Server-side bytes // get cleaned by the unlinked-attachment janitor (1h grace). useTransferStore.getState().remove(transferId); } else if (t && t.state !== 'aborted') { // active/paused/queued/failed: abortUpload tears down any live tus // instance AND sends DELETE for orphaned server-side .tus sessions. // Then drop the transfer + free the retained File reference. abortUpload(transferId); useTransferStore.getState().remove(transferId); } else { // Already 'aborted' (server already cleaned); just drop the record. useTransferStore.getState().remove(transferId); } removeStaged(channelId, transferId); }, [abortUpload, removeStaged, channelId], ); // 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'); } 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 ?? 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, draftText, setDraft, channelId], ); 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, ); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setMentionState((prev) => prev ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } : null, ); return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); const selected = filteredMembers[mentionState.selectedIndex]; if (selected) selectMention(selected); return; } if (e.key === 'Escape') { e.preventDefault(); setMentionState(null); return; } } // Default: Enter to submit if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void handleSubmit(); } }; const handlePaste = (e: React.ClipboardEvent): void => { const items = e.clipboardData.items; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item && item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) void enqueueFile({ file }); } } }; 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); for (const file of droppedFiles) { void enqueueFile({ file }); } }; const handleDragOver = (e: React.DragEvent): void => { e.preventDefault(); }; const handleChange = (e: React.ChangeEvent): void => { const value = e.target.value; const cursorPos = e.target.selectionStart; setDraft(channelId, value); // Detect @mention trigger const textBeforeCursor = value.slice(0, cursorPos); const mentionMatch = textBeforeCursor.match(/@([^\s<]*)$/); if (mentionMatch) { const atIndex = cursorPos - mentionMatch[0].length; // Only trigger at word boundary: start of input, after space, or after newline const charBefore = atIndex > 0 ? value[atIndex - 1] : undefined; if (charBefore === undefined || charBefore === ' ' || charBefore === '\n') { setMentionState({ query: mentionMatch[1] ?? '', startIndex: atIndex, selectedIndex: 0, }); } else { setMentionState(null); } } else { setMentionState(null); } handleTyping(); // Auto-resize textarea const textarea = e.target; textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; }; 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; }); }, [draftText, setDraft, channelId], ); 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)); }, []); // 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; // Composer positioning model — IDENTICAL across desktop and mobile. // ───────────────────────────────────────────────────────────────── // The composer is a floating glass-bubble (`glass-bubble rounded-[14px]`) // pinned to the bottom of the chat region with `position: absolute`. The // MessageList sibling fills the entire chat area; the last messages scroll // *behind* the translucent bubble. MessageList content carries a dynamic // `paddingBottom` (CSS variable `--composer-clearance`, set by the // ResizeObserver effect below) so the last message clears the bubble's // top edge with a 12 px breathing gap regardless of bubble height. // // Vertical positioning differs only in the `bottom` value: // - Desktop: `bottom: 12px` (the historical `md:bottom-3` constant). // - Mobile, keyboard closed: `bottom: env(safe-area-inset-bottom) + 6px` // so the bubble clears the iOS home indicator with a small breathing gap. // - Mobile, keyboard open: `bottom: 0`. `MobileShell` shrinks its container // to `visualViewport.height` (see `MobileShell.tsx`), so the chat region's // bottom edge already sits on the keyboard's top edge. The composer then // lands flush with the keyboard regardless of how reliably // `visualViewport` event delivery is on iOS PWA — the shell's height // shrinking is the load-bearing mechanism, not the inset arithmetic // here. This dodges the long-standing iOS-standalone bug where // `visualViewport.resize` fires late or not at all when the soft // keyboard opens. The hook's `focusin` polling fallback covers the // remaining gap by re-reading `vv.height` for ~600 ms after a text // input gains focus, even when no resize event ever lands. // // The horizontal inset is symmetric: `left-2 right-2` on mobile (matches // `MobileVoiceMiniBar`'s `mx-2` and the `MobileBottomNav` spacing tier); // `md:left-3 md:right-3` on desktop (the historical 12 px inset). // // `z-[110]` keeps the bubble above any in-chat overlays (mention popover, // staged-attachment tiles) but below modals (`z-[300]+`). const isMobile = useUIStore((s) => s.isMobile); const { keyboardOpen, textInputFocused } = useVisualViewportInset(); // iOS PWA standalone shrinks the *layout viewport* itself for the keyboard // (interactive-widget=resizes-content / native standalone behavior), so // `vv.height` matches `innerHeight` and the height-delta-inferred // `keyboardOpen` stays false even though the keyboard IS up. Focus state is // the robust fallback: a text input being focused means the keyboard is up. // - keyboardOpen true (Android Chrome): MobileShell already shrunk to // `vv.height`; composer at `bottom: 0` lands on the keyboard top. // - keyboardOpen false but textInputFocused true (iOS PWA): layout viewport // already shrunk by iOS; composer at `bottom: 4px` lands ~4 px above the // keyboard top — the tight visual gap the user wants. // - both false: composer 6 px above the home indicator, the rest state. const composerStyle: React.CSSProperties | undefined = isMobile ? { bottom: keyboardOpen ? '0px' : textInputFocused ? '4px' : 'calc(env(safe-area-inset-bottom) + 6px)', } : undefined; const composerClass = 'absolute left-2 right-2 z-[110] glass-bubble rounded-[14px]' + ' md:left-3 md:right-3 md:bottom-3'; // Dynamic message-list bottom padding ("composer clearance"): // // The composer is `position: absolute` and overlays the bottom of the // chat region. The MessageList scroll content needs enough bottom padding // that the last message can be scrolled fully into view above the bubble // with a visible gap — otherwise the last message sticks flush to the // bubble's top edge (the bug user reported on iOS PWA: a static `pb-20` // = 80 px is smaller than `composer-bottom-offset (env safe-area + 6) + // composer-height (~50–100 px depending on staged attachments / multi- // line text)` on iPhone). // // Strategy: a single CSS custom property `--composer-clearance` is // written to the nearest scrollable ancestor on every composer-size or // composer-bottom-offset change. `MessageList` reads that variable as // its content's `paddingBottom`, falling back to a static 80 px when // unset (e.g. when no composer is mounted, or before the first measure). // The 12 px constant below is the desired breathing-room gap between the // last message's bottom edge and the composer's top edge. // // Why a CSS variable on the parent rather than a global: // - One MessageInput per chat region; the variable scopes to that // region so multi-pane layouts (DM list + chat in a future split // view, voice channel side-panel, etc.) don't cross-talk. // - The MessageList content already lives inside the same parent // subtree, so a CSS variable inheritance just works. // We track the live composer DOM element via a state-backed ref. A plain // ref isn't enough because the component renders different JSX when // `canSendMessages` flips (the early-return permission-denied path doesn't // attach the ref), and a useEffect on the ref's value would not re-fire on // those re-renders. Channel permissions arrive asynchronously, so the // initial mount renders the no-permission JSX first, then re-renders with // the full composer once permissions resolve — we need to (re-)attach the // ResizeObserver at that moment. const [composerEl, setComposerEl] = useState(null); useEffect(() => { if (!composerEl) return; const target = composerEl.parentElement; if (!target) return; const el = composerEl; const sync = () => { // Total clearance = composer height + bottom offset + 12 px gap. // We measure the bubble's visual height (including replyTo banner + // staged-attachment tiles + textarea autosize) plus the distance from // the parent's bottom edge to the bubble's bottom edge (which folds // in `env(safe-area-inset-bottom) + 6` on mobile or `12 px` on // desktop, whichever the composer's `bottom` resolves to). const composerRect = el.getBoundingClientRect(); const parentRect = target.getBoundingClientRect(); const bottomOffset = Math.max(0, parentRect.bottom - composerRect.bottom); const clearance = Math.round(composerRect.height + bottomOffset + 12); target.style.setProperty('--composer-clearance', `${clearance}px`); }; sync(); const ro = new ResizeObserver(sync); ro.observe(el); // Also re-sync when the parent itself resizes (keyboard open/close // collapses the chat region's height; MobileShell drives this via // visualViewport.height). ro.observe(target); // Re-sync on visual viewport changes — the parent's `getBoundingClientRect` // updates with the layout, but if `MobileShell`'s height attribute // updates between paints, we want a same-frame re-measure. const vv = window.visualViewport; const onVv = () => sync(); if (vv) { vv.addEventListener('resize', onVv); vv.addEventListener('scroll', onVv); } return () => { ro.disconnect(); if (vv) { vv.removeEventListener('resize', onVv); vv.removeEventListener('scroll', onVv); } target.style.removeProperty('--composer-clearance'); }; // Re-arm the observer / listeners when keyboard transitions or the // composer's content materially changes — the dependency list is the // set of inputs that can change the bubble's height or its bottom // offset between renders. The ResizeObserver itself is what catches // continuous textarea-autosize growth; these deps just ensure we're // attached to the live element after a remount. }, [composerEl, isMobile, keyboardOpen, textInputFocused, chatReplyTo, stagedTransfers.length]); // Combined ref: keep `popoverAnchorRef` populated (InputPopover / mention // popover anchor + scroll-into-view targets) AND notify the // `composerEl` state slot so the clearance-measuring effect can re-run // when the element materializes / changes between conditional render // branches. const setComposerRef = useCallback((node: HTMLDivElement | null) => { popoverAnchorRef.current = node; setComposerEl(node); }, []); if (!canSendMessages) { return (
You do not have permission to send messages in this channel
); } return (
{/* Input popover (emoji / gif) */} {activePopover && ( setActivePopover(null)} onEmojiSelect={handleEmojiSelect} onGifSelect={handleGifSelect} anchorRef={popoverAnchorRef} gifEnabled={gifEnabled} onTabChange={setActivePopover} /> )} {chatReplyTo && (
Replying to {chatReplyTo.user.displayName ?? chatReplyTo.user.username}
)}
{/* Mention autocomplete popover */} {mentionState && filteredMembers.length > 0 && ( )} {/* Staged transfer tiles */} {stagedTransfers.length > 0 && (
{stagedTransfers.map((t) => { const isImage = t.file.mimetype.startsWith('image/'); const isFinal = t.state === 'completed'; const showOverlay = t.state !== 'completed'; const previewUrl = previewUrlsRef.current.get(t.id); return (
{isImage ? (
{previewUrl ? ( {t.file.name} ) : ( )}
) : (
{t.file.name}
)} {/* Overlay: progress / paused / failed indicator (driven by AttachmentProgress) */} {showOverlay && ( pauseUpload(t.id) : undefined} onResume={t.state === 'paused' ? () => void resumeUpload(t.id) : undefined} onAbort={() => removeStagedTransfer(t.id)} /> )} {/* Final-state remove button (top-right rose chip) — only when completed */} {isFinal && ( )}
); })}
)}
{/* File attach button */} {canAttachFiles && ( )} { const selected = Array.from(e.target.files ?? []); for (const f of selected) { void enqueueFile({ file: f }); } e.target.value = ''; }} /> {/* Text input */}