242 lines
9.6 KiB
JavaScript
242 lines
9.6 KiB
JavaScript
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'
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
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()
|
|
}
|
|
|
|
// ── MessagesPage ─────────────────────────────────────────────────────────────
|
|
|
|
function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
|
const [conversations, setConversations] = useState([])
|
|
const [loadingConvs, setLoadingConvs] = useState(true)
|
|
const [activeId, setActiveId] = useState(initialId ?? null)
|
|
const [showNewModal, setShowNewModal] = useState(false)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [searchResults, setSearchResults] = useState([])
|
|
const [searching, setSearching] = useState(false)
|
|
const pollRef = useRef(null)
|
|
|
|
// ── Load conversations list ────────────────────────────────────────────────
|
|
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()
|
|
|
|
// Phase 1 polling: refresh conversation list every 15 seconds
|
|
pollRef.current = setInterval(loadConversations, 15_000)
|
|
return () => clearInterval(pollRef.current)
|
|
}, [loadConversations])
|
|
|
|
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(c => c.id === conversationId ? { ...c, unread_count: 0 } : c)
|
|
)
|
|
}, [])
|
|
|
|
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(c => c.id === activeId) ?? null
|
|
|
|
return (
|
|
<div className="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 py-6">
|
|
<div className="flex h-[calc(100vh-10rem)] overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm">
|
|
|
|
{/* ── Left panel: conversation list ─────────────────────────────── */}
|
|
<aside className={`w-full sm:w-80 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex flex-col ${activeId ? 'hidden sm:flex' : 'flex'}`}>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
|
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Messages</h1>
|
|
<button
|
|
onClick={() => setShowNewModal(true)}
|
|
className="rounded-full p-1.5 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
title="New message"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
|
|
<input
|
|
type="search"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
placeholder="Search all messages…"
|
|
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"
|
|
/>
|
|
{searching && <p className="mt-1 text-[11px] text-gray-400">Searching…</p>}
|
|
</div>
|
|
|
|
{searchQuery.trim().length >= 2 && (
|
|
<div className="border-b border-gray-100 dark:border-gray-800 max-h-44 overflow-y-auto">
|
|
{searchResults.length === 0 && !searching && (
|
|
<p className="px-3 py-2 text-xs text-gray-400">No results.</p>
|
|
)}
|
|
{searchResults.map(item => (
|
|
<button
|
|
key={`search-${item.id}`}
|
|
onClick={() => openSearchResult(item)}
|
|
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/60 border-b border-gray-100 dark:border-gray-800"
|
|
>
|
|
<p className="text-xs text-gray-500">@{item.sender?.username ?? 'unknown'} · {new Date(item.created_at).toLocaleString()}</p>
|
|
<p className="text-sm text-gray-800 dark:text-gray-200 truncate">{item.body || '(attachment)'}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<ConversationList
|
|
conversations={conversations}
|
|
loading={loadingConvs}
|
|
activeId={activeId}
|
|
currentUserId={userId}
|
|
onSelect={handleSelectConversation}
|
|
/>
|
|
</aside>
|
|
|
|
{/* ── Right panel: thread ───────────────────────────────────────── */}
|
|
<main className={`flex-1 flex flex-col min-w-0 ${activeId ? 'flex' : 'hidden sm:flex'}`}>
|
|
{activeId ? (
|
|
<ConversationThread
|
|
key={activeId}
|
|
conversationId={activeId}
|
|
conversation={activeConversation}
|
|
currentUserId={userId}
|
|
currentUsername={username}
|
|
apiFetch={apiFetch}
|
|
onBack={() => { setActiveId(null); history.replaceState(null, '', '/messages') }}
|
|
onMarkRead={handleMarkRead}
|
|
onConversationUpdated={loadConversations}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-1 items-center justify-center text-gray-400 dark:text-gray-600">
|
|
<div className="text-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="mx-auto h-12 w-12 mb-3 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
</svg>
|
|
<p className="text-sm">Select a conversation or start a new one</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
{showNewModal && (
|
|
<NewConversationModal
|
|
currentUserId={userId}
|
|
apiFetch={apiFetch}
|
|
onCreated={handleConversationCreated}
|
|
onClose={() => setShowNewModal(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Mount ────────────────────────────────────────────────────────────────────
|
|
|
|
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(
|
|
<MessagesPage
|
|
userId={parse('userId')}
|
|
username={parse('username', '')}
|
|
activeConversationId={parse('activeConversationId')}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export default MessagesPage
|