import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import MessageBubble from './MessageBubble' /** * Right panel: scrollable thread of messages with send form. */ export default function ConversationThread({ conversationId, conversation, realtimeEnabled = false, 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 [uploadProgress, setUploadProgress] = useState(null) const [typingUsers, setTypingUsers] = useState([]) const [threadSearch, setThreadSearch] = useState('') const [threadSearchResults, setThreadSearchResults] = useState([]) const [lightboxImage, setLightboxImage] = useState(null) 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}` const previewAttachments = useMemo(() => { return attachments.map(file => ({ file, previewUrl: isImageLike(file) ? URL.createObjectURL(file) : null, })) }, [attachments]) useEffect(() => { return () => { for (const item of previewAttachments) { if (item.previewUrl) { URL.revokeObjectURL(item.previewUrl) } } } }, [previewAttachments]) // ── 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() if (!realtimeEnabled) { 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 () => { if (pollRef.current) clearInterval(pollRef.current) } }, [conversationId, draftKey, realtimeEnabled, currentUserId, apiFetch, loadMessages, onConversationUpdated]) useEffect(() => { if (!realtimeEnabled) { typingPollRef.current = setInterval(async () => { try { const data = await apiFetch(`/api/messages/${conversationId}/typing`) setTypingUsers(data.typing ?? []) } catch (_) {} }, 2_000) } return () => { if (typingPollRef.current) clearInterval(typingPollRef.current) clearTimeout(typingStopTimerRef.current) apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) } }, [conversationId, apiFetch, realtimeEnabled]) 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)) setUploadProgress(0) const msg = await sendMessageWithProgress(`/api/messages/${conversationId}`, formData, (progress) => { setUploadProgress(progress) }) 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 { setUploadProgress(null) 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]) const toggleMute = useCallback(async () => { try { await apiFetch(`/api/messages/${conversationId}/mute`, { method: 'POST' }) onConversationUpdated() } catch (e) { setError(e.message) } }, [apiFetch, conversationId, onConversationUpdated]) const toggleArchive = useCallback(async () => { try { await apiFetch(`/api/messages/${conversationId}/archive`, { method: 'POST' }) onConversationUpdated() } catch (e) { setError(e.message) } }, [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 myParticipant = conversation?.my_participant ?? conversation?.all_participants?.find(p => p.user_id === currentUserId) 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 (
{/* Header */}

{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…

)}
setThreadSearch(e.target.value)} placeholder="Search in this conversation…" className="w-full rounded-lg bg-gray-100 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500" /> {threadSearch.trim().length >= 2 && (
{threadSearchResults.length === 0 && (

No matches

)} {threadSearchResults.map(item => ( ))}
)}
{/* Messages */}
{loadingMore && (
Loading older messages…
)} {loading && (
Loading messages…
)} {!loading && messages.length === 0 && (
No messages yet. Say hello!
)} {grouped.map(({ date, messages: dayMessages }) => (

{date}
{dayMessages.map((msg, idx) => (
))}
))}
{/* Error */} {error && (
{error}
)} {/* Compose */}