651 lines
23 KiB
JavaScript
651 lines
23 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react'
|
|
import { createRoot } from 'react-dom/client'
|
|
import { getEcho } from '../../bootstrap'
|
|
import ConversationList from '../../components/messaging/ConversationList'
|
|
import ConversationThread from '../../components/messaging/ConversationThread'
|
|
import NewConversationModal from '../../components/messaging/NewConversationModal'
|
|
|
|
function getCsrf() {
|
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
|
|
}
|
|
|
|
async function apiFetch(url, options = {}) {
|
|
const isFormData = options.body instanceof FormData
|
|
const socketId = getEcho()?.socketId?.()
|
|
const headers = {
|
|
'X-CSRF-TOKEN': getCsrf(),
|
|
Accept: 'application/json',
|
|
...options.headers,
|
|
}
|
|
|
|
if (socketId) {
|
|
headers['X-Socket-ID'] = socketId
|
|
}
|
|
|
|
if (!isFormData) {
|
|
headers['Content-Type'] = 'application/json'
|
|
}
|
|
|
|
const res = await fetch(url, {
|
|
headers,
|
|
...options,
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}))
|
|
throw new Error(err.message ?? `HTTP ${res.status}`)
|
|
}
|
|
|
|
return res.json()
|
|
}
|
|
|
|
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([])
|
|
const [loadingConvs, setLoadingConvs] = useState(true)
|
|
const [activeId, setActiveId] = useState(initialId ?? null)
|
|
const [realtimeEnabled, setRealtimeEnabled] = useState(false)
|
|
const [realtimeStatus, setRealtimeStatus] = useState('offline')
|
|
const [typingByConversation, setTypingByConversation] = useState({})
|
|
const [showNewModal, setShowNewModal] = useState(false)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [searchResults, setSearchResults] = useState([])
|
|
const [searching, setSearching] = useState(false)
|
|
|
|
const loadConversations = useCallback(async () => {
|
|
try {
|
|
const data = await apiFetch('/api/messages/conversations')
|
|
setConversations(data.data ?? [])
|
|
} catch (e) {
|
|
console.error('Failed to load conversations', e)
|
|
} finally {
|
|
setLoadingConvs(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadConversations()
|
|
|
|
apiFetch('/api/messages/settings')
|
|
.then((data) => setRealtimeEnabled(!!data?.realtime_enabled))
|
|
.catch(() => setRealtimeEnabled(false))
|
|
}, [loadConversations])
|
|
|
|
useEffect(() => {
|
|
const handlePopState = () => {
|
|
const match = window.location.pathname.match(/^\/messages\/(\d+)$/)
|
|
setActiveId(match ? Number(match[1]) : null)
|
|
}
|
|
|
|
window.addEventListener('popstate', handlePopState)
|
|
|
|
return () => window.removeEventListener('popstate', handlePopState)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (realtimeEnabled) {
|
|
return undefined
|
|
}
|
|
|
|
const poll = window.setInterval(loadConversations, 15000)
|
|
|
|
return () => window.clearInterval(poll)
|
|
}, [loadConversations, realtimeEnabled])
|
|
|
|
useEffect(() => {
|
|
if (!realtimeEnabled || !userId) {
|
|
setRealtimeStatus('offline')
|
|
return undefined
|
|
}
|
|
|
|
const echo = getEcho()
|
|
if (!echo) {
|
|
setRealtimeStatus('offline')
|
|
return undefined
|
|
}
|
|
|
|
const connection = echo.connector?.pusher?.connection
|
|
let heartbeatId = null
|
|
const mapConnectionState = (state) => {
|
|
if (state === 'connected') {
|
|
return 'connected'
|
|
}
|
|
|
|
if (state === 'connecting' || state === 'initialized' || state === 'connecting_in') {
|
|
return 'connecting'
|
|
}
|
|
|
|
return 'offline'
|
|
}
|
|
|
|
const syncConnectionState = (payload = null) => {
|
|
const nextState = typeof payload?.current === 'string'
|
|
? payload.current
|
|
: connection?.state
|
|
|
|
if (echo.socketId?.()) {
|
|
setRealtimeStatus('connected')
|
|
return
|
|
}
|
|
|
|
setRealtimeStatus(mapConnectionState(nextState))
|
|
}
|
|
|
|
const handleVisibilitySync = () => {
|
|
if (document.visibilityState === 'visible') {
|
|
syncConnectionState()
|
|
}
|
|
}
|
|
|
|
syncConnectionState()
|
|
connection?.bind?.('state_change', syncConnectionState)
|
|
connection?.bind?.('connected', syncConnectionState)
|
|
connection?.bind?.('unavailable', syncConnectionState)
|
|
connection?.bind?.('disconnected', syncConnectionState)
|
|
heartbeatId = window.setInterval(syncConnectionState, 1000)
|
|
window.addEventListener('focus', syncConnectionState)
|
|
document.addEventListener('visibilitychange', handleVisibilitySync)
|
|
|
|
const channel = echo.private(`user.${userId}`)
|
|
const handleConversationUpdated = (payload) => {
|
|
const nextConversation = payload?.conversation
|
|
if (!nextConversation?.id) {
|
|
return
|
|
}
|
|
|
|
setConversations((prev) => mergeConversationSummary(prev, nextConversation))
|
|
}
|
|
|
|
channel.listen('.conversation.updated', handleConversationUpdated)
|
|
|
|
return () => {
|
|
connection?.unbind?.('state_change', syncConnectionState)
|
|
connection?.unbind?.('connected', syncConnectionState)
|
|
connection?.unbind?.('unavailable', syncConnectionState)
|
|
connection?.unbind?.('disconnected', syncConnectionState)
|
|
if (heartbeatId) {
|
|
window.clearInterval(heartbeatId)
|
|
}
|
|
window.removeEventListener('focus', syncConnectionState)
|
|
document.removeEventListener('visibilitychange', handleVisibilitySync)
|
|
channel.stopListening('.conversation.updated', handleConversationUpdated)
|
|
echo.leaveChannel(`private-user.${userId}`)
|
|
}
|
|
}, [realtimeEnabled, userId])
|
|
|
|
useEffect(() => {
|
|
if (!realtimeEnabled) {
|
|
setTypingByConversation({})
|
|
return undefined
|
|
}
|
|
|
|
const echo = getEcho()
|
|
if (!echo || conversations.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
const timers = new Map()
|
|
const joinedChannels = []
|
|
|
|
const removeTypingUser = (conversationId, userIdToRemove) => {
|
|
const timerKey = `${conversationId}:${userIdToRemove}`
|
|
const existingTimer = timers.get(timerKey)
|
|
if (existingTimer) {
|
|
window.clearTimeout(existingTimer)
|
|
timers.delete(timerKey)
|
|
}
|
|
|
|
setTypingByConversation((prev) => {
|
|
const current = prev[conversationId] ?? []
|
|
const nextUsers = current.filter((user) => String(user.user_id ?? user.id) !== String(userIdToRemove))
|
|
|
|
if (nextUsers.length === current.length) {
|
|
return prev
|
|
}
|
|
|
|
if (nextUsers.length === 0) {
|
|
const next = { ...prev }
|
|
delete next[conversationId]
|
|
return next
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
[conversationId]: nextUsers,
|
|
}
|
|
})
|
|
}
|
|
|
|
conversations.forEach((conversation) => {
|
|
if (!conversation?.id) {
|
|
return
|
|
}
|
|
|
|
const conversationId = conversation.id
|
|
const channel = echo.join(`conversation.${conversationId}`)
|
|
joinedChannels.push(conversationId)
|
|
|
|
channel
|
|
.listen('.typing.started', (payload) => {
|
|
const user = payload?.user
|
|
if (!user?.id || user.id === userId) {
|
|
return
|
|
}
|
|
|
|
setTypingByConversation((prev) => {
|
|
const current = prev[conversationId] ?? []
|
|
const index = current.findIndex((entry) => String(entry.user_id ?? entry.id) === String(user.id))
|
|
const nextUser = { user_id: user.id, username: user.username }
|
|
|
|
if (index === -1) {
|
|
return {
|
|
...prev,
|
|
[conversationId]: [...current, nextUser],
|
|
}
|
|
}
|
|
|
|
const nextUsers = [...current]
|
|
nextUsers[index] = { ...nextUsers[index], ...nextUser }
|
|
return {
|
|
...prev,
|
|
[conversationId]: nextUsers,
|
|
}
|
|
})
|
|
|
|
const timerKey = `${conversationId}:${user.id}`
|
|
const existingTimer = timers.get(timerKey)
|
|
if (existingTimer) {
|
|
window.clearTimeout(existingTimer)
|
|
}
|
|
|
|
const timeout = window.setTimeout(() => removeTypingUser(conversationId, user.id), Number(payload?.expires_in_ms ?? 3500))
|
|
timers.set(timerKey, timeout)
|
|
})
|
|
.listen('.typing.stopped', (payload) => {
|
|
const typingUserId = payload?.user?.id
|
|
if (!typingUserId) {
|
|
return
|
|
}
|
|
|
|
removeTypingUser(conversationId, typingUserId)
|
|
})
|
|
})
|
|
|
|
return () => {
|
|
timers.forEach((timer) => window.clearTimeout(timer))
|
|
joinedChannels.forEach((conversationId) => {
|
|
echo.leave(`conversation.${conversationId}`)
|
|
})
|
|
}
|
|
}, [conversations, realtimeEnabled, userId])
|
|
|
|
const handleSelectConversation = useCallback((id) => {
|
|
setActiveId(id)
|
|
history.replaceState(null, '', `/messages/${id}`)
|
|
}, [])
|
|
|
|
const handleConversationCreated = useCallback((conv) => {
|
|
setShowNewModal(false)
|
|
loadConversations()
|
|
setActiveId(conv.id)
|
|
history.replaceState(null, '', `/messages/${conv.id}`)
|
|
}, [loadConversations])
|
|
|
|
const handleMarkRead = useCallback((conversationId) => {
|
|
setConversations((prev) => prev.map((conversation) => (
|
|
conversation.id === conversationId
|
|
? { ...conversation, unread_count: 0 }
|
|
: conversation
|
|
)))
|
|
}, [])
|
|
|
|
const handleConversationPatched = useCallback((patch) => {
|
|
if (!patch?.id) {
|
|
return
|
|
}
|
|
|
|
setConversations((prev) => mergeConversationSummary(prev, patch))
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
const run = async () => {
|
|
const q = searchQuery.trim()
|
|
if (q.length < 2) {
|
|
setSearchResults([])
|
|
setSearching(false)
|
|
return
|
|
}
|
|
|
|
setSearching(true)
|
|
|
|
try {
|
|
const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}`)
|
|
if (!cancelled) {
|
|
setSearchResults(data.data ?? [])
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setSearchResults([])
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setSearching(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
const timer = setTimeout(run, 250)
|
|
return () => {
|
|
cancelled = true
|
|
clearTimeout(timer)
|
|
}
|
|
}, [searchQuery])
|
|
|
|
const openSearchResult = useCallback((item) => {
|
|
if (!item?.conversation_id) return
|
|
setActiveId(item.conversation_id)
|
|
history.replaceState(null, '', `/messages/${item.conversation_id}?focus=${item.id}`)
|
|
}, [])
|
|
|
|
const activeConversation = conversations.find((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="messages-page 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 ${connectionBadgeClass(realtimeEnabled, realtimeStatus)}`}>
|
|
<span className={`h-1.5 w-1.5 rounded-full ${connectionDotClass(realtimeEnabled, realtimeStatus)}`} />
|
|
{connectionBadgeLabel(realtimeEnabled, realtimeStatus)}
|
|
</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}
|
|
loading={loadingConvs}
|
|
activeId={activeId}
|
|
currentUserId={userId}
|
|
typingByConversation={typingByConversation}
|
|
onSelect={handleSelectConversation}
|
|
/>
|
|
</aside>
|
|
|
|
<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}
|
|
conversationId={activeId}
|
|
conversation={activeConversation}
|
|
realtimeEnabled={realtimeEnabled}
|
|
realtimeStatus={realtimeStatus}
|
|
currentUserId={userId}
|
|
currentUsername={username}
|
|
apiFetch={apiFetch}
|
|
onBack={() => {
|
|
setActiveId(null)
|
|
history.replaceState(null, '', '/messages')
|
|
}}
|
|
onMarkRead={handleMarkRead}
|
|
onConversationPatched={handleConversationPatched}
|
|
/>
|
|
) : (
|
|
<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>
|
|
|
|
{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>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
function mergeConversationSummary(existing, incoming) {
|
|
const next = [...existing]
|
|
const index = next.findIndex((conversation) => conversation.id === incoming.id)
|
|
|
|
if (index >= 0) {
|
|
next[index] = { ...next[index], ...incoming }
|
|
} else {
|
|
next.unshift(incoming)
|
|
}
|
|
|
|
return next.sort((left, right) => {
|
|
const leftPinned = left.my_participant?.is_pinned ? 1 : 0
|
|
const rightPinned = right.my_participant?.is_pinned ? 1 : 0
|
|
if (leftPinned !== rightPinned) {
|
|
return rightPinned - leftPinned
|
|
}
|
|
|
|
const leftPinnedAt = left.my_participant?.pinned_at ? new Date(left.my_participant.pinned_at).getTime() : 0
|
|
const rightPinnedAt = right.my_participant?.pinned_at ? new Date(right.my_participant.pinned_at).getTime() : 0
|
|
if (leftPinnedAt !== rightPinnedAt) {
|
|
return rightPinnedAt - leftPinnedAt
|
|
}
|
|
|
|
const leftTime = left.last_message_at ? new Date(left.last_message_at).getTime() : 0
|
|
const rightTime = right.last_message_at ? new Date(right.last_message_at).getTime() : 0
|
|
return rightTime - leftTime
|
|
})
|
|
}
|
|
|
|
function connectionBadgeClass(realtimeEnabled, realtimeStatus) {
|
|
if (!realtimeEnabled) {
|
|
return 'border-white/[0.08] bg-white/[0.04] text-white/55'
|
|
}
|
|
|
|
if (realtimeStatus === 'connected') {
|
|
return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
|
|
}
|
|
|
|
if (realtimeStatus === 'connecting') {
|
|
return 'border-amber-400/20 bg-amber-500/10 text-amber-200'
|
|
}
|
|
|
|
return 'border-rose-400/18 bg-rose-500/10 text-rose-200'
|
|
}
|
|
|
|
function connectionDotClass(realtimeEnabled, realtimeStatus) {
|
|
if (!realtimeEnabled) {
|
|
return 'bg-white/30'
|
|
}
|
|
|
|
if (realtimeStatus === 'connected') {
|
|
return 'bg-emerald-300'
|
|
}
|
|
|
|
if (realtimeStatus === 'connecting') {
|
|
return 'bg-amber-300'
|
|
}
|
|
|
|
return 'bg-rose-300'
|
|
}
|
|
|
|
function connectionBadgeLabel(realtimeEnabled, realtimeStatus) {
|
|
if (!realtimeEnabled) {
|
|
return 'Polling every 15s'
|
|
}
|
|
|
|
if (realtimeStatus === 'connected') {
|
|
return 'Realtime connected'
|
|
}
|
|
|
|
if (realtimeStatus === 'connecting') {
|
|
return 'Realtime connecting'
|
|
}
|
|
|
|
return 'Realtime disconnected'
|
|
}
|
|
|
|
const el = document.getElementById('messages-root')
|
|
|
|
if (el) {
|
|
function parse(key, fallback = null) {
|
|
try {
|
|
return JSON.parse(el.dataset[key] ?? 'null') ?? fallback
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
createRoot(el).render(
|
|
<MessagesPage
|
|
userId={parse('userId')}
|
|
username={parse('username', '')}
|
|
activeConversationId={parse('activeConversationId')}
|
|
/>,
|
|
)
|
|
}
|
|
|
|
export default MessagesPage
|