import React, { useState, useEffect, useCallback, useRef } from 'react' import { createRoot } from 'react-dom/client' 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 headers = { 'X-CSRF-TOKEN': getCsrf(), Accept: 'application/json', ...options.headers, } 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 [showNewModal, setShowNewModal] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [searching, setSearching] = useState(false) const pollRef = useRef(null) 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)) return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [loadConversations]) useEffect(() => { if (pollRef.current) { clearInterval(pollRef.current) pollRef.current = null } if (realtimeEnabled) { return undefined } pollRef.current = setInterval(loadConversations, 15000) return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [loadConversations, realtimeEnabled]) 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 ))) }, []) 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} onConversationUpdated={loadConversations} /> ) : (

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()}

) } 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