import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { useChatStore } from '../../stores/chatStore'; import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '../../stores/spaceStore'; import { wsSend } from '../../hooks/useWebSocket'; import { MentionPopover } from './MentionPopover'; import { TypingIndicator } from './TypingIndicator'; import { InputPopover, type InputPopoverTab } from './InputPopover'; 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'; interface MessageInputProps { channelId: string; channelName: string; } interface MentionState { query: string; startIndex: number; selectedIndex: number; } 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); 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); const sendMessage = useChatStore((s) => s.sendMessage); const replyTo = useChatStore((s) => s.replyTo); const setReplyTo = useChatStore((s) => s.setReplyTo); const members = useSpaceStore((s) => s.members); 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); // Auto-focus textarea on channel navigation useEffect(() => { textareaRef.current?.focus(); }, [channelId]); // Auto-focus textarea when replying useEffect(() => { if (replyTo) { textareaRef.current?.focus(); } }, [replyTo]); // Close popover on channel change useEffect(() => { setActivePopover(null); }, [channelId]); // Filter members for the mention popover 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 isDm = isDmChannel(channelId); if (isDm) { 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]); 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]!; 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)); } catch (err) { failedFiles.push(file.name); const msg = err instanceof Error ? err.message : 'Upload failed'; addToast(`Failed to upload ${file.name}: ${msg}`, 'warning'); } } if (failedFiles.length > 0 && attachmentIds.length === 0 && !trimmed) { // All uploads failed, no text — nothing to send return; } 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(); } // Clear typing timeout if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = undefined; } } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to send message'; addToast(msg, 'warning'); } finally { setIsUploading(false); setUploadProgress(new Map()); } }; 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); // 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]); const handleKeyDown = (e: React.KeyboardEvent) => { // 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(); handleSubmit(); } }; const handlePaste = (e: React.ClipboardEvent) => { 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 (pastedFiles.length > 0) { setFiles((prev) => [...prev, ...pastedFiles]); } }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); const droppedFiles = Array.from(e.dataTransfer.files); if (droppedFiles.length > 0) { setFiles((prev) => [...prev, ...droppedFiles]); } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); }; const removeFile = (index: number) => { setFiles((prev) => prev.filter((_, i) => i !== index)); }; const handleChange = (e: React.ChangeEvent) => { const value = e.target.value; const cursorPos = e.target.selectionStart; setContent(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) { 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); // Restore cursor position after the emoji const newCursorPos = start + emoji.native.length; requestAnimationFrame(() => { textarea.focus(); textarea.selectionStart = newCursorPos; textarea.selectionEnd = newCursorPos; }); }, [content]); const handleGifSelect = useCallback((url: string) => { setActivePopover(null); sendMessage(channelId, url); }, [channelId, sendMessage]); const togglePopover = useCallback((tab: InputPopoverTab) => { setActivePopover((prev) => prev === tab ? null : tab); }, []); const canSend = (content.trim() || files.length > 0) && !isOverLimit && !isUploading; 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} /> )} {replyTo && (
Replying to {replyTo.user.displayName ?? replyTo.user.username}
)}
{/* Mention autocomplete popover */} {mentionState && filteredMembers.length > 0 && ( )} {/* File previews */} {files.length > 0 && (
{files.map((file, i) => { const progress = uploadProgress.get(i); return (
{file.type.startsWith('image/') ? ( {file.name} ) : (
{file.name}
)} {/* Upload progress bar */} {progress !== undefined && progress < 100 && (
)} {progress !== undefined && progress < 100 && (
{progress}%
)} {!isUploading && ( )}
); })}
)}
{/* File attach button */} {canAttachFiles && ( )} { const selected = Array.from(e.target.files ?? []); if (selected.length > 0) { setFiles((prev) => [...prev, ...selected]); } e.target.value = ''; }} /> {/* Text input */}