import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { getEcho } from '../../bootstrap' import MessageBubble from './MessageBubble' export default function ConversationThread({ conversationId, conversation, realtimeEnabled, realtimeStatus, currentUserId, currentUsername, onlineUserIds, apiFetch, onBack, onMarkRead, onConversationPatched, }) { 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 [participantState, setParticipantState] = useState(conversation?.all_participants ?? []) const [presenceUsers, setPresenceUsers] = 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 readReceiptRef = useRef(null) const typingExpiryTimersRef = useRef(new Map()) const messagesRef = useRef([]) const lastStartRef = useRef(0) const initialLoadRef = useRef(true) const shouldStickToBottomRef = useRef(true) const previousScrollHeightRef = useRef(0) const pendingPrependRef = useRef(false) const pendingComposerScrollRef = useRef(false) const knownMessageIdsRef = useRef(new Set()) const animatedMessageIdsRef = useRef(new Set()) const [animatedMessageIds, setAnimatedMessageIds] = useState({}) const prefersReducedMotion = usePrefersReducedMotion() const myParticipant = useMemo(() => ( conversation?.my_participant ?? participantState.find((participant) => participant.user_id === currentUserId) ?? null ), [conversation, currentUserId, participantState]) const participants = useMemo(() => participantState, [participantState]) const participantNames = useMemo(() => ( participants .map((participant) => participant.user?.username) .filter(Boolean) ), [participants]) const remoteParticipantNames = useMemo(() => ( participants .filter((participant) => participant.user_id !== currentUserId) .map((participant) => participant.user?.username) .filter(Boolean) ), [currentUserId, participants]) const directParticipant = useMemo(() => ( participants.find((participant) => participant.user_id !== currentUserId) ?? null ), [currentUserId, participants]) const remoteIsOnline = directParticipant ? onlineUserIds.includes(Number(directParticipant.user_id)) : false const remoteIsViewingConversation = directParticipant ? presenceUsers.some((user) => Number(user?.id) === Number(directParticipant.user_id)) : false 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 patchConversation = useCallback((patch) => { if (!patch) { return } onConversationPatched?.({ id: conversationId, ...patch, }) }, [conversationId, onConversationPatched]) const patchLastMessage = useCallback((message, extra = {}) => { if (!message) { return } patchConversation({ latest_message: message, last_message_at: message.created_at ?? new Date().toISOString(), ...extra, }) }, [patchConversation]) const patchMyParticipantState = useCallback((changes) => { if (!changes) { return } setParticipantState((prev) => prev.map((participant) => ( participant.user_id === currentUserId ? { ...participant, ...changes } : participant ))) patchConversation({ my_participant: { ...(myParticipant ?? {}), ...changes, }, }) }, [currentUserId, myParticipant, patchConversation]) const scrollToBottom = useCallback(() => { if (!listRef.current) { return } listRef.current.scrollTop = listRef.current.scrollHeight shouldStickToBottomRef.current = true }, []) const loadMessages = useCallback(async ({ cursor = null, append = false, silent = false } = {}) => { if (append) setLoadingMore(true) else if (!silent) setLoading(true) if (append && listRef.current) { previousScrollHeightRef.current = listRef.current.scrollHeight pendingPrependRef.current = 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]) const markConversationRead = useCallback(async (messageId = null) => { try { const response = await apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST', body: JSON.stringify(messageId ? { message_id: messageId } : {}), }) setParticipantState((prev) => prev.map((participant) => ( participant.user_id === currentUserId ? { ...participant, last_read_at: response.last_read_at ?? new Date().toISOString(), last_read_message_id: response.last_read_message_id ?? messageId ?? participant.last_read_message_id, } : participant ))) onMarkRead?.(conversationId, response?.unread_total ?? null) } catch { // no-op } }, [apiFetch, conversationId, currentUserId, onMarkRead]) const queueReadReceipt = useCallback((messageId = null) => { if (readReceiptRef.current) { window.clearTimeout(readReceiptRef.current) } readReceiptRef.current = window.setTimeout(() => { markConversationRead(messageId) }, 220) }, [markConversationRead]) useEffect(() => { initialLoadRef.current = true shouldStickToBottomRef.current = true previousScrollHeightRef.current = 0 pendingPrependRef.current = false pendingComposerScrollRef.current = false knownMessageIdsRef.current = new Set() animatedMessageIdsRef.current = new Set() setMessages([]) setPresenceUsers([]) setTypingUsers([]) setNextCursor(null) setBody('') setFiles([]) loadMessages() if (!realtimeEnabled) { loadTyping() } }, [conversationId, loadMessages, loadTyping, realtimeEnabled]) useEffect(() => { setParticipantState(conversation?.all_participants ?? []) setDraftTitle(conversation?.title ?? '') }, [conversation?.all_participants, conversation?.title]) useEffect(() => { markConversationRead() }, [markConversationRead]) useEffect(() => { if (realtimeEnabled) { return undefined } const timer = window.setInterval(() => { loadTyping() loadMessages({ silent: true }) }, 8000) return () => window.clearInterval(timer) }, [loadMessages, loadTyping, realtimeEnabled]) useEffect(() => () => { if (typingRef.current) window.clearTimeout(typingRef.current) if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current) if (readReceiptRef.current) window.clearTimeout(readReceiptRef.current) typingExpiryTimersRef.current.forEach((timer) => window.clearTimeout(timer)) }, []) useEffect(() => { messagesRef.current = messages }, [messages]) useLayoutEffect(() => { const container = listRef.current if (!container) { return } if (initialLoadRef.current) { scrollToBottom() initialLoadRef.current = false previousScrollHeightRef.current = container.scrollHeight return } if (pendingPrependRef.current) { const heightDelta = container.scrollHeight - previousScrollHeightRef.current container.scrollTop += heightDelta pendingPrependRef.current = false previousScrollHeightRef.current = container.scrollHeight return } const latestMessage = messages[messages.length - 1] ?? null const shouldScroll = pendingComposerScrollRef.current || shouldStickToBottomRef.current || latestMessage?._optimistic || latestMessage?.sender_id === currentUserId if (shouldScroll) { scrollToBottom() } pendingComposerScrollRef.current = false previousScrollHeightRef.current = container.scrollHeight }, [currentUserId, messages, scrollToBottom]) useEffect(() => { if (!realtimeEnabled) { return undefined } const echo = getEcho() if (!echo) { return undefined } const syncMissedMessages = async () => { const lastServerMessage = [...messagesRef.current] .reverse() .find((message) => Number.isFinite(Number(message.id)) && !message._optimistic) if (!lastServerMessage?.id) { return } try { const data = await apiFetch(`/api/messages/${conversationId}/delta?after_message_id=${encodeURIComponent(lastServerMessage.id)}`) const incoming = normalizeMessages(data.data ?? [], currentUserId) if (incoming.length > 0) { setMessages((prev) => mergeMessageLists(prev, incoming)) } } catch { // no-op } } const handleMessageCreated = (payload) => { if (!payload?.message) return const incoming = normalizeMessage(payload.message, currentUserId) setMessages((prev) => mergeMessageLists(prev, [incoming])) patchLastMessage(incoming, { unread_count: 0 }) if (incoming.sender_id !== currentUserId && document.visibilityState === 'visible') { queueReadReceipt(incoming.id) } } const handleMessageUpdated = (payload) => { if (!payload?.message) return const updated = normalizeMessage(payload.message, currentUserId) setMessages((prev) => mergeMessageLists(prev, [updated])) patchLastMessage(updated) } const handleMessageDeleted = (payload) => { const deletedAt = payload?.deleted_at ?? new Date().toISOString() setMessages((prev) => prev.map((message) => ( messagesMatch(message, payload) ? { ...message, body: '', deleted_at: deletedAt, attachments: [] } : message ))) patchConversation({ last_message_at: deletedAt }) } const handleMessageRead = (payload) => { if (!payload?.user?.id) return setParticipantState((prev) => prev.map((participant) => ( participant.user_id === payload.user.id ? { ...participant, last_read_at: payload.last_read_at ?? participant.last_read_at, last_read_message_id: payload.last_read_message_id ?? participant.last_read_message_id, } : participant ))) } const removeTypingUser = (userId) => { const existingTimer = typingExpiryTimersRef.current.get(userId) if (existingTimer) { window.clearTimeout(existingTimer) typingExpiryTimersRef.current.delete(userId) } setTypingUsers((prev) => prev.filter((user) => user.user_id !== userId && user.id !== userId)) } const handleTypingStarted = (payload) => { const user = payload?.user if (!user?.id || user.id === currentUserId) return setTypingUsers((prev) => mergeTypingUsers(prev, { user_id: user.id, username: user.username, })) removeTypingUser(user.id) const timeout = window.setTimeout(() => removeTypingUser(user.id), Number(payload?.expires_in_ms ?? 3500)) typingExpiryTimersRef.current.set(user.id, timeout) } const handleTypingStopped = (payload) => { const userId = payload?.user?.id if (!userId) return removeTypingUser(userId) } const privateChannel = echo.private(`conversation.${conversationId}`) privateChannel.listen('.message.created', handleMessageCreated) privateChannel.listen('.message.updated', handleMessageUpdated) privateChannel.listen('.message.deleted', handleMessageDeleted) privateChannel.listen('.message.read', handleMessageRead) const presenceChannel = echo.join(`conversation.${conversationId}`) presenceChannel .here((users) => setPresenceUsers(normalizePresenceUsers(users, currentUserId))) .joining((user) => setPresenceUsers((prev) => mergePresenceUsers(prev, user, currentUserId))) .leaving((user) => setPresenceUsers((prev) => prev.filter((member) => member.id !== user?.id))) .listen('.typing.started', handleTypingStarted) .listen('.typing.stopped', handleTypingStopped) const connection = echo.connector?.pusher?.connection connection?.bind?.('connected', syncMissedMessages) syncMissedMessages() return () => { connection?.unbind?.('connected', syncMissedMessages) typingExpiryTimersRef.current.forEach((timer) => window.clearTimeout(timer)) typingExpiryTimersRef.current.clear() echo.leave(`conversation.${conversationId}`) } }, [apiFetch, conversationId, currentUserId, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled]) 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) if (value.trim() === '') { if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current) apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) return } 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 clientTempId = `tmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` const optimisticMessage = normalizeMessage({ id: optimisticId, client_temp_id: clientTempId, 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) pendingComposerScrollRef.current = true shouldStickToBottomRef.current = true 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) formData.append('client_temp_id', clientTempId) 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) => mergeMessageLists(prev, [normalized])) patchLastMessage(normalized, { unread_count: 0 }) } catch (err) { setMessages((prev) => prev.filter((message) => !messagesMatch(message, { id: optimisticId, client_temp_id: clientTempId }))) 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, patchLastMessage, 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 ))) patchLastMessage(normalizeMessage(updated, currentUserId)) }, [apiFetch, currentUserId, patchLastMessage]) 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 ))) patchConversation({ last_message_at: new Date().toISOString() }) }, [apiFetch, patchConversation]) const runConversationAction = useCallback(async (action, url, apply) => { setBusyAction(action) setError(null) try { const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' }) apply?.(response) if (action === 'leave') onBack?.() } catch (e) { setError(e.message) } finally { setBusyAction(null) } }, [apiFetch, onBack]) 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 }), }) patchConversation({ title }) } catch (e) { setError(e.message) } finally { setBusyAction(null) } }, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation]) const visibleMessages = filteredMessages const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant), [visibleMessages, currentUserId, myParticipant]) const typingLabel = buildTypingLabel(typingUsers) const presenceLabel = conversation?.type === 'group' ? (presenceUsers.length > 0 ? `${presenceUsers.length} active now` : null) : (remoteIsViewingConversation ? 'Viewing this conversation' : (remoteIsOnline ? 'Online now' : null)) const typingSummary = typingUsers.length > 0 ? `${typingLabel} ${conversation?.type === 'group' ? '' : 'Reply will appear here instantly.'}`.trim() : null return (
{conversation?.type === 'group' ? `Participants: ${participantNames.join(', ')}` : `Private thread with ${participantNames.find((name) => name !== currentUsername) ?? conversationLabel}.`}
{typingSummary ? ({presenceLabel}
: conversation?.type === 'direct' && remoteParticipantNames.length > 0 ?Chatting with @{remoteParticipantNames[0]} in realtime.
: null}{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.'}