feat: redesign private messaging inbox
This commit is contained in:
@@ -4,8 +4,6 @@ import ConversationList from '../../components/messaging/ConversationList'
|
|||||||
import ConversationThread from '../../components/messaging/ConversationThread'
|
import ConversationThread from '../../components/messaging/ConversationThread'
|
||||||
import NewConversationModal from '../../components/messaging/NewConversationModal'
|
import NewConversationModal from '../../components/messaging/NewConversationModal'
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getCsrf() {
|
function getCsrf() {
|
||||||
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
|
||||||
}
|
}
|
||||||
@@ -26,14 +24,34 @@ async function apiFetch(url, options = {}) {
|
|||||||
headers,
|
headers,
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}))
|
const err = await res.json().catch(() => ({}))
|
||||||
throw new Error(err.message ?? `HTTP ${res.status}`)
|
throw new Error(err.message ?? `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json()
|
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 }) {
|
function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||||
const [conversations, setConversations] = useState([])
|
const [conversations, setConversations] = useState([])
|
||||||
@@ -46,7 +64,6 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
|||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
const pollRef = useRef(null)
|
const pollRef = useRef(null)
|
||||||
|
|
||||||
// ── Load conversations list ────────────────────────────────────────────────
|
|
||||||
const loadConversations = useCallback(async () => {
|
const loadConversations = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch('/api/messages/conversations')
|
const data = await apiFetch('/api/messages/conversations')
|
||||||
@@ -62,7 +79,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
|||||||
loadConversations()
|
loadConversations()
|
||||||
|
|
||||||
apiFetch('/api/messages/settings')
|
apiFetch('/api/messages/settings')
|
||||||
.then(data => setRealtimeEnabled(!!data?.realtime_enabled))
|
.then((data) => setRealtimeEnabled(!!data?.realtime_enabled))
|
||||||
.catch(() => setRealtimeEnabled(false))
|
.catch(() => setRealtimeEnabled(false))
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -77,10 +94,10 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (realtimeEnabled) {
|
if (realtimeEnabled) {
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
pollRef.current = setInterval(loadConversations, 15_000)
|
pollRef.current = setInterval(loadConversations, 15000)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (pollRef.current) clearInterval(pollRef.current)
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
@@ -100,9 +117,11 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
|||||||
}, [loadConversations])
|
}, [loadConversations])
|
||||||
|
|
||||||
const handleMarkRead = useCallback((conversationId) => {
|
const handleMarkRead = useCallback((conversationId) => {
|
||||||
setConversations(prev =>
|
setConversations((prev) => prev.map((conversation) => (
|
||||||
prev.map(c => c.id === conversationId ? { ...c, unread_count: 0 } : c)
|
conversation.id === conversationId
|
||||||
)
|
? { ...conversation, unread_count: 0 }
|
||||||
|
: conversation
|
||||||
|
)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,12 +136,13 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSearching(true)
|
setSearching(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}`)
|
const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}`)
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSearchResults(data.data ?? [])
|
setSearchResults(data.data ?? [])
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
}
|
}
|
||||||
@@ -146,55 +166,107 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
|||||||
history.replaceState(null, '', `/messages/${item.conversation_id}?focus=${item.id}`)
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 py-6">
|
<div className="px-4 pb-16 pt-4 md:px-6 lg:px-8 lg:pt-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">
|
<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'}`}>
|
||||||
{/* ── Left panel: conversation list ─────────────────────────────── */}
|
<div className="border-b border-white/[0.06] p-5">
|
||||||
<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-start justify-between gap-4">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Messages</h1>
|
<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
|
<button
|
||||||
onClick={() => setShowNewModal(true)}
|
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"
|
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"
|
title="New message"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<i className="fa-solid fa-pen-to-square text-xs" />
|
||||||
<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" />
|
Compose
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
|
<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
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search all messages…"
|
placeholder="Find text, attachments, or senders…"
|
||||||
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"
|
className="w-full bg-transparent text-sm text-white outline-none placeholder:text-white/25"
|
||||||
/>
|
/>
|
||||||
{searching && <p className="mt-1 text-[11px] text-gray-400">Searching…</p>}
|
</div>
|
||||||
|
</div>
|
||||||
|
{searching ? <p className="mt-2 text-[11px] text-white/35">Searching across your inbox…</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{searchQuery.trim().length >= 2 && (
|
{activeSearch ? (
|
||||||
<div className="border-b border-gray-100 dark:border-gray-800 max-h-44 overflow-y-auto">
|
<div className="border-b border-white/[0.06] px-3 py-3">
|
||||||
{searchResults.length === 0 && !searching && (
|
<div className="mb-2 flex items-center justify-between px-2">
|
||||||
<p className="px-3 py-2 text-xs text-gray-400">No results.</p>
|
<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>
|
||||||
{searchResults.map(item => (
|
</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
|
<button
|
||||||
key={`search-${item.id}`}
|
key={`search-${item.id}`}
|
||||||
onClick={() => openSearchResult(item)}
|
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"
|
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]"
|
||||||
>
|
>
|
||||||
<p className="text-xs text-gray-500">@{item.sender?.username ?? 'unknown'} · {new Date(item.created_at).toLocaleString()}</p>
|
<div className="flex items-center justify-between gap-3 text-[11px] text-white/35">
|
||||||
<p className="text-sm text-gray-800 dark:text-gray-200 truncate">{item.body || '(attachment)'}</p>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ConversationList
|
<ConversationList
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
@@ -205,8 +277,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
|||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ── Right panel: thread ───────────────────────────────────────── */}
|
<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'}`}>
|
||||||
<main className={`flex-1 flex flex-col min-w-0 ${activeId ? 'flex' : 'hidden sm:flex'}`}>
|
|
||||||
{activeId ? (
|
{activeId ? (
|
||||||
<ConversationThread
|
<ConversationThread
|
||||||
key={activeId}
|
key={activeId}
|
||||||
@@ -216,48 +287,82 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
|||||||
currentUserId={userId}
|
currentUserId={userId}
|
||||||
currentUsername={username}
|
currentUsername={username}
|
||||||
apiFetch={apiFetch}
|
apiFetch={apiFetch}
|
||||||
onBack={() => { setActiveId(null); history.replaceState(null, '', '/messages') }}
|
onBack={() => {
|
||||||
|
setActiveId(null)
|
||||||
|
history.replaceState(null, '', '/messages')
|
||||||
|
}}
|
||||||
onMarkRead={handleMarkRead}
|
onMarkRead={handleMarkRead}
|
||||||
onConversationUpdated={loadConversations}
|
onConversationUpdated={loadConversations}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 items-center justify-center text-gray-400 dark:text-gray-600">
|
<div className="flex flex-1 items-center justify-center p-8">
|
||||||
<div className="text-center">
|
<div className="max-w-xl 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">
|
<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)]">
|
||||||
<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" />
|
<i className="fa-solid fa-comments text-3xl" />
|
||||||
</svg>
|
</div>
|
||||||
<p className="text-sm">Select a conversation or start a new one</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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
|
<NewConversationModal
|
||||||
currentUserId={userId}
|
currentUserId={userId}
|
||||||
apiFetch={apiFetch}
|
apiFetch={apiFetch}
|
||||||
onCreated={handleConversationCreated}
|
onCreated={handleConversationCreated}
|
||||||
onClose={() => setShowNewModal(false)}
|
onClose={() => setShowNewModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</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')
|
const el = document.getElementById('messages-root')
|
||||||
|
|
||||||
if (el) {
|
if (el) {
|
||||||
function parse(key, fallback = null) {
|
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(
|
createRoot(el).render(
|
||||||
<MessagesPage
|
<MessagesPage
|
||||||
userId={parse('userId')}
|
userId={parse('userId')}
|
||||||
username={parse('username', '')}
|
username={parse('username', '')}
|
||||||
activeConversationId={parse('activeConversationId')}
|
activeConversationId={parse('activeConversationId')}
|
||||||
/>
|
/>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) {
|
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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Search */}
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
|
||||||
<div className="px-3 py-2 border-b border-gray-100 dark:border-gray-800">
|
<div>
|
||||||
<input
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Conversations</p>
|
||||||
type="search"
|
<p className="mt-1 text-xs text-white/35">Recent threads and group rooms</p>
|
||||||
placeholder="Search conversations…"
|
</div>
|
||||||
value={search}
|
<span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/45">
|
||||||
onChange={e => setSearch(e.target.value)}
|
{conversations.length}
|
||||||
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"
|
</span>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
<ul className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||||
<ul className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
{loading ? (
|
||||||
{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>
|
||||||
<li className="px-4 py-8 text-center text-sm text-gray-400">Loading…</li>
|
) : null}
|
||||||
)}
|
|
||||||
{!loading && filtered.length === 0 && (
|
{!loading && conversations.length === 0 ? (
|
||||||
<li className="px-4 py-8 text-center text-sm text-gray-400">
|
<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>
|
||||||
{search ? 'No matches found.' : 'No conversations yet.'}
|
) : null}
|
||||||
</li>
|
|
||||||
)}
|
{conversations.map((conversation) => (
|
||||||
{filtered.map(conv => (
|
|
||||||
<ConversationRow
|
<ConversationRow
|
||||||
key={conv.id}
|
key={conversation.id}
|
||||||
conv={conv}
|
conv={conversation}
|
||||||
isActive={conv.id === activeId}
|
isActive={conversation.id === activeId}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onClick={() => onSelect(conv.id)}
|
onClick={() => onSelect(conversation.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -56,44 +39,59 @@ export default function ConversationList({ conversations, loading, activeId, cur
|
|||||||
function ConversationRow({ conv, isActive, currentUserId, onClick }) {
|
function ConversationRow({ conv, isActive, currentUserId, onClick }) {
|
||||||
const label = convLabel(conv, currentUserId)
|
const label = convLabel(conv, currentUserId)
|
||||||
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
|
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 preview = lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet'
|
||||||
const unread = conv.unread_count ?? 0
|
const unread = conv.unread_count ?? 0
|
||||||
const myParticipant = conv.all_participants?.find(p => p.user_id === currentUserId)
|
const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId)
|
||||||
const isArchived = myParticipant?.is_archived ?? false
|
const isArchived = myParticipant?.is_archived ?? false
|
||||||
const isPinned = myParticipant?.is_pinned ?? 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 (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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 gap-3">
|
||||||
<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">
|
<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'}`}>
|
||||||
{label.charAt(0).toUpperCase()}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="min-w-0">
|
||||||
{isPinned && <span className="text-xs" title="Pinned">📌</span>}
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-sm font-medium truncate ${isActive ? 'text-blue-700 dark:text-blue-300' : 'text-gray-900 dark:text-gray-100'}`}>
|
<span className={`truncate text-sm font-semibold ${isActive ? 'text-sky-100' : 'text-white/90'}`}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{conv.last_message_at && (
|
<p className="mt-1 text-[11px] uppercase tracking-[0.18em] text-white/30">{typeLabel}</p>
|
||||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
|
||||||
{relativeTime(conv.last_message_at)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-1 mt-0.5">
|
|
||||||
<span className="text-xs text-gray-400 truncate">{preview}</span>
|
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||||
{unread > 0 && (
|
{conv.last_message_at ? <span className="text-[11px] text-white/30">{relativeTime(conv.last_message_at)}</span> : null}
|
||||||
<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 > 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}
|
{unread > 99 ? '99+' : unread}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -101,22 +99,21 @@ function ConversationRow({ conv, isActive, currentUserId, onClick }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function convLabel(conv, currentUserId) {
|
function convLabel(conv, currentUserId) {
|
||||||
if (conv.type === 'group') return conv.title ?? 'Group'
|
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'
|
return other?.user?.username ?? 'Direct message'
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncate(str, max) {
|
function truncate(str, max) {
|
||||||
if (!str) return ''
|
if (!str) return ''
|
||||||
return str.length > max ? str.slice(0, max) + '…' : str
|
return str.length > max ? `${str.slice(0, max)}…` : str
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeTime(iso) {
|
function relativeTime(iso) {
|
||||||
|
if (!iso) return 'No activity'
|
||||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000
|
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 < 3600) return `${Math.floor(diff / 60)}m`
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||||
return `${Math.floor(diff / 86400)}d`
|
return `${Math.floor(diff / 86400)}d`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,32 +3,38 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
|
|
||||||
const QUICK_REACTIONS = ['👍', '❤️', '🔥', '😂', '👏', '😮']
|
const QUICK_REACTIONS = ['👍', '❤️', '🔥', '😂', '👏', '😮']
|
||||||
|
|
||||||
/**
|
export default function MessageBubble({ message, isMine, showAvatar, endsSequence = true, isNewlyArrived = false, prefersReducedMotion = false, onReact, onUnreact, onEdit, onDelete = null, onReport = null, onOpenImage = null, seenText = null }) {
|
||||||
* Individual message bubble with:
|
|
||||||
* - Markdown rendering (no raw HTML allowed)
|
|
||||||
* - Hover reaction picker + unreact on click
|
|
||||||
* - Inline edit for own messages
|
|
||||||
* - Soft-delete display
|
|
||||||
*/
|
|
||||||
export default function MessageBubble({ message, isMine, showAvatar, onReact, onUnreact, onEdit, onReport = null, onOpenImage = null, seenText = null }) {
|
|
||||||
const [showPicker, setShowPicker] = useState(false)
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
|
const [showActions, setShowActions] = useState(false)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editBody, setEditBody] = useState(message.body ?? '')
|
const [editBody, setEditBody] = useState(message.body ?? '')
|
||||||
const [savingEdit, setSavingEdit] = useState(false)
|
const [savingEdit, setSavingEdit] = useState(false)
|
||||||
|
const [isArrivalVisible, setIsArrivalVisible] = useState(true)
|
||||||
const editRef = useRef(null)
|
const editRef = useRef(null)
|
||||||
|
|
||||||
const isDeleted = !!message.deleted_at
|
const isDeleted = !!message.deleted_at
|
||||||
const isEdited = !!message.edited_at
|
const isEdited = !!message.edited_at
|
||||||
const username = message.sender?.username ?? 'Unknown'
|
const username = message.sender?.username ?? 'Unknown'
|
||||||
const time = formatTime(message.created_at)
|
const time = formatTime(message.created_at)
|
||||||
|
const initials = username.charAt(0).toUpperCase()
|
||||||
|
|
||||||
// Focus textarea when entering edit mode
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
editRef.current?.focus()
|
editRef.current?.focus()
|
||||||
editRef.current?.setSelectionRange(editBody.length, editBody.length)
|
editRef.current?.setSelectionRange(editBody.length, editBody.length)
|
||||||
}
|
}
|
||||||
}, [editing])
|
}, [editing, editBody.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefersReducedMotion || !isNewlyArrived) {
|
||||||
|
setIsArrivalVisible(true)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsArrivalVisible(false)
|
||||||
|
const frame = window.requestAnimationFrame(() => setIsArrivalVisible(true))
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [isNewlyArrived, prefersReducedMotion])
|
||||||
|
|
||||||
const reactionGroups = groupReactions(message.reactions ?? [])
|
const reactionGroups = groupReactions(message.reactions ?? [])
|
||||||
|
|
||||||
@@ -45,50 +51,77 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditKeyDown = (e) => {
|
const handleEditKeyDown = (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveEdit() }
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
if (e.key === 'Escape') { setEditing(false); setEditBody(message.body) }
|
e.preventDefault()
|
||||||
|
handleSaveEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditing(false)
|
||||||
|
setEditBody(message.body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group flex gap-2 items-end ${isMine ? 'flex-row-reverse' : 'flex-row'} ${showAvatar ? 'mt-3' : 'mt-0.5'}`}
|
className={`group flex items-end gap-2.5 sm:gap-3 ${isMine ? 'flex-row-reverse' : 'flex-row'} ${showAvatar ? 'mt-4' : 'mt-1'}`}
|
||||||
|
style={prefersReducedMotion ? undefined : {
|
||||||
|
opacity: isArrivalVisible ? 1 : 0.55,
|
||||||
|
transform: isArrivalVisible ? 'translateY(0px)' : 'translateY(10px)',
|
||||||
|
filter: isNewlyArrived && isArrivalVisible ? 'drop-shadow(0 0 20px rgba(56,189,248,0.12))' : 'none',
|
||||||
|
transition: 'opacity 260ms ease, transform 320ms ease, filter 900ms ease',
|
||||||
|
}}
|
||||||
onMouseEnter={() => !isDeleted && !editing && setShowPicker(true)}
|
onMouseEnter={() => !isDeleted && !editing && setShowPicker(true)}
|
||||||
onMouseLeave={() => setShowPicker(false)}
|
onMouseLeave={() => {
|
||||||
|
setShowPicker(false)
|
||||||
|
setShowActions(false)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
<div className={`h-9 w-9 shrink-0 ${showAvatar ? 'visible' : 'invisible'}`}>
|
||||||
<div className={`flex-shrink-0 w-7 h-7 ${showAvatar ? 'visible' : 'invisible'}`}>
|
<div className={`flex h-9 w-9 items-center justify-center rounded-2xl text-xs font-semibold text-white shadow-[0_10px_20px_rgba(0,0,0,0.18)] ${isMine ? 'bg-gradient-to-br from-sky-500 to-cyan-500' : 'bg-gradient-to-br from-violet-500 to-fuchsia-600'}`}>
|
||||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-400 to-pink-400 flex items-center justify-center text-white text-xs font-medium select-none">
|
{initials}
|
||||||
{username.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`max-w-[75%] flex flex-col ${isMine ? 'items-end' : 'items-start'}`}>
|
<div className={`flex max-w-[84%] flex-col sm:max-w-[75%] ${isMine ? 'items-end' : 'items-start'}`}>
|
||||||
{/* Sender name & time */}
|
{showAvatar ? (
|
||||||
{showAvatar && (
|
<div className={`mb-1.5 flex items-center gap-2 ${isMine ? 'flex-row-reverse' : ''}`}>
|
||||||
<div className={`flex items-center gap-1.5 mb-1 ${isMine ? 'flex-row-reverse' : ''}`}>
|
<span className="text-xs font-medium text-white/78">{username}</span>
|
||||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">{username}</span>
|
<span className="text-[11px] text-white/30">{time}</span>
|
||||||
<span className="text-xs text-gray-400">{time}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* Bubble */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{!editing && !isDeleted ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowActions((prev) => !prev)}
|
||||||
|
className={`absolute z-[1] inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/[0.08] bg-[#0b1220]/88 text-white/40 shadow-[0_10px_20px_rgba(0,0,0,0.18)] transition hover:bg-[#11192a] hover:text-white ${isMine ? '-left-3 top-2 md:opacity-0 md:group-hover:opacity-100' : '-right-3 top-2 md:opacity-0 md:group-hover:opacity-100'}`}
|
||||||
|
aria-label="Message actions"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-ellipsis text-[11px]" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="w-72">
|
<div className="w-72">
|
||||||
<textarea
|
<textarea
|
||||||
ref={editRef}
|
ref={editRef}
|
||||||
value={editBody}
|
value={editBody}
|
||||||
onChange={e => setEditBody(e.target.value)}
|
onChange={(e) => setEditBody(e.target.value)}
|
||||||
onKeyDown={handleEditKeyDown}
|
onKeyDown={handleEditKeyDown}
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
className="w-full resize-none rounded-xl border border-blue-400 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full resize-none rounded-2xl border border-sky-400/30 bg-black/20 px-3 py-2 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 mt-1 justify-end">
|
<div className="mt-2 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditing(false); setEditBody(message.body) }}
|
onClick={() => {
|
||||||
className="text-xs text-gray-400 hover:text-gray-600"
|
setEditing(false)
|
||||||
|
setEditBody(message.body)
|
||||||
|
}}
|
||||||
|
className="text-xs text-white/35 transition hover:text-white/65"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -96,7 +129,7 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSaveEdit}
|
onClick={handleSaveEdit}
|
||||||
disabled={savingEdit || !editBody.trim() || editBody.trim() === message.body}
|
disabled={savingEdit || !editBody.trim() || editBody.trim() === message.body}
|
||||||
className="text-xs text-blue-500 hover:text-blue-700 font-medium disabled:opacity-40"
|
className="text-xs font-medium text-sky-300 transition hover:text-sky-200 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{savingEdit ? 'Saving…' : 'Save'}
|
{savingEdit ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
@@ -104,137 +137,137 @@ export default function MessageBubble({ message, isMine, showAvatar, onReact, on
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`px-3 py-2 rounded-2xl text-sm leading-relaxed break-words ${
|
className={`px-4 py-3 text-sm leading-relaxed shadow-[0_14px_35px_rgba(0,0,0,0.16)] ${bubbleShape({ isMine, showAvatar, endsSequence })} ${isMine ? 'border border-sky-400/24 bg-sky-500/16 text-white' : 'border border-white/[0.06] bg-white/[0.04] text-white/88'} ${isDeleted ? 'italic opacity-60' : ''} ${message._optimistic ? 'opacity-70' : ''}`}
|
||||||
isMine
|
|
||||||
? 'bg-blue-500 text-white rounded-br-sm'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-sm'
|
|
||||||
} ${isDeleted ? 'italic opacity-60' : ''} ${message._optimistic ? 'opacity-70' : ''}`}
|
|
||||||
>
|
>
|
||||||
{isDeleted ? (
|
{isDeleted ? (
|
||||||
<span>This message was deleted.</span>
|
<span>This message was deleted.</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{message.attachments?.length > 0 && (
|
{message.attachments?.length > 0 ? (
|
||||||
<div className="mb-2 space-y-1">
|
<AttachmentCluster attachments={message.attachments} isMine={isMine} onOpenImage={onOpenImage} />
|
||||||
{message.attachments.map(att => (
|
) : null}
|
||||||
<div key={att.id}>
|
|
||||||
{att.type === 'image' ? (
|
<div className="prose prose-invert prose-sm max-w-none prose-headings:text-white prose-li:text-inherit prose-p:my-0 prose-pre:my-1 prose-strong:text-white text-white/90">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenImage?.({ id: att.id, original_name: att.original_name, url: `/messages/attachments/${att.id}` })}
|
|
||||||
className="block"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`/messages/attachments/${att.id}`}
|
|
||||||
alt={att.original_name}
|
|
||||||
className="max-h-44 rounded-lg border border-white/20"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={`/messages/attachments/${att.id}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ${isMine ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100'}`}
|
|
||||||
>
|
|
||||||
📎 {att.original_name}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={`prose prose-sm max-w-none prose-p:my-0 prose-pre:my-1 ${isMine ? 'prose-invert' : 'dark:prose-invert'}`}>
|
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
allowedElements={['p', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'a', 'br']}
|
allowedElements={['p', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'a', 'br']}
|
||||||
unwrapDisallowed
|
unwrapDisallowed
|
||||||
components={{
|
components={{
|
||||||
a: ({ href, children }) => (
|
a: ({ href, children }) => (
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow"
|
<a
|
||||||
className={isMine ? 'text-blue-200 underline' : 'text-blue-600 dark:text-blue-400 underline'}>
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
className={isMine ? 'text-sky-100 underline' : 'text-sky-300 underline'}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
code: ({ children, className }) => className
|
code: ({ children, className }) => className
|
||||||
? <code className={`${className} text-xs`}>{children}</code>
|
? <code className={`${className} text-xs`}>{children}</code>
|
||||||
: <code className={`px-1 py-0.5 rounded text-xs ${isMine ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}`}>{children}</code>,
|
: <code className={`rounded px-1 py-0.5 text-xs ${isMine ? 'bg-sky-500/20' : 'bg-white/[0.08]'}`}>{children}</code>,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{message.body}
|
{message.body}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
{isEdited && (
|
|
||||||
<span className={`text-xs ml-1 ${isMine ? 'text-blue-200' : 'text-gray-400'}`}>(edited)</span>
|
{isEdited ? (
|
||||||
)}
|
<span className={`ml-1 text-xs ${isMine ? 'text-sky-100/80' : 'text-white/40'}`}>(edited)</span>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hover action strip: reactions + edit pencil */}
|
{(showPicker || showActions) && !editing && !isDeleted ? (
|
||||||
{showPicker && !editing && !isDeleted && (
|
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-full mb-1 ${isMine ? 'right-0' : 'left-0'} flex items-center gap-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-full px-2 py-1 shadow-md z-10 whitespace-nowrap`}
|
className={`absolute bottom-full z-10 mb-2 flex items-center gap-1 whitespace-nowrap rounded-full border border-white/[0.08] bg-[#0b1220] px-2 py-1 shadow-[0_20px_40px_rgba(0,0,0,0.35)] ${isMine ? 'right-0' : 'left-0'}`}
|
||||||
onMouseEnter={() => setShowPicker(true)}
|
onMouseEnter={() => setShowPicker(true)}
|
||||||
onMouseLeave={() => setShowPicker(false)}
|
onMouseLeave={() => {
|
||||||
|
setShowPicker(false)
|
||||||
|
setShowActions(false)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{QUICK_REACTIONS.map(emoji => (
|
{QUICK_REACTIONS.map((emoji) => (
|
||||||
<button key={emoji} onClick={() => onReact(message.id, emoji)}
|
<button key={emoji} onClick={() => {
|
||||||
className="text-base hover:scale-125 transition-transform" title={`React ${emoji}`}>
|
onReact(message.id, emoji)
|
||||||
|
setShowActions(false)
|
||||||
|
}} className="inline-flex h-8 w-8 items-center justify-center rounded-full text-base transition-transform hover:scale-125" title={`React ${emoji}`}>
|
||||||
{emoji}
|
{emoji}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{isMine && (
|
{isMine ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditing(true); setShowPicker(false) }}
|
type="button"
|
||||||
className="ml-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
onClick={() => {
|
||||||
|
setEditing(true)
|
||||||
|
setShowPicker(false)
|
||||||
|
setShowActions(false)
|
||||||
|
}}
|
||||||
|
className="ml-1 inline-flex h-8 w-8 items-center justify-center rounded-full text-white/35 transition hover:bg-white/[0.06] hover:text-white/80"
|
||||||
title="Edit message"
|
title="Edit message"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
{onDelete ? (
|
||||||
{!isMine && onReport && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { onReport(message.id); setShowPicker(false) }}
|
type="button"
|
||||||
className="ml-1 text-gray-400 hover:text-red-500"
|
onClick={() => {
|
||||||
|
onDelete(message.id)
|
||||||
|
setShowPicker(false)
|
||||||
|
setShowActions(false)
|
||||||
|
}}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-white/35 transition hover:bg-rose-500/10 hover:text-rose-300"
|
||||||
|
title="Delete message"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-trash text-[11px]" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{!isMine && onReport ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onReport(message.id)
|
||||||
|
setShowPicker(false)
|
||||||
|
setShowActions(false)
|
||||||
|
}}
|
||||||
|
className="ml-1 inline-flex h-8 w-8 items-center justify-center rounded-full text-white/35 transition hover:bg-rose-500/10 hover:text-rose-300"
|
||||||
title="Report message"
|
title="Report message"
|
||||||
>
|
>
|
||||||
⚑
|
⚑
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reactions bar */}
|
{reactionGroups.length > 0 && !isDeleted ? (
|
||||||
{reactionGroups.length > 0 && !isDeleted && (
|
<div className={`mt-1 flex flex-wrap gap-1 ${isMine ? 'justify-end' : 'justify-start'} ${endsSequence ? '' : 'mb-0.5'}`}>
|
||||||
<div className={`flex flex-wrap gap-1 mt-1 ${isMine ? 'justify-end' : 'justify-start'}`}>
|
|
||||||
{reactionGroups.map(({ emoji, count, iReacted }) => (
|
{reactionGroups.map(({ emoji, count, iReacted }) => (
|
||||||
<button
|
<button
|
||||||
key={emoji}
|
key={emoji}
|
||||||
onClick={() => iReacted ? onUnreact(message.id, emoji) : onReact(message.id, emoji)}
|
onClick={() => (iReacted ? onUnreact(message.id, emoji) : onReact(message.id, emoji))}
|
||||||
title={iReacted ? 'Remove reaction' : `React ${emoji}`}
|
title={iReacted ? 'Remove reaction' : `React ${emoji}`}
|
||||||
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-xs transition-colors ${
|
className={`flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs transition-colors ${iReacted ? 'border border-sky-400/20 bg-sky-500/14 text-sky-100' : 'border border-white/[0.08] bg-white/[0.04] text-white/55 hover:bg-white/[0.08] hover:text-white'}`}
|
||||||
iReacted
|
|
||||||
? 'bg-blue-100 dark:bg-blue-900/40 border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span>{emoji}</span>
|
<span>{emoji}</span>
|
||||||
<span>{count}</span>
|
<span>{count}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{isMine && seenText && (
|
{isMine && seenText ? (
|
||||||
<div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
|
<div className="mt-1 text-[11px] text-white/30">
|
||||||
{seenText}
|
{seenText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -244,13 +277,102 @@ function formatTime(iso) {
|
|||||||
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bubbleShape({ isMine, showAvatar, endsSequence }) {
|
||||||
|
if (isMine) {
|
||||||
|
return `${showAvatar ? 'rounded-t-[24px]' : 'rounded-tl-[24px] rounded-tr-[14px]'} ${endsSequence ? 'rounded-bl-[24px] rounded-br-[8px]' : 'rounded-bl-[24px] rounded-br-[14px]'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${showAvatar ? 'rounded-t-[24px]' : 'rounded-tr-[24px] rounded-tl-[14px]'} ${endsSequence ? 'rounded-br-[24px] rounded-bl-[8px]' : 'rounded-br-[24px] rounded-bl-[14px]'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentCluster({ attachments, isMine, onOpenImage }) {
|
||||||
|
const images = attachments.filter((attachment) => attachment.type === 'image')
|
||||||
|
const files = attachments.filter((attachment) => attachment.type !== 'image')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 space-y-2.5">
|
||||||
|
{images.length > 0 ? <AttachmentImageGrid images={images} onOpenImage={onOpenImage} /> : null}
|
||||||
|
{files.length > 0 ? <AttachmentFileStack files={files} isMine={isMine} /> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentImageGrid({ images, onOpenImage }) {
|
||||||
|
const gridClass = images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid gap-2 ${gridClass}`}>
|
||||||
|
{images.slice(0, 4).map((attachment, index) => {
|
||||||
|
const remaining = images.length - 4
|
||||||
|
const showOverlay = remaining > 0 && index === 3
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={attachment.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenImage?.({
|
||||||
|
id: attachment.id,
|
||||||
|
original_name: attachment.original_name,
|
||||||
|
url: `/messages/attachments/${attachment.id}`,
|
||||||
|
})}
|
||||||
|
className={`group/image relative overflow-hidden rounded-[20px] border border-white/15 bg-black/20 ${images.length === 1 ? 'max-w-md' : ''}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/messages/attachments/${attachment.id}`}
|
||||||
|
alt={attachment.original_name}
|
||||||
|
className={`w-full object-cover transition duration-200 group-hover/image:scale-[1.02] ${images.length === 1 ? 'max-h-72' : 'h-32 sm:h-36'}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-3 py-2 text-left text-xs text-white/80">
|
||||||
|
<span className="line-clamp-1">{attachment.original_name}</span>
|
||||||
|
</div>
|
||||||
|
{showOverlay ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-lg font-semibold text-white">
|
||||||
|
+{remaining}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentFileStack({ files, isMine }) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-[20px] border px-3 py-3 ${isMine ? 'border-sky-400/18 bg-sky-500/10' : 'border-white/[0.08] bg-black/20'}`}>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Files</span>
|
||||||
|
<span className="text-[11px] text-white/35">{files.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{files.map((attachment) => (
|
||||||
|
<a
|
||||||
|
key={attachment.id}
|
||||||
|
href={`/messages/attachments/${attachment.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`flex items-center gap-3 rounded-2xl border px-3 py-2 text-left text-xs transition hover:translate-x-[1px] ${isMine ? 'border-sky-400/12 bg-sky-500/10 text-sky-50 hover:bg-sky-500/14' : 'border-white/[0.08] bg-white/[0.04] text-white/80 hover:bg-white/[0.06]'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl ${isMine ? 'bg-sky-500/16 text-sky-100' : 'bg-white/[0.08] text-white/70'}`}>
|
||||||
|
<i className="fa-solid fa-paperclip text-[11px]" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{attachment.original_name}</span>
|
||||||
|
<i className="fa-solid fa-arrow-up-right-from-square text-[10px] opacity-60" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function groupReactions(reactions) {
|
function groupReactions(reactions) {
|
||||||
const map = new Map()
|
const map = new Map()
|
||||||
for (const r of reactions) {
|
for (const reaction of reactions) {
|
||||||
if (!map.has(r.reaction)) map.set(r.reaction, { count: 0, iReacted: false })
|
if (!map.has(reaction.reaction)) map.set(reaction.reaction, { count: 0, iReacted: false })
|
||||||
const entry = map.get(r.reaction)
|
const entry = map.get(reaction.reaction)
|
||||||
entry.count++
|
entry.count += 1
|
||||||
if (r._iMine) entry.iReacted = true
|
if (reaction._iMine) entry.iReacted = true
|
||||||
}
|
}
|
||||||
return Array.from(map.entries()).map(([emoji, { count, iReacted }]) => ({ emoji, count, iReacted }))
|
return Array.from(map.entries()).map(([emoji, { count, iReacted }]) => ({ emoji, count, iReacted }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
|
|
||||||
/**
|
|
||||||
* Modal for creating a new direct or group conversation.
|
|
||||||
*/
|
|
||||||
export default function NewConversationModal({ currentUserId, apiFetch, onCreated, onClose }) {
|
export default function NewConversationModal({ currentUserId, apiFetch, onCreated, onClose }) {
|
||||||
const [type, setType] = useState('direct')
|
const [type, setType] = useState('direct')
|
||||||
const [recipientInput, setRecipient] = useState('')
|
const [recipientInput, setRecipient] = useState('')
|
||||||
@@ -12,11 +9,9 @@ export default function NewConversationModal({ currentUserId, apiFetch, onCreate
|
|||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const addParticipant = () => setParticipantInputs(p => [...p, ''])
|
const addParticipant = () => setParticipantInputs((prev) => [...prev, ''])
|
||||||
const updateParticipant = (i, val) =>
|
const updateParticipant = (index, value) => setParticipantInputs((prev) => prev.map((entry, currentIndex) => (currentIndex === index ? value : entry)))
|
||||||
setParticipantInputs(p => p.map((v, idx) => idx === i ? val : v))
|
const removeParticipant = (index) => setParticipantInputs((prev) => prev.filter((_, currentIndex) => currentIndex !== index))
|
||||||
const removeParticipant = (i) =>
|
|
||||||
setParticipantInputs(p => p.filter((_, idx) => idx !== i))
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e) => {
|
const handleSubmit = useCallback(async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -24,8 +19,7 @@ export default function NewConversationModal({ currentUserId, apiFetch, onCreate
|
|||||||
setSending(true)
|
setSending(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Resolve usernames to IDs via the search API
|
const payload = { type, body }
|
||||||
let payload = { type, body }
|
|
||||||
|
|
||||||
if (type === 'direct') {
|
if (type === 'direct') {
|
||||||
const user = await resolveUsername(recipientInput.trim(), apiFetch)
|
const user = await resolveUsername(recipientInput.trim(), apiFetch)
|
||||||
@@ -33,11 +27,11 @@ export default function NewConversationModal({ currentUserId, apiFetch, onCreate
|
|||||||
} else {
|
} else {
|
||||||
const resolved = await Promise.all(
|
const resolved = await Promise.all(
|
||||||
participantInputs
|
participantInputs
|
||||||
.map(p => p.trim())
|
.map((entry) => entry.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map(u => resolveUsername(u, apiFetch))
|
.map((entry) => resolveUsername(entry, apiFetch)),
|
||||||
)
|
)
|
||||||
payload.participant_ids = resolved.map(u => u.id)
|
payload.participant_ids = resolved.map((user) => user.id)
|
||||||
payload.title = groupTitle.trim()
|
payload.title = groupTitle.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,114 +46,126 @@ export default function NewConversationModal({ currentUserId, apiFetch, onCreate
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
}, [type, body, recipientInput, groupTitle, participantInputs, apiFetch, onCreated])
|
}, [apiFetch, body, groupTitle, onCreated, participantInputs, recipientInput, type])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#020611cc] p-4 backdrop-blur-md">
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-md p-6">
|
<div className="w-full max-w-xl overflow-hidden rounded-[32px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(12,18,29,0.98),rgba(7,11,18,0.96))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">New Message</h2>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/35">Compose</p>
|
||||||
|
<h2 className="mt-2 text-xl font-semibold text-white">Start a new conversation</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-white/50">Send a direct message or set up a group thread with collaborators.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] text-white/55 transition hover:bg-white/[0.08] hover:text-white">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Type toggle */}
|
<form onSubmit={handleSubmit} className="space-y-5 px-6 py-6">
|
||||||
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-4">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{['direct', 'group'].map(t => (
|
{['direct', 'group'].map((entryType) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={entryType}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setType(t)}
|
onClick={() => setType(entryType)}
|
||||||
className={`flex-1 py-1.5 text-sm font-medium transition-colors ${
|
className={`rounded-[24px] border px-4 py-4 text-left transition ${type === entryType ? 'border-sky-400/28 bg-sky-500/[0.12] text-sky-100' : 'border-white/[0.08] bg-white/[0.03] text-white/65 hover:bg-white/[0.05] hover:text-white'}`}
|
||||||
type === t
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t === 'direct' ? '1:1 Message' : 'Group'}
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl ${entryType === 'direct' ? 'bg-sky-500/14 text-sky-200' : 'bg-fuchsia-500/14 text-fuchsia-200'}`}>
|
||||||
|
<i className={`fa-solid ${entryType === 'direct' ? 'fa-user' : 'fa-user-group'}`} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{entryType === 'direct' ? 'Direct message' : 'Group conversation'}</p>
|
||||||
|
<p className="mt-1 text-xs text-white/40">{entryType === 'direct' ? 'One creator, one thread.' : 'Coordinate with multiple people.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
|
||||||
{type === 'direct' ? (
|
{type === 'direct' ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">Recipient username</label>
|
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Recipient username</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={recipientInput}
|
value={recipientInput}
|
||||||
onChange={e => setRecipient(e.target.value)}
|
onChange={(e) => setRecipient(e.target.value)}
|
||||||
placeholder="username"
|
placeholder="username"
|
||||||
required
|
required
|
||||||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full rounded-2xl border border-white/[0.08] bg-black/15 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:border-sky-400/30 focus:bg-black/25"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">Group name</label>
|
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Group name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={groupTitle}
|
value={groupTitle}
|
||||||
onChange={e => setGroupTitle(e.target.value)}
|
onChange={(e) => setGroupTitle(e.target.value)}
|
||||||
placeholder="Group name"
|
placeholder="Group name"
|
||||||
required
|
required
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full rounded-2xl border border-white/[0.08] bg-black/15 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:border-sky-400/30 focus:bg-black/25"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">Participants (usernames)</label>
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
{participantInputs.map((val, i) => (
|
<label className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Participants</label>
|
||||||
<div key={i} className="flex gap-2 mb-1">
|
<button type="button" onClick={addParticipant} className="text-xs font-medium text-sky-300 transition hover:text-sky-200">
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={val}
|
|
||||||
onChange={e => updateParticipant(i, e.target.value)}
|
|
||||||
placeholder={`Username ${i + 1}`}
|
|
||||||
required
|
|
||||||
className="flex-1 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
{participantInputs.length > 2 && (
|
|
||||||
<button type="button" onClick={() => removeParticipant(i)} className="text-gray-400 hover:text-red-500">×</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button type="button" onClick={addParticipant} className="text-xs text-blue-500 hover:text-blue-700 mt-1">
|
|
||||||
+ Add participant
|
+ Add participant
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{participantInputs.map((value, index) => (
|
||||||
|
<div key={index} className="mb-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => updateParticipant(index, e.target.value)}
|
||||||
|
placeholder={`Username ${index + 1}`}
|
||||||
|
required
|
||||||
|
className="flex-1 rounded-2xl border border-white/[0.08] bg-black/15 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:border-sky-400/30 focus:bg-black/25"
|
||||||
|
/>
|
||||||
|
{participantInputs.length > 2 ? (
|
||||||
|
<button type="button" onClick={() => removeParticipant(index)} className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04] text-white/45 transition hover:border-rose-400/18 hover:bg-rose-500/10 hover:text-rose-200">×</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 mb-1">Message</label>
|
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Opening message</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={e => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
placeholder="Write your message…"
|
placeholder="Write your message…"
|
||||||
required
|
required
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
className="w-full resize-none rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full resize-none rounded-2xl border border-white/[0.08] bg-black/15 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:border-sky-400/30 focus:bg-black/25"
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-2 text-[11px] text-white/30">Keep it clear. You can continue the thread after the conversation is created.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error ? (
|
||||||
<p className="text-xs text-red-500 bg-red-50 dark:bg-red-900/30 rounded px-2 py-1">{error}</p>
|
<p className="rounded-2xl border border-rose-500/18 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">{error}</p>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end pt-1">
|
<div className="flex justify-end gap-2 border-t border-white/[0.06] pt-5">
|
||||||
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900">Cancel</button>
|
<button type="button" onClick={onClose} className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/65 transition hover:bg-white/[0.08] hover:text-white">Cancel</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
className="px-4 py-2 rounded-xl bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
className="rounded-full bg-sky-500 px-5 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{sending ? 'Sending…' : 'Send'}
|
{sending ? 'Creating…' : 'Create conversation'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -168,10 +174,9 @@ export default function NewConversationModal({ currentUserId, apiFetch, onCreate
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resolve username to user object via search API ───────────────────────────
|
|
||||||
async function resolveUsername(username, apiFetch) {
|
async function resolveUsername(username, apiFetch) {
|
||||||
const data = await apiFetch(`/api/search/users?q=${encodeURIComponent(username)}&limit=1`)
|
const data = await apiFetch(`/api/search/users?q=${encodeURIComponent(username)}&limit=1`)
|
||||||
const user = data?.data?.[0] ?? data?.[0]
|
const user = data?.data?.[0] ?? data?.[0]
|
||||||
if (!user) throw new Error(`User "${username}" not found.`)
|
if (!user) throw new Error(`User \"${username}\" not found.`)
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user