import { useState, useRef, useCallback, useEffect } from 'react'; import { api } from '../utils/api.js'; import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; import './MessageInput.css'; const URL_REGEX = /https?:\/\/[^\s]+/g; // Detect if a string is purely emoji characters (no other text) function isEmojiOnly(str) { const emojiRegex = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F|\u200D|[\u{1F1E0}-\u{1F1FF}])+$/u; return emojiRegex.test(str.trim()); } export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onlineUserIds = new Set() }) { const [text, setText] = useState(''); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState(null); const [mentionSearch, setMentionSearch] = useState(''); const [mentionResults, setMentionResults] = useState([]); const [mentionIndex, setMentionIndex] = useState(-1); const [showMention, setShowMention] = useState(false); const [linkPreview, setLinkPreview] = useState(null); const [loadingPreview, setLoadingPreview] = useState(false); const [showAttachMenu, setShowAttachMenu] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const inputRef = useRef(null); const typingTimer = useRef(null); const wasTyping = useRef(false); const mentionStart = useRef(-1); const fileInput = useRef(null); const cameraInput = useRef(null); const attachMenuRef = useRef(null); const emojiPickerRef = useRef(null); // Close attach menu / emoji picker on outside click useEffect(() => { const handler = (e) => { if (attachMenuRef.current && !attachMenuRef.current.contains(e.target)) { setShowAttachMenu(false); } if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target)) { setShowEmojiPicker(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, []); // Handle typing notification const handleTypingChange = (value) => { if (value && !wasTyping.current) { wasTyping.current = true; onTyping(true); } if (typingTimer.current) clearTimeout(typingTimer.current); typingTimer.current = setTimeout(() => { if (wasTyping.current) { wasTyping.current = false; onTyping(false); } }, 2000); }; // Link preview — 5 second timeout, then abandon and enable Send const previewTimeoutRef = useRef(null); const fetchPreview = useCallback(async (url) => { setLoadingPreview(true); setLinkPreview(null); if (previewTimeoutRef.current) clearTimeout(previewTimeoutRef.current); const abandonTimer = setTimeout(() => { setLoadingPreview(false); }, 5000); previewTimeoutRef.current = abandonTimer; try { const { preview } = await api.getLinkPreview(url); clearTimeout(abandonTimer); if (preview) setLinkPreview(preview); } catch { clearTimeout(abandonTimer); } setLoadingPreview(false); }, []); const handleChange = (e) => { const val = e.target.value; setText(val); handleTypingChange(val); const el = e.target; el.style.height = 'auto'; const lineHeight = parseFloat(getComputedStyle(el).lineHeight); const maxHeight = lineHeight * 5 + 20; el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px'; el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden'; const cur = e.target.selectionStart; const lastAt = val.lastIndexOf('@', cur - 1); if (lastAt !== -1) { const between = val.slice(lastAt + 1, cur); if (!between.includes(' ') && !between.includes('\n')) { mentionStart.current = lastAt; setMentionSearch(between); setShowMention(true); api.searchUsers(between, group?.id).then(({ users }) => { setMentionResults(users); setMentionIndex(0); }).catch(() => {}); return; } } setShowMention(false); const urls = val.match(URL_REGEX); if (urls && urls[0] !== linkPreview?.url) { fetchPreview(urls[0]); } else if (!urls) { setLinkPreview(null); } }; const insertMention = (user) => { const before = text.slice(0, mentionStart.current); const after = text.slice(inputRef.current.selectionStart); const name = user.display_name || user.name; setText(before + `@[${name}] ` + after); setShowMention(false); setMentionResults([]); inputRef.current.focus(); }; const handleKeyDown = (e) => { if (showMention && mentionResults.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => Math.min(i + 1, mentionResults.length - 1)); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => Math.max(i - 1, 0)); return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); if (mentionIndex >= 0) insertMention(mentionResults[mentionIndex]); return; } if (e.key === 'Escape') { setShowMention(false); return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const handleSend = async () => { const trimmed = text.trim(); if (!trimmed && !imageFile) return; const lp = linkPreview; setText(''); setLinkPreview(null); setImageFile(null); setImagePreview(null); wasTyping.current = false; onTyping(false); if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.style.overflowY = 'hidden'; } const emojiOnly = !!trimmed && isEmojiOnly(trimmed); await onSend({ content: trimmed || null, imageFile, linkPreview: lp, emojiOnly }); }; // Insert emoji at cursor position in the textarea const handleEmojiSelect = (emoji) => { setShowEmojiPicker(false); const el = inputRef.current; const native = emoji.native; if (el) { const start = el.selectionStart ?? 0; const end = el.selectionEnd ?? 0; const newText = text.slice(0, start) + native + text.slice(end); setText(newText); // Restore focus and move cursor after the inserted emoji requestAnimationFrame(() => { el.focus(); const pos = start + native.length; el.setSelectionRange(pos, pos); // Resize textarea el.style.height = 'auto'; const lineHeight = parseFloat(getComputedStyle(el).lineHeight); const maxHeight = lineHeight * 5 + 20; el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px'; el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden'; }); } else { // No ref yet — just append setText(prev => prev + native); } }; const compressImage = (file) => new Promise((resolve) => { const MAX_PX = 1920; const QUALITY = 0.82; const isPng = file.type === 'image/png'; const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); let { width, height } = img; if (width <= MAX_PX && height <= MAX_PX) { // already small } else { const ratio = Math.min(MAX_PX / width, MAX_PX / height); width = Math.round(width * ratio); height = Math.round(height * ratio); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!isPng) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, width, height); } ctx.drawImage(img, 0, 0, width, height); if (isPng) { canvas.toBlob(blob => resolve(new File([blob], file.name, { type: 'image/png' })), 'image/png'); } else { canvas.toBlob(blob => resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' })), 'image/jpeg', QUALITY); } }; img.src = url; }); const handleImageSelect = async (e) => { const file = e.target.files?.[0]; if (!file) return; const compressed = await compressImage(file); setImageFile(compressed); const reader = new FileReader(); reader.onload = (e) => setImagePreview(e.target.result); reader.readAsDataURL(compressed); setShowAttachMenu(false); }; // Detect mobile (touch device) const isMobile = () => window.matchMedia('(pointer: coarse)').matches; return (