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

@@ -1,51 +1,34 @@
import React, { useMemo, useState } from 'react'
import React from 'react'
/**
* Left panel: searchable, paginated list of conversations.
*/
export default function ConversationList({ conversations, loading, activeId, currentUserId, onSelect }) {
const [search, setSearch] = useState('')
const filtered = useMemo(() => {
const q = search.toLowerCase().trim()
if (!q) return conversations
return conversations.filter(conv => {
const label = convLabel(conv, currentUserId).toLowerCase()
const last = (conv.latest_message?.[0]?.body ?? '').toLowerCase()
return label.includes(q) || last.includes(q)
})
}, [conversations, search, currentUserId])
return (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Search */}
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
<input
type="search"
placeholder="Search conversations…"
value={search}
onChange={e => setSearch(e.target.value)}
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"
/>
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Conversations</p>
<p className="mt-1 text-xs text-white/35">Recent threads and group rooms</p>
</div>
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/45">
{conversations.length}
</span>
</div>
{/* List */}
<ul className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
{loading && (
<li className="px-4 py-8 text-center text-sm text-gray-400">Loading</li>
)}
{!loading && filtered.length === 0 && (
<li className="px-4 py-8 text-center text-sm text-gray-400">
{search ? 'No matches found.' : 'No conversations yet.'}
</li>
)}
{filtered.map(conv => (
<ul className="flex-1 space-y-2 overflow-y-auto p-3">
{loading ? (
<li className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-8 text-center text-sm text-white/40">Loading conversations</li>
) : null}
{!loading && conversations.length === 0 ? (
<li className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-8 text-center text-sm text-white/40">No conversations yet.</li>
) : null}
{conversations.map((conversation) => (
<ConversationRow
key={conv.id}
conv={conv}
isActive={conv.id === activeId}
key={conversation.id}
conv={conversation}
isActive={conversation.id === activeId}
currentUserId={currentUserId}
onClick={() => onSelect(conv.id)}
onClick={() => onSelect(conversation.id)}
/>
))}
</ul>
@@ -54,46 +37,61 @@ export default function ConversationList({ conversations, loading, activeId, cur
}
function ConversationRow({ conv, isActive, currentUserId, onClick }) {
const label = convLabel(conv, currentUserId)
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
const preview = lastMsg ? truncate(lastMsg.body, 60) : 'No messages yet'
const unread = conv.unread_count ?? 0
const myParticipant = conv.all_participants?.find(p => p.user_id === currentUserId)
const isArchived = myParticipant?.is_archived ?? false
const isPinned = myParticipant?.is_pinned ?? false
const label = convLabel(conv, currentUserId)
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
const preview = lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet'
const unread = conv.unread_count ?? 0
const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId)
const isArchived = myParticipant?.is_archived ?? false
const isPinned = myParticipant?.is_pinned ?? false
const activeMembers = conv.all_participants?.filter((participant) => !participant.left_at).length ?? 0
const typeLabel = conv.type === 'group' ? `${activeMembers} members` : 'Direct message'
const senderLabel = lastMsg?.sender?.username ? `@${lastMsg.sender.username}` : null
const initials = label
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('') || 'M'
return (
<li>
<button
onClick={onClick}
className={`w-full text-left px-4 py-3 flex gap-3 hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors ${isActive ? 'bg-blue-50 dark:bg-blue-900/30' : ''} ${isArchived ? 'opacity-60' : ''}`}
className={`w-full rounded-[24px] border px-4 py-4 text-left transition ${isActive ? 'border-sky-400/28 bg-sky-500/[0.12] shadow-[0_0_0_1px_rgba(56,189,248,0.08)]' : 'border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05]'} ${isArchived ? 'opacity-65' : ''}`}
>
{/* Avatar placeholder */}
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium">
{label.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<div className="flex items-center gap-1 min-w-0">
{isPinned && <span className="text-xs" title="Pinned">📌</span>}
<span className={`text-sm font-medium truncate ${isActive ? 'text-blue-700 dark:text-blue-300' : 'text-gray-900 dark:text-gray-100'}`}>
{label}
</span>
</div>
{conv.last_message_at && (
<span className="text-xs text-gray-400 flex-shrink-0">
{relativeTime(conv.last_message_at)}
</span>
)}
<div className="flex gap-3">
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[0_10px_25px_rgba(0,0,0,0.18)] ${conv.type === 'group' ? 'bg-gradient-to-br from-fuchsia-500 to-violet-600' : 'bg-gradient-to-br from-sky-500 to-cyan-500'}`}>
{initials}
</div>
<div className="flex items-center justify-between gap-1 mt-0.5">
<span className="text-xs text-gray-400 truncate">{preview}</span>
{unread > 0 && (
<span className="flex-shrink-0 min-w-[1.25rem] h-5 rounded-full bg-blue-500 text-white text-xs font-medium flex items-center justify-center px-1">
{unread > 99 ? '99+' : unread}
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`truncate text-sm font-semibold ${isActive ? 'text-sky-100' : 'text-white/90'}`}>
{label}
</span>
{isPinned ? <span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-200">Pinned</span> : null}
{isArchived ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/45">Archived</span> : null}
</div>
<p className="mt-1 text-[11px] uppercase tracking-[0.18em] text-white/30">{typeLabel}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
{conv.last_message_at ? <span className="text-[11px] text-white/30">{relativeTime(conv.last_message_at)}</span> : null}
{unread > 0 ? (
<span className="inline-flex min-w-[1.55rem] items-center justify-center rounded-full border border-sky-400/20 bg-sky-500/14 px-1.5 py-0.5 text-[11px] font-semibold text-sky-100">
{unread > 99 ? '99+' : unread}
</span>
) : null}
</div>
</div>
<div className="mt-3 rounded-2xl border border-white/[0.04] bg-black/10 px-3 py-2.5">
{senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null}
<p className="mt-1 truncate text-sm text-white/62">{preview}</p>
</div>
</div>
</div>
</button>
@@ -101,22 +99,21 @@ function ConversationRow({ conv, isActive, currentUserId, onClick }) {
)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function convLabel(conv, currentUserId) {
if (conv.type === 'group') return conv.title ?? 'Group'
const other = conv.all_participants?.find(p => p.user_id !== currentUserId)
const other = conv.all_participants?.find((participant) => participant.user_id !== currentUserId)
return other?.user?.username ?? 'Direct message'
}
function truncate(str, max) {
if (!str) return ''
return str.length > max ? str.slice(0, max) + '…' : str
return str.length > max ? `${str.slice(0, max)}` : str
}
function relativeTime(iso) {
if (!iso) return 'No activity'
const diff = (Date.now() - new Date(iso).getTime()) / 1000
if (diff < 60) return 'now'
if (diff < 60) return 'Now'
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
return `${Math.floor(diff / 86400)}d`