feat: redesign private messaging inbox

This commit is contained in:
2026-03-17 18:34:00 +01:00
parent 2119741ba7
commit 7b37259a2c
5 changed files with 1278 additions and 985 deletions

View File

@@ -4,8 +4,6 @@ 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 ?? ''
}
@@ -26,14 +24,34 @@ async function apiFetch(url, options = {}) {
headers,
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.message ?? `HTTP ${res.status}`)
}
return res.json()
}
// ── MessagesPage ─────────────────────────────────────────────────────────────
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([])
@@ -46,7 +64,6 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
const [searching, setSearching] = useState(false)
const pollRef = useRef(null)
// ── Load conversations list ────────────────────────────────────────────────
const loadConversations = useCallback(async () => {
try {
const data = await apiFetch('/api/messages/conversations')
@@ -62,7 +79,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
loadConversations()
apiFetch('/api/messages/settings')
.then(data => setRealtimeEnabled(!!data?.realtime_enabled))
.then((data) => setRealtimeEnabled(!!data?.realtime_enabled))
.catch(() => setRealtimeEnabled(false))
return () => {
@@ -77,10 +94,10 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
}
if (realtimeEnabled) {
return
return undefined
}
pollRef.current = setInterval(loadConversations, 15_000)
pollRef.current = setInterval(loadConversations, 15000)
return () => {
if (pollRef.current) clearInterval(pollRef.current)
@@ -100,9 +117,11 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
}, [loadConversations])
const handleMarkRead = useCallback((conversationId) => {
setConversations(prev =>
prev.map(c => c.id === conversationId ? { ...c, unread_count: 0 } : c)
)
setConversations((prev) => prev.map((conversation) => (
conversation.id === conversationId
? { ...conversation, unread_count: 0 }
: conversation
)))
}, [])
useEffect(() => {
@@ -117,12 +136,13 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
}
setSearching(true)
try {
const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}`)
if (!cancelled) {
setSearchResults(data.data ?? [])
}
} catch (_) {
} catch {
if (!cancelled) {
setSearchResults([])
}
@@ -146,55 +166,107 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
history.replaceState(null, '', `/messages/${item.conversation_id}?focus=${item.id}`)
}, [])
const activeConversation = conversations.find(c => c.id === activeId) ?? null
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 (
<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 className="px-4 pb-16 pt-4 md:px-6 lg:px-8 lg:pt-6">
<div className="grid gap-5 lg:items-start lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[360px_minmax(0,1fr)] xl:gap-6">
<aside className={`flex min-h-[calc(100vh-18rem)] flex-col overflow-hidden rounded-[30px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.96),rgba(7,11,18,0.92))] shadow-[0_20px_60px_rgba(0,0,0,0.28)] lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] ${activeId ? 'hidden lg:flex' : 'flex'}`}>
<div className="border-b border-white/[0.06] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/35">Private inbox</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Messages</h2>
<p className="mt-2 text-sm leading-6 text-white/50">Keep direct chats, group threads, and file drops in one focused workspace.</p>
</div>
<button
onClick={() => setShowNewModal(true)}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/12 px-4 text-sm font-medium text-sky-200 transition hover:bg-sky-500/18"
title="New message"
>
<i className="fa-solid fa-pen-to-square text-xs" />
Compose
</button>
</div>
)}
<div className="mt-5 grid grid-cols-3 gap-2">
<StatChip label="Unread" value={unreadCount} tone="sky" />
<StatChip label="Pinned" value={pinnedCount} tone="amber" />
<StatChip label="Archived" value={archivedCount} tone="slate" />
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-white/45">
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 ${realtimeEnabled ? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200' : 'border-white/[0.08] bg-white/[0.04] text-white/55'}`}>
<span className={`h-1.5 w-1.5 rounded-full ${realtimeEnabled ? 'bg-emerald-300' : 'bg-white/30'}`} />
{realtimeEnabled ? 'Realtime active' : 'Polling every 15s'}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/55">
<i className="fa-solid fa-comments text-[10px]" />
{conversations.length} conversations
</span>
</div>
</div>
<div className="border-b border-white/[0.06] p-4">
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Search all messages</label>
<div className="rounded-2xl border border-white/[0.08] bg-black/15 px-3 py-2.5 transition focus-within:border-sky-400/30 focus-within:bg-black/25">
<div className="flex items-center gap-3">
<i className="fa-solid fa-magnifying-glass text-xs text-white/30" />
<input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Find text, attachments, or senders…"
className="w-full bg-transparent text-sm text-white outline-none placeholder:text-white/25"
/>
</div>
</div>
{searching ? <p className="mt-2 text-[11px] text-white/35">Searching across your inbox</p> : null}
</div>
{activeSearch ? (
<div className="border-b border-white/[0.06] px-3 py-3">
<div className="mb-2 flex items-center justify-between px-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Search results</p>
<span className="text-[11px] text-white/30">{searchResults.length}</span>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto pr-1">
{searchResults.length === 0 && !searching ? (
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-4 text-sm text-white/40">
No results matched {searchQuery.trim()}.
</div>
) : null}
{searchResults.map((item) => (
<button
key={`search-${item.id}`}
onClick={() => openSearchResult(item)}
className="block w-full rounded-2xl border border-white/[0.06] bg-white/[0.03] px-4 py-3 text-left transition hover:border-sky-400/20 hover:bg-sky-500/[0.08]"
>
<div className="flex items-center justify-between gap-3 text-[11px] text-white/35">
<span>@{item.sender?.username ?? 'unknown'}</span>
<span>{relativeTime(item.created_at)}</span>
</div>
<p className="mt-2 line-clamp-2 text-sm leading-6 text-white/78">{buildSearchPreview(item)}</p>
</button>
))}
</div>
</div>
) : null}
<ConversationList
conversations={conversations}
@@ -205,8 +277,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
/>
</aside>
{/* ── Right panel: thread ───────────────────────────────────────── */}
<main className={`flex-1 flex flex-col min-w-0 ${activeId ? 'flex' : 'hidden sm:flex'}`}>
<main className={`flex min-h-[calc(100vh-18rem)] min-w-0 flex-col overflow-hidden rounded-[30px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.96),rgba(7,11,18,0.92))] shadow-[0_20px_60px_rgba(0,0,0,0.28)] lg:h-[calc(100vh-3rem)] lg:max-h-[calc(100vh-3rem)] ${activeId ? 'flex' : 'hidden lg:flex'}`}>
{activeId ? (
<ConversationThread
key={activeId}
@@ -216,48 +287,82 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
currentUserId={userId}
currentUsername={username}
apiFetch={apiFetch}
onBack={() => { setActiveId(null); history.replaceState(null, '', '/messages') }}
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 className="flex flex-1 items-center justify-center p-8">
<div className="max-w-xl text-center">
<div className="mx-auto flex h-18 w-18 items-center justify-center rounded-[26px] border border-white/[0.08] bg-white/[0.03] text-white/35 shadow-[0_18px_45px_rgba(0,0,0,0.22)]">
<i className="fa-solid fa-comments text-3xl" />
</div>
<h2 className="mt-6 text-3xl font-semibold text-white">Choose a conversation</h2>
<p className="mt-3 text-sm leading-7 text-white/55">Jump back into a direct message, catch up on a group thread, or start a new conversation with creators and collaborators.</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2 text-sm text-white/55">
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2">Search your full message history</span>
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2">Share files inline</span>
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2">Track seen status</span>
</div>
</div>
</div>
)}
</main>
</div>
{showNewModal && (
{activeId ? (
<div className="mt-4 rounded-2xl border border-white/[0.06] bg-white/[0.03] px-4 py-3 text-xs text-white/45 lg:hidden">
Viewing <span className="font-medium text-white/75">{activeConversationLabel}</span>. Use the back button to return to your inbox list.
</div>
) : null}
{showNewModal ? (
<NewConversationModal
currentUserId={userId}
apiFetch={apiFetch}
onCreated={handleConversationCreated}
onClose={() => setShowNewModal(false)}
/>
)}
) : null}
</div>
)
}
// ── Mount ────────────────────────────────────────────────────────────────────
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 (
<div className={`rounded-2xl border px-3 py-3 ${tones[tone] || tones.sky}`}>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">{label}</p>
<p className="mt-2 text-lg font-semibold">{Number(value || 0).toLocaleString()}</p>
</div>
)
}
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 }
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')}
/>
/>,
)
}