import React, { useState, useEffect, useCallback } from 'react' import { createRoot } from 'react-dom/client' import { getEcho } from '../../bootstrap' import ConversationList from '../../components/messaging/ConversationList' import ConversationThread from '../../components/messaging/ConversationThread' import NewConversationModal from '../../components/messaging/NewConversationModal' function getCsrf() { return document.querySelector('meta[name="csrf-token"]')?.content ?? '' } async function apiFetch(url, options = {}) { const isFormData = options.body instanceof FormData const socketId = getEcho()?.socketId?.() const headers = { 'X-CSRF-TOKEN': getCsrf(), Accept: 'application/json', ...options.headers, } if (socketId) { headers['X-Socket-ID'] = socketId } if (!isFormData) { headers['Content-Type'] = 'application/json' } const res = await fetch(url, { headers, ...options, }) if (!res.ok) { const err = await res.json().catch(() => ({})) throw new Error(err.message ?? `HTTP ${res.status}`) } return res.json() } function relativeTime(iso) { if (!iso) return 'No activity yet' const diff = (Date.now() - new Date(iso).getTime()) / 1000 if (diff < 60) return 'Just now' if (diff < 3600) return `${Math.floor(diff / 60)}m ago` if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` if (diff < 604800) return `${Math.floor(diff / 86400)}d ago` return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', }) } function buildSearchPreview(item) { const body = (item?.body || '').trim() return body || '(attachment only)' } function MessagesPage({ userId, username, activeConversationId: initialId }) { const [conversations, setConversations] = useState([]) const [loadingConvs, setLoadingConvs] = useState(true) const [activeId, setActiveId] = useState(initialId ?? null) const [realtimeEnabled, setRealtimeEnabled] = useState(false) const [realtimeStatus, setRealtimeStatus] = useState('offline') const [typingByConversation, setTypingByConversation] = useState({}) const [showNewModal, setShowNewModal] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [searching, setSearching] = useState(false) const loadConversations = useCallback(async () => { try { const data = await apiFetch('/api/messages/conversations') setConversations(data.data ?? []) } catch (e) { console.error('Failed to load conversations', e) } finally { setLoadingConvs(false) } }, []) useEffect(() => { loadConversations() apiFetch('/api/messages/settings') .then((data) => setRealtimeEnabled(!!data?.realtime_enabled)) .catch(() => setRealtimeEnabled(false)) }, [loadConversations]) useEffect(() => { const handlePopState = () => { const match = window.location.pathname.match(/^\/messages\/(\d+)$/) setActiveId(match ? Number(match[1]) : null) } window.addEventListener('popstate', handlePopState) return () => window.removeEventListener('popstate', handlePopState) }, []) useEffect(() => { if (realtimeEnabled) { return undefined } const poll = window.setInterval(loadConversations, 15000) return () => window.clearInterval(poll) }, [loadConversations, realtimeEnabled]) useEffect(() => { if (!realtimeEnabled || !userId) { setRealtimeStatus('offline') return undefined } const echo = getEcho() if (!echo) { setRealtimeStatus('offline') return undefined } const connection = echo.connector?.pusher?.connection let heartbeatId = null const mapConnectionState = (state) => { if (state === 'connected') { return 'connected' } if (state === 'connecting' || state === 'initialized' || state === 'connecting_in') { return 'connecting' } return 'offline' } const syncConnectionState = (payload = null) => { const nextState = typeof payload?.current === 'string' ? payload.current : connection?.state if (echo.socketId?.()) { setRealtimeStatus('connected') return } setRealtimeStatus(mapConnectionState(nextState)) } const handleVisibilitySync = () => { if (document.visibilityState === 'visible') { syncConnectionState() } } syncConnectionState() connection?.bind?.('state_change', syncConnectionState) connection?.bind?.('connected', syncConnectionState) connection?.bind?.('unavailable', syncConnectionState) connection?.bind?.('disconnected', syncConnectionState) heartbeatId = window.setInterval(syncConnectionState, 1000) window.addEventListener('focus', syncConnectionState) document.addEventListener('visibilitychange', handleVisibilitySync) const channel = echo.private(`user.${userId}`) const handleConversationUpdated = (payload) => { const nextConversation = payload?.conversation if (!nextConversation?.id) { return } setConversations((prev) => mergeConversationSummary(prev, nextConversation)) } channel.listen('.conversation.updated', handleConversationUpdated) return () => { connection?.unbind?.('state_change', syncConnectionState) connection?.unbind?.('connected', syncConnectionState) connection?.unbind?.('unavailable', syncConnectionState) connection?.unbind?.('disconnected', syncConnectionState) if (heartbeatId) { window.clearInterval(heartbeatId) } window.removeEventListener('focus', syncConnectionState) document.removeEventListener('visibilitychange', handleVisibilitySync) channel.stopListening('.conversation.updated', handleConversationUpdated) echo.leaveChannel(`private-user.${userId}`) } }, [realtimeEnabled, userId]) useEffect(() => { if (!realtimeEnabled) { setTypingByConversation({}) return undefined } const echo = getEcho() if (!echo || conversations.length === 0) { return undefined } const timers = new Map() const joinedChannels = [] const removeTypingUser = (conversationId, userIdToRemove) => { const timerKey = `${conversationId}:${userIdToRemove}` const existingTimer = timers.get(timerKey) if (existingTimer) { window.clearTimeout(existingTimer) timers.delete(timerKey) } setTypingByConversation((prev) => { const current = prev[conversationId] ?? [] const nextUsers = current.filter((user) => String(user.user_id ?? user.id) !== String(userIdToRemove)) if (nextUsers.length === current.length) { return prev } if (nextUsers.length === 0) { const next = { ...prev } delete next[conversationId] return next } return { ...prev, [conversationId]: nextUsers, } }) } conversations.forEach((conversation) => { if (!conversation?.id) { return } const conversationId = conversation.id const channel = echo.join(`conversation.${conversationId}`) joinedChannels.push(conversationId) channel .listen('.typing.started', (payload) => { const user = payload?.user if (!user?.id || user.id === userId) { return } setTypingByConversation((prev) => { const current = prev[conversationId] ?? [] const index = current.findIndex((entry) => String(entry.user_id ?? entry.id) === String(user.id)) const nextUser = { user_id: user.id, username: user.username } if (index === -1) { return { ...prev, [conversationId]: [...current, nextUser], } } const nextUsers = [...current] nextUsers[index] = { ...nextUsers[index], ...nextUser } return { ...prev, [conversationId]: nextUsers, } }) const timerKey = `${conversationId}:${user.id}` const existingTimer = timers.get(timerKey) if (existingTimer) { window.clearTimeout(existingTimer) } const timeout = window.setTimeout(() => removeTypingUser(conversationId, user.id), Number(payload?.expires_in_ms ?? 3500)) timers.set(timerKey, timeout) }) .listen('.typing.stopped', (payload) => { const typingUserId = payload?.user?.id if (!typingUserId) { return } removeTypingUser(conversationId, typingUserId) }) }) return () => { timers.forEach((timer) => window.clearTimeout(timer)) joinedChannels.forEach((conversationId) => { echo.leave(`conversation.${conversationId}`) }) } }, [conversations, realtimeEnabled, userId]) const handleSelectConversation = useCallback((id) => { setActiveId(id) history.replaceState(null, '', `/messages/${id}`) }, []) const handleConversationCreated = useCallback((conv) => { setShowNewModal(false) loadConversations() setActiveId(conv.id) history.replaceState(null, '', `/messages/${conv.id}`) }, [loadConversations]) const handleMarkRead = useCallback((conversationId) => { setConversations((prev) => prev.map((conversation) => ( conversation.id === conversationId ? { ...conversation, unread_count: 0 } : conversation ))) }, []) const handleConversationPatched = useCallback((patch) => { if (!patch?.id) { return } setConversations((prev) => mergeConversationSummary(prev, patch)) }, []) useEffect(() => { let cancelled = false const run = async () => { const q = searchQuery.trim() if (q.length < 2) { setSearchResults([]) setSearching(false) return } setSearching(true) try { const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}`) if (!cancelled) { setSearchResults(data.data ?? []) } } catch { if (!cancelled) { setSearchResults([]) } } finally { if (!cancelled) { setSearching(false) } } } const timer = setTimeout(run, 250) return () => { cancelled = true clearTimeout(timer) } }, [searchQuery]) const openSearchResult = useCallback((item) => { if (!item?.conversation_id) return setActiveId(item.conversation_id) history.replaceState(null, '', `/messages/${item.conversation_id}?focus=${item.id}`) }, []) const activeConversation = conversations.find((conversation) => conversation.id === activeId) ?? null const unreadCount = conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0) const pinnedCount = conversations.reduce((sum, conversation) => { const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId) return sum + (me?.is_pinned ? 1 : 0) }, 0) const archivedCount = conversations.reduce((sum, conversation) => { const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId) return sum + (me?.is_archived ? 1 : 0) }, 0) const activeSearch = searchQuery.trim().length >= 2 const activeConversationLabel = activeConversation?.title || activeConversation?.all_participants?.find((participant) => participant.user_id !== userId)?.user?.username || 'Conversation' return (
{activeId ? ( { setActiveId(null) history.replaceState(null, '', '/messages') }} onMarkRead={handleMarkRead} onConversationPatched={handleConversationPatched} /> ) : (

Choose a conversation

Jump back into a direct message, catch up on a group thread, or start a new conversation with creators and collaborators.

Search your full message history Share files inline Track seen status
)}
{activeId ? (
Viewing {activeConversationLabel}. Use the back button to return to your inbox list.
) : null} {showNewModal ? ( setShowNewModal(false)} /> ) : null}
) } function StatChip({ label, value, tone = 'sky' }) { const tones = { sky: 'border-sky-400/20 bg-sky-500/10 text-sky-200', amber: 'border-amber-400/20 bg-amber-500/10 text-amber-200', slate: 'border-white/[0.08] bg-white/[0.04] text-white/65', } return (

{label}

{Number(value || 0).toLocaleString()}

) } function mergeConversationSummary(existing, incoming) { const next = [...existing] const index = next.findIndex((conversation) => conversation.id === incoming.id) if (index >= 0) { next[index] = { ...next[index], ...incoming } } else { next.unshift(incoming) } return next.sort((left, right) => { const leftPinned = left.my_participant?.is_pinned ? 1 : 0 const rightPinned = right.my_participant?.is_pinned ? 1 : 0 if (leftPinned !== rightPinned) { return rightPinned - leftPinned } const leftPinnedAt = left.my_participant?.pinned_at ? new Date(left.my_participant.pinned_at).getTime() : 0 const rightPinnedAt = right.my_participant?.pinned_at ? new Date(right.my_participant.pinned_at).getTime() : 0 if (leftPinnedAt !== rightPinnedAt) { return rightPinnedAt - leftPinnedAt } const leftTime = left.last_message_at ? new Date(left.last_message_at).getTime() : 0 const rightTime = right.last_message_at ? new Date(right.last_message_at).getTime() : 0 return rightTime - leftTime }) } function connectionBadgeClass(realtimeEnabled, realtimeStatus) { if (!realtimeEnabled) { return 'border-white/[0.08] bg-white/[0.04] text-white/55' } if (realtimeStatus === 'connected') { return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200' } if (realtimeStatus === 'connecting') { return 'border-amber-400/20 bg-amber-500/10 text-amber-200' } return 'border-rose-400/18 bg-rose-500/10 text-rose-200' } function connectionDotClass(realtimeEnabled, realtimeStatus) { if (!realtimeEnabled) { return 'bg-white/30' } if (realtimeStatus === 'connected') { return 'bg-emerald-300' } if (realtimeStatus === 'connecting') { return 'bg-amber-300' } return 'bg-rose-300' } function connectionBadgeLabel(realtimeEnabled, realtimeStatus) { if (!realtimeEnabled) { return 'Polling every 15s' } if (realtimeStatus === 'connected') { return 'Realtime connected' } if (realtimeStatus === 'connecting') { return 'Realtime connecting' } return 'Realtime disconnected' } const el = document.getElementById('messages-root') if (el) { function parse(key, fallback = null) { try { return JSON.parse(el.dataset[key] ?? 'null') ?? fallback } catch { return fallback } } createRoot(el).render( , ) } export default MessagesPage