/** * MentionInput - Text input/textarea with @mention support * * Detects @ character and shows user dropdown for selection. * Inserts @{full_name} pattern when a user is selected. */ function MentionInput({ value = '', onChange, placeholder = '', className = '', rows = 1, maxLength, onKeyDown, disabled = false, id, }) { const { user: currentUser } = useAuth(); const [showDropdown, setShowDropdown] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); const [users, setUsers] = React.useState([]); const [filteredUsers, setFilteredUsers] = React.useState([]); const [selectedIndex, setSelectedIndex] = React.useState(0); const [mentionStartIndex, setMentionStartIndex] = React.useState(null); const inputRef = React.useRef(null); const dropdownRef = React.useRef(null); // Fetch all users on mount for client-side filtering (excluding current user) React.useEffect(() => { const loadUsers = async () => { try { const userData = await ApiUtils.fetchUsersForMentions(); // Filter out the current user - they can't mention themselves const filteredData = (userData || []).filter(u => u.id !== currentUser?.id); setUsers(filteredData); } catch (err) { console.error('Failed to load users for mentions:', err); } }; loadUsers(); }, [currentUser?.id]); // Filter users based on search query React.useEffect(() => { if (!searchQuery) { setFilteredUsers(users.slice(0, 10)); } else { const query = searchQuery.toLowerCase(); const filtered = users .filter(u => u.full_name && u.full_name.toLowerCase().includes(query)) .slice(0, 10); setFilteredUsers(filtered); } setSelectedIndex(0); }, [searchQuery, users]); // Handle input changes const handleChange = (e) => { const newValue = e.target.value; const cursorPos = e.target.selectionStart; // Check if we need to open/close dropdown const textBeforeCursor = newValue.substring(0, cursorPos); const atIndex = textBeforeCursor.lastIndexOf('@'); if (atIndex !== -1) { // Check if @ is at start or preceded by whitespace const charBefore = atIndex > 0 ? textBeforeCursor[atIndex - 1] : ' '; if (charBefore === ' ' || charBefore === '\n' || atIndex === 0) { // Check if there's no closing } or whitespace between @ and cursor const textAfterAt = textBeforeCursor.substring(atIndex + 1); if (!textAfterAt.includes('}') && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { // Open dropdown setShowDropdown(true); setMentionStartIndex(atIndex); setSearchQuery(textAfterAt); } else { setShowDropdown(false); } } else { setShowDropdown(false); } } else { setShowDropdown(false); } onChange(newValue); }; // Handle user selection from dropdown const selectUser = (user) => { if (mentionStartIndex === null || !inputRef.current) return; const beforeMention = value.substring(0, mentionStartIndex); const afterCursor = value.substring(inputRef.current.selectionStart); const mentionText = `@{${user.full_name}}`; const newValue = beforeMention + mentionText + ' ' + afterCursor; onChange(newValue); setShowDropdown(false); setMentionStartIndex(null); setSearchQuery(''); // Focus back on input setTimeout(() => { if (inputRef.current) { const newCursorPos = beforeMention.length + mentionText.length + 1; inputRef.current.focus(); inputRef.current.setSelectionRange(newCursorPos, newCursorPos); } }, 0); }; // Handle keyboard navigation in dropdown const handleKeyDown = (e) => { if (showDropdown && filteredUsers.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(prev => Math.min(prev + 1, filteredUsers.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(prev => Math.max(prev - 1, 0)); } else if (e.key === 'Enter' && filteredUsers[selectedIndex]) { e.preventDefault(); selectUser(filteredUsers[selectedIndex]); } else if (e.key === 'Escape') { e.preventDefault(); setShowDropdown(false); } else if (e.key === 'Tab') { // Close dropdown on tab setShowDropdown(false); } } else if (onKeyDown) { onKeyDown(e); } }; // Close dropdown on outside click React.useEffect(() => { const handleClickOutside = (e) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target) && inputRef.current && !inputRef.current.contains(e.target)) { setShowDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Scroll selected item into view React.useEffect(() => { if (showDropdown && dropdownRef.current) { const selectedEl = dropdownRef.current.children[selectedIndex]; if (selectedEl) { selectedEl.scrollIntoView({ block: 'nearest' }); } } }, [selectedIndex, showDropdown]); const InputComponent = rows > 1 ? 'textarea' : 'input'; const baseClassName = 'w-full min-w-0 px-3 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 text-sm resize-none disabled:bg-slate-100 disabled:cursor-not-allowed'; const inputProps = { ref: inputRef, id, value: value || '', onChange: handleChange, onKeyDown: handleKeyDown, placeholder, disabled, maxLength, className: `${baseClassName} ${className}`, }; if (rows > 1) { inputProps.rows = rows; } else { inputProps.type = 'text'; } // Extract flex-related classes for the wrapper div const wrapperClasses = className.includes('flex-1') ? 'relative flex-1 min-w-0' : 'relative'; return (