import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { useChatStore } from '../../stores/chatStore'; import { isDmChannel, useServerStore } from '../../stores/serverStore'; import { wsSend } from '../../hooks/useWebSocket'; import { api } from '../../api/client'; import { MentionPopover } from './MentionPopover'; import type { MemberWithUser } from '@backspace/shared'; 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 [mentionState, setMentionState] = useState(null); const fileInputRef = useRef(null); const textareaRef = useRef(null); const sendMessage = useChatStore((s) => s.sendMessage); const replyTo = useChatStore((s) => s.replyTo); const setReplyTo = useChatStore((s) => s.setReplyTo); const members = useServerStore((s) => s.members); const typingTimeoutRef = useRef>(); // Auto-focus textarea on channel navigation useEffect(() => { textareaRef.current?.focus(); }, [channelId]); // Auto-focus textarea when replying useEffect(() => { if (replyTo) { textareaRef.current?.focus(); } }, [replyTo]); // 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 }); } else { wsSend({ type: 'typing_start', channelId }); } typingTimeoutRef.current = setTimeout(() => { typingTimeoutRef.current = undefined; }, 3000); }, [channelId]); const handleSubmit = async () => { const trimmed = content.trim(); if (!trimmed && files.length === 0) return; setIsUploading(true); setMentionState(null); try { // Upload files first const attachmentIds: string[] = []; for (const file of files) { const attachment = await api.uploads.upload(file); attachmentIds.push(attachment.id); } await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined); setContent(''); setFiles([]); // Reset textarea height if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } // Clear typing timeout if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = undefined; } } catch (err) { console.error('Failed to send message:', err); } finally { setIsUploading(false); } }; 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'; }; return (
{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) => (
{file.type.startsWith('image/') ? ( {file.name} ) : (
{file.name}
)}
))}
)}
{/* File attach button */} { const selected = Array.from(e.target.files ?? []); if (selected.length > 0) { setFiles((prev) => [...prev, ...selected]); } e.target.value = ''; }} /> {/* Text input */}