messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

@@ -0,0 +1,241 @@
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