import React, { useState, useEffect, useRef, useCallback } from 'react' import MessageBubble from './MessageBubble' /** * Right panel: scrollable thread of messages with send form. */ export default function ConversationThread({ conversationId, conversation, currentUserId, currentUsername, apiFetch, onBack, onMarkRead, onConversationUpdated, }) { const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(true) const [sending, setSending] = useState(false) const [body, setBody] = useState('') const [nextCursor, setNextCursor] = useState(null) const [loadingMore, setLoadingMore] = useState(false) const [error, setError] = useState(null) const [attachments, setAttachments] = useState([]) const [typingUsers, setTypingUsers] = useState([]) const [threadSearch, setThreadSearch] = useState('') const [threadSearchResults, setThreadSearchResults] = useState([]) const fileInputRef = useRef(null) const bottomRef = useRef(null) const threadRef = useRef(null) const pollRef = useRef(null) const typingPollRef = useRef(null) const typingStopTimerRef = useRef(null) const latestIdRef = useRef(null) const shouldAutoScrollRef = useRef(true) const draftKey = `nova_draft_${conversationId}` // ── Initial load ───────────────────────────────────────────────────────── const loadMessages = useCallback(async () => { try { const data = await apiFetch(`/api/messages/${conversationId}`) const msgs = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId)) setMessages(msgs) setNextCursor(data.next_cursor ?? null) setLoading(false) if (msgs.length) latestIdRef.current = msgs[msgs.length - 1].id shouldAutoScrollRef.current = true } catch (e) { setError(e.message) setLoading(false) } }, [conversationId, currentUserId, apiFetch]) useEffect(() => { setLoading(true) setMessages([]) const storedDraft = window.localStorage.getItem(draftKey) setBody(storedDraft ?? '') loadMessages() // Phase 1 polling: check new messages every 10 seconds pollRef.current = setInterval(async () => { try { const data = await apiFetch(`/api/messages/${conversationId}`) const latestChunk = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId)) if (latestChunk.length && latestChunk[latestChunk.length - 1].id !== latestIdRef.current) { shouldAutoScrollRef.current = true setMessages(prev => mergeMessageLists(prev, latestChunk)) latestIdRef.current = latestChunk[latestChunk.length - 1].id onConversationUpdated() } } catch (_) {} }, 10_000) return () => clearInterval(pollRef.current) }, [conversationId, draftKey]) useEffect(() => { typingPollRef.current = setInterval(async () => { try { const data = await apiFetch(`/api/messages/${conversationId}/typing`) setTypingUsers(data.typing ?? []) } catch (_) {} }, 2_000) return () => { clearInterval(typingPollRef.current) clearTimeout(typingStopTimerRef.current) apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) } }, [conversationId, apiFetch]) useEffect(() => { const content = body.trim() if (!content) { clearTimeout(typingStopTimerRef.current) apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) return } apiFetch(`/api/messages/${conversationId}/typing`, { method: 'POST' }).catch(() => {}) clearTimeout(typingStopTimerRef.current) typingStopTimerRef.current = setTimeout(() => { apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) }, 2500) }, [body, conversationId, apiFetch]) useEffect(() => { if (body.trim()) { window.localStorage.setItem(draftKey, body) return } window.localStorage.removeItem(draftKey) }, [body, draftKey]) // ── Scroll to bottom on first load and new messages ─────────────────────── useEffect(() => { if (!loading && shouldAutoScrollRef.current) { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) shouldAutoScrollRef.current = false } }, [loading, messages.length]) // ── Mark as read when thread is viewed ──────────────────────────────────── useEffect(() => { if (!loading) { apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' }) .then(() => onMarkRead(conversationId)) .catch(() => {}) } }, [loading, conversationId]) // ── Load older messages ─────────────────────────────────────────────────── const loadMore = useCallback(async () => { if (!nextCursor || loadingMore) return setLoadingMore(true) const container = threadRef.current const prevHeight = container?.scrollHeight ?? 0 try { const data = await apiFetch(`/api/messages/${conversationId}?cursor=${nextCursor}`) const older = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId)) shouldAutoScrollRef.current = false setMessages(prev => [...older, ...prev]) setNextCursor(data.next_cursor ?? null) requestAnimationFrame(() => { if (!container) return const newHeight = container.scrollHeight container.scrollTop = Math.max(0, newHeight - prevHeight + container.scrollTop) }) } catch (_) {} setLoadingMore(false) }, [nextCursor, loadingMore, apiFetch, conversationId, currentUserId]) const handleThreadScroll = useCallback((e) => { if (e.currentTarget.scrollTop < 120) { loadMore() } }, [loadMore]) // ── Send message ────────────────────────────────────────────────────────── const handleSend = useCallback(async (e) => { e.preventDefault() const text = body.trim() if ((!text && attachments.length === 0) || sending) return setSending(true) const optimistic = { id: `opt-${Date.now()}`, sender_id: currentUserId, sender: { id: currentUserId, username: currentUsername }, body: text, created_at: new Date().toISOString(), _optimistic: true, attachments: attachments.map((file, index) => ({ id: `tmp-${Date.now()}-${index}`, type: file.type.startsWith('image/') ? 'image' : 'file', original_name: file.name, })), } setMessages(prev => [...prev, optimistic]) setBody('') window.localStorage.removeItem(draftKey) shouldAutoScrollRef.current = true bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) try { const formData = new FormData() formData.append('body', text) attachments.forEach(file => formData.append('attachments[]', file)) const msg = await apiFetch(`/api/messages/${conversationId}`, { method: 'POST', body: formData, }) setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m)) latestIdRef.current = msg.id onConversationUpdated() setAttachments([]) } catch (e) { setMessages(prev => prev.filter(m => m.id !== optimistic.id)) setError(e.message) } finally { setSending(false) } }, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey]) const handleKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend(e) } }, [handleSend]) // ── Reaction ────────────────────────────────────────────────────────────── const handleReact = useCallback(async (messageId, emoji) => { try { await apiFetch(`/api/messages/${messageId}/reactions`, { method: 'POST', body: JSON.stringify({ reaction: emoji }), }) // Optimistically add reaction with _iMine flag setMessages(prev => prev.map(m => { if (m.id !== messageId) return m const existing = (m.reactions ?? []).some(r => r._iMine && r.reaction === emoji) if (existing) return m return { ...m, reactions: [...(m.reactions ?? []), { reaction: emoji, user_id: currentUserId, _iMine: true }] } })) } catch (_) {} }, [currentUserId, apiFetch]) const handleUnreact = useCallback(async (messageId, emoji) => { try { await apiFetch(`/api/messages/${messageId}/reactions`, { method: 'DELETE', body: JSON.stringify({ reaction: emoji }), }) // Optimistically remove reaction setMessages(prev => prev.map(m => { if (m.id !== messageId) return m return { ...m, reactions: (m.reactions ?? []).filter(r => !(r._iMine && r.reaction === emoji)) } })) } catch (_) {} }, [apiFetch]) const handleEdit = useCallback(async (messageId, newBody) => { const updated = await apiFetch(`/api/messages/message/${messageId}`, { method: 'PATCH', body: JSON.stringify({ body: newBody }), }) setMessages(prev => prev.map(m => m.id === messageId ? { ...m, body: updated.body, edited_at: updated.edited_at } : m)) }, [apiFetch]) const handleReportMessage = useCallback(async (messageId) => { try { await apiFetch('/api/reports', { method: 'POST', body: JSON.stringify({ target_type: 'message', target_id: messageId, reason: 'inappropriate', details: '', }), }) } catch (e) { setError(e.message) } }, [apiFetch]) const handlePickAttachments = useCallback((e) => { const next = Array.from(e.target.files ?? []) if (!next.length) return setAttachments(prev => [...prev, ...next].slice(0, 5)) e.target.value = '' }, []) const removeAttachment = useCallback((index) => { setAttachments(prev => prev.filter((_, i) => i !== index)) }, []) const togglePin = useCallback(async () => { const me = conversation?.all_participants?.find(p => p.user_id === currentUserId) const isPinned = !!me?.is_pinned const endpoint = isPinned ? 'unpin' : 'pin' try { await apiFetch(`/api/messages/${conversationId}/${endpoint}`, { method: 'POST' }) onConversationUpdated() } catch (e) { setError(e.message) } }, [conversation, currentUserId, apiFetch, conversationId, onConversationUpdated]) useEffect(() => { let cancelled = false const q = threadSearch.trim() if (q.length < 2) { setThreadSearchResults([]) return } const timer = setTimeout(async () => { try { const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}&conversation_id=${conversationId}`) if (!cancelled) { setThreadSearchResults(data.data ?? []) } } catch (_) { if (!cancelled) { setThreadSearchResults([]) } } }, 250) return () => { cancelled = true clearTimeout(timer) } }, [threadSearch, conversationId, apiFetch]) const jumpToMessage = useCallback((messageId) => { const target = document.getElementById(`message-${messageId}`) if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }, []) // ── Thread header label ─────────────────────────────────────────────────── const threadLabel = conversation?.type === 'group' ? (conversation?.title ?? 'Group conversation') : (conversation?.all_participants?.find(p => p.user_id !== currentUserId)?.user?.username ?? 'Direct message') const otherParticipant = conversation?.all_participants?.find(p => p.user_id !== currentUserId) const otherLastReadAt = otherParticipant?.last_read_at ?? null const lastMessageId = messages[messages.length - 1]?.id ?? null // ── Group date separators from messages ────────────────────────────────── const grouped = groupByDate(messages) return (
{threadLabel}
{conversation?.type === 'group' && ({conversation.all_participants?.filter(p => !p.left_at).length ?? 0} members
)} {typingUsers.length > 0 && ({typingUsers.map(u => u.username).join(', ')} typing…
)}No matches
)} {threadSearchResults.map(item => ( ))}