import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import MessageBubble from './MessageBubble' export default function ConversationThread({ conversationId, conversation, realtimeEnabled, currentUserId, currentUsername, apiFetch, onBack, onMarkRead, onConversationUpdated, }) { const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [nextCursor, setNextCursor] = useState(null) const [body, setBody] = useState('') const [files, setFiles] = useState([]) const [sending, setSending] = useState(false) const [error, setError] = useState(null) const [typingUsers, setTypingUsers] = useState([]) const [threadSearch, setThreadSearch] = useState('') const [busyAction, setBusyAction] = useState(null) const [lightbox, setLightbox] = useState(null) const [draftTitle, setDraftTitle] = useState(conversation?.title ?? '') const listRef = useRef(null) const fileInputRef = useRef(null) const typingRef = useRef(null) const stopTypingRef = useRef(null) const lastStartRef = useRef(0) const initialLoadRef = useRef(true) const knownMessageIdsRef = useRef(new Set()) const animatedMessageIdsRef = useRef(new Set()) const [animatedMessageIds, setAnimatedMessageIds] = useState({}) const prefersReducedMotion = usePrefersReducedMotion() const myParticipant = useMemo(() => ( conversation?.my_participant ?? conversation?.all_participants?.find((participant) => participant.user_id === currentUserId) ?? null ), [conversation, currentUserId]) const participants = useMemo(() => conversation?.all_participants ?? [], [conversation]) const participantNames = useMemo(() => ( participants .map((participant) => participant.user?.username) .filter(Boolean) ), [participants]) const filteredMessages = useMemo(() => { const query = threadSearch.trim().toLowerCase() if (!query) return messages return messages.filter((message) => { const sender = message.sender?.username?.toLowerCase() ?? '' const text = message.body?.toLowerCase() ?? '' const attachmentNames = (message.attachments ?? []).map((attachment) => attachment.original_name?.toLowerCase() ?? '').join(' ') return sender.includes(query) || text.includes(query) || attachmentNames.includes(query) }) }, [messages, threadSearch]) const conversationLabel = useMemo(() => { if (!conversation) return 'Conversation' if (conversation.type === 'group') return conversation.title ?? 'Group conversation' return participants.find((participant) => participant.user_id !== currentUserId)?.user?.username ?? 'Direct message' }, [conversation, currentUserId, participants]) const loadMessages = useCallback(async ({ cursor = null, append = false, silent = false } = {}) => { if (append) setLoadingMore(true) else if (!silent) setLoading(true) try { const url = cursor ? `/api/messages/${conversationId}?cursor=${encodeURIComponent(cursor)}` : `/api/messages/${conversationId}` const data = await apiFetch(url) const incoming = normalizeMessages(data.data ?? [], currentUserId) setNextCursor(data.next_cursor ?? null) setMessages((prev) => append ? mergeMessageLists(incoming, prev) : incoming) setError(null) } catch (e) { setError(e.message) } finally { if (!silent) setLoading(false) setLoadingMore(false) } }, [apiFetch, conversationId, currentUserId]) const loadTyping = useCallback(async () => { try { const data = await apiFetch(`/api/messages/${conversationId}/typing`) setTypingUsers(data.typing ?? []) } catch { setTypingUsers([]) } }, [apiFetch, conversationId]) useEffect(() => { initialLoadRef.current = true setMessages([]) setNextCursor(null) setBody('') setFiles([]) setDraftTitle(conversation?.title ?? '') loadMessages() loadTyping() }, [conversation?.title, conversationId, loadMessages, loadTyping]) useEffect(() => { apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' }) .then(() => onMarkRead?.(conversationId)) .catch(() => {}) }, [apiFetch, conversationId, onMarkRead]) useEffect(() => { const timer = window.setInterval(() => { loadTyping() if (!realtimeEnabled) { loadMessages({ silent: true }) } }, realtimeEnabled ? 5000 : 8000) return () => window.clearInterval(timer) }, [loadMessages, loadTyping, realtimeEnabled]) useEffect(() => () => { if (typingRef.current) window.clearTimeout(typingRef.current) if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current) }, []) useEffect(() => { if (!listRef.current) return if (initialLoadRef.current) { listRef.current.scrollTop = listRef.current.scrollHeight initialLoadRef.current = false return } const nearBottom = listRef.current.scrollHeight - listRef.current.scrollTop - listRef.current.clientHeight < 180 if (nearBottom) { listRef.current.scrollTop = listRef.current.scrollHeight } }, [messages.length]) useEffect(() => { const known = knownMessageIdsRef.current const nextAnimatedIds = [] for (const message of messages) { if (known.has(message.id)) continue known.add(message.id) if (!initialLoadRef.current && !message._optimistic && !animatedMessageIdsRef.current.has(message.id)) { animatedMessageIdsRef.current.add(message.id) nextAnimatedIds.push(message.id) } } if (prefersReducedMotion || nextAnimatedIds.length === 0) return undefined setAnimatedMessageIds((prev) => { const next = { ...prev } nextAnimatedIds.forEach((id) => { next[id] = true }) return next }) const timer = window.setTimeout(() => { setAnimatedMessageIds((prev) => { const next = { ...prev } nextAnimatedIds.forEach((id) => { delete next[id] }) return next }) }, 2200) return () => window.clearTimeout(timer) }, [messages, prefersReducedMotion]) const handleBodyChange = useCallback((value) => { setBody(value) const now = Date.now() if (now - lastStartRef.current > 2500) { lastStartRef.current = now apiFetch(`/api/messages/${conversationId}/typing`, { method: 'POST' }).catch(() => {}) } if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current) stopTypingRef.current = window.setTimeout(() => { apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) }, 1200) }, [apiFetch, conversationId]) const handleFiles = useCallback((selectedFiles) => { const nextFiles = Array.from(selectedFiles || []).slice(0, 5) setFiles(nextFiles) }, []) const handleSend = useCallback(async (e) => { e.preventDefault() if (sending) return const trimmed = body.trim() if (!trimmed && files.length === 0) return const optimisticId = `optimistic-${Date.now()}` const optimisticMessage = normalizeMessage({ id: optimisticId, body: trimmed, sender: { id: currentUserId, username: currentUsername }, sender_id: currentUserId, created_at: new Date().toISOString(), attachments: files.map((file, index) => ({ id: `${optimisticId}-${index}`, type: file.type?.startsWith('image/') ? 'image' : 'file', original_name: file.name, })), reactions: [], _optimistic: true, }, currentUserId) setMessages((prev) => mergeMessageLists(prev, [optimisticMessage])) setBody('') setFiles([]) if (fileInputRef.current) fileInputRef.current.value = '' setSending(true) setError(null) const formData = new FormData() if (trimmed) formData.append('body', trimmed) files.forEach((file) => formData.append('attachments[]', file)) try { const created = await apiFetch(`/api/messages/${conversationId}`, { method: 'POST', body: formData, }) const normalized = normalizeMessage(created, currentUserId) setMessages((prev) => prev.map((message) => message.id === optimisticId ? normalized : message)) onConversationUpdated?.() } catch (err) { setMessages((prev) => prev.filter((message) => message.id !== optimisticId)) setBody(trimmed) setFiles(files) setError(err.message) } finally { setSending(false) apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) } }, [apiFetch, body, conversationId, currentUserId, currentUsername, files, onConversationUpdated, sending]) const updateReactions = useCallback((messageId, summary) => { setMessages((prev) => prev.map((message) => { if (message.id !== messageId) return message return { ...message, reactions: summaryToReactionArray(summary, currentUserId), } })) }, [currentUserId]) const handleReact = useCallback(async (messageId, reaction) => { const summary = await apiFetch(`/api/messages/${messageId}/reactions`, { method: 'POST', body: JSON.stringify({ reaction }), }) updateReactions(messageId, summary) }, [apiFetch, updateReactions]) const handleUnreact = useCallback(async (messageId, reaction) => { const summary = await apiFetch(`/api/messages/${messageId}/reactions`, { method: 'DELETE', body: JSON.stringify({ reaction }), }) updateReactions(messageId, summary) }, [apiFetch, updateReactions]) const handleEdit = useCallback(async (messageId, nextBody) => { const updated = await apiFetch(`/api/messages/message/${messageId}`, { method: 'PATCH', body: JSON.stringify({ body: nextBody }), }) setMessages((prev) => prev.map((message) => ( message.id === messageId ? normalizeMessage({ ...message, ...updated }, currentUserId) : message ))) onConversationUpdated?.() }, [apiFetch, currentUserId, onConversationUpdated]) const handleDelete = useCallback(async (messageId) => { await apiFetch(`/api/messages/message/${messageId}`, { method: 'DELETE' }) setMessages((prev) => prev.map((message) => ( message.id === messageId ? { ...message, body: '', deleted_at: new Date().toISOString(), attachments: [] } : message ))) onConversationUpdated?.() }, [apiFetch, onConversationUpdated]) const runConversationAction = useCallback(async (action, url, apply) => { setBusyAction(action) setError(null) try { const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' }) apply?.(response) onConversationUpdated?.() if (action === 'leave') onBack?.() } catch (e) { setError(e.message) } finally { setBusyAction(null) } }, [apiFetch, onBack, onConversationUpdated]) const handleRename = useCallback(async () => { const title = draftTitle.trim() if (!title || title === conversation?.title) return setBusyAction('rename') setError(null) try { await apiFetch(`/api/messages/${conversationId}/rename`, { method: 'POST', body: JSON.stringify({ title }), }) onConversationUpdated?.() } catch (e) { setError(e.message) } finally { setBusyAction(null) } }, [apiFetch, conversation?.title, conversationId, draftTitle, onConversationUpdated]) const visibleMessages = filteredMessages const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at]) const typingLabel = buildTypingLabel(typingUsers) return (
{conversation?.type === 'group' ? `Participants: ${participantNames.join(', ')}` : `Private thread with ${participantNames.find((name) => name !== currentUsername) ?? conversationLabel}.`}
{threadSearch.trim() ? 'Try a different search term or clear the filter to see the full thread.' : 'Start the conversation with a message, file, or quick note.'}