feat: add Reverb realtime messaging

This commit is contained in:
2026-03-21 12:51:59 +01:00
parent 60f78e8235
commit e8b5edf5d2
45 changed files with 3609 additions and 339 deletions

View File

@@ -1,6 +1,6 @@
import React from 'react'
export default function ConversationList({ conversations, loading, activeId, currentUserId, onSelect }) {
export default function ConversationList({ conversations, loading, activeId, currentUserId, typingByConversation = {}, onSelect }) {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
@@ -13,7 +13,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
</span>
</div>
<ul className="flex-1 space-y-2 overflow-y-auto p-3">
<ul className="nova-scrollbar-message flex-1 space-y-2 overflow-y-auto p-3 pr-2">
{loading ? (
<li className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-8 text-center text-sm text-white/40">Loading conversations</li>
) : null}
@@ -28,6 +28,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
conv={conversation}
isActive={conversation.id === activeId}
currentUserId={currentUserId}
typingUsers={typingByConversation[conversation.id] ?? []}
onClick={() => onSelect(conversation.id)}
/>
))}
@@ -36,10 +37,12 @@ export default function ConversationList({ conversations, loading, activeId, cur
)
}
function ConversationRow({ conv, isActive, currentUserId, onClick }) {
function ConversationRow({ conv, isActive, currentUserId, typingUsers, onClick }) {
const label = convLabel(conv, currentUserId)
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
const preview = lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet'
const preview = typingUsers.length > 0
? buildTypingPreview(typingUsers)
: lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet'
const unread = conv.unread_count ?? 0
const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId)
const isArchived = myParticipant?.is_archived ?? false
@@ -89,8 +92,11 @@ function ConversationRow({ conv, isActive, currentUserId, onClick }) {
</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>
{typingUsers.length === 0 && senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null}
<p className={`mt-1 flex items-center gap-2 truncate text-sm ${typingUsers.length > 0 ? 'text-emerald-200' : 'text-white/62'}`}>
{typingUsers.length > 0 ? <SidebarTypingIcon /> : null}
<span className="truncate">{preview}</span>
</p>
</div>
</div>
</div>
@@ -110,6 +116,23 @@ function truncate(str, max) {
return str.length > max ? `${str.slice(0, max)}` : str
}
function buildTypingPreview(users) {
const names = users.map((user) => `@${user.username}`)
if (names.length === 1) return `${names[0]} is typing...`
if (names.length === 2) return `${names[0]} and ${names[1]} are typing...`
return `${names[0]}, ${names[1]} and ${names.length - 2} others are typing...`
}
function SidebarTypingIcon() {
return (
<span className="inline-flex shrink-0 items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300 animate-[pulse_1s_ease-in-out_infinite]" />
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300/80 animate-[pulse_1s_ease-in-out_150ms_infinite]" />
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300/60 animate-[pulse_1s_ease-in-out_300ms_infinite]" />
</span>
)
}
function relativeTime(iso) {
if (!iso) return 'No activity'
const diff = (Date.now() - new Date(iso).getTime()) / 1000

View File

@@ -1,16 +1,18 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { getEcho } from '../../bootstrap'
import MessageBubble from './MessageBubble'
export default function ConversationThread({
conversationId,
conversation,
realtimeEnabled,
realtimeStatus,
currentUserId,
currentUsername,
apiFetch,
onBack,
onMarkRead,
onConversationUpdated,
onConversationPatched,
}) {
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
@@ -21,6 +23,8 @@ export default function ConversationThread({
const [sending, setSending] = useState(false)
const [error, setError] = useState(null)
const [typingUsers, setTypingUsers] = useState([])
const [participantState, setParticipantState] = useState(conversation?.all_participants ?? [])
const [presenceUsers, setPresenceUsers] = useState([])
const [threadSearch, setThreadSearch] = useState('')
const [busyAction, setBusyAction] = useState(null)
const [lightbox, setLightbox] = useState(null)
@@ -29,8 +33,15 @@ export default function ConversationThread({
const fileInputRef = useRef(null)
const typingRef = useRef(null)
const stopTypingRef = useRef(null)
const readReceiptRef = useRef(null)
const typingExpiryTimersRef = useRef(new Map())
const messagesRef = useRef([])
const lastStartRef = useRef(0)
const initialLoadRef = useRef(true)
const shouldStickToBottomRef = useRef(true)
const previousScrollHeightRef = useRef(0)
const pendingPrependRef = useRef(false)
const pendingComposerScrollRef = useRef(false)
const knownMessageIdsRef = useRef(new Set())
const animatedMessageIdsRef = useRef(new Set())
const [animatedMessageIds, setAnimatedMessageIds] = useState({})
@@ -38,16 +49,22 @@ export default function ConversationThread({
const myParticipant = useMemo(() => (
conversation?.my_participant
?? conversation?.all_participants?.find((participant) => participant.user_id === currentUserId)
?? participantState.find((participant) => participant.user_id === currentUserId)
?? null
), [conversation, currentUserId])
), [conversation, currentUserId, participantState])
const participants = useMemo(() => conversation?.all_participants ?? [], [conversation])
const participants = useMemo(() => participantState, [participantState])
const participantNames = useMemo(() => (
participants
.map((participant) => participant.user?.username)
.filter(Boolean)
), [participants])
const remoteParticipantNames = useMemo(() => (
participants
.filter((participant) => participant.user_id !== currentUserId)
.map((participant) => participant.user?.username)
.filter(Boolean)
), [currentUserId, participants])
const filteredMessages = useMemo(() => {
const query = threadSearch.trim().toLowerCase()
@@ -66,10 +83,66 @@ export default function ConversationThread({
return participants.find((participant) => participant.user_id !== currentUserId)?.user?.username ?? 'Direct message'
}, [conversation, currentUserId, participants])
const patchConversation = useCallback((patch) => {
if (!patch) {
return
}
onConversationPatched?.({
id: conversationId,
...patch,
})
}, [conversationId, onConversationPatched])
const patchLastMessage = useCallback((message, extra = {}) => {
if (!message) {
return
}
patchConversation({
latest_message: message,
last_message_at: message.created_at ?? new Date().toISOString(),
...extra,
})
}, [patchConversation])
const patchMyParticipantState = useCallback((changes) => {
if (!changes) {
return
}
setParticipantState((prev) => prev.map((participant) => (
participant.user_id === currentUserId
? { ...participant, ...changes }
: participant
)))
patchConversation({
my_participant: {
...(myParticipant ?? {}),
...changes,
},
})
}, [currentUserId, myParticipant, patchConversation])
const scrollToBottom = useCallback(() => {
if (!listRef.current) {
return
}
listRef.current.scrollTop = listRef.current.scrollHeight
shouldStickToBottomRef.current = true
}, [])
const loadMessages = useCallback(async ({ cursor = null, append = false, silent = false } = {}) => {
if (append) setLoadingMore(true)
else if (!silent) setLoading(true)
if (append && listRef.current) {
previousScrollHeightRef.current = listRef.current.scrollHeight
pendingPrependRef.current = true
}
try {
const url = cursor
? `/api/messages/${conversationId}?cursor=${encodeURIComponent(cursor)}`
@@ -96,30 +169,76 @@ export default function ConversationThread({
}
}, [apiFetch, conversationId])
const markConversationRead = useCallback(async (messageId = null) => {
try {
const response = await apiFetch(`/api/messages/${conversationId}/read`, {
method: 'POST',
body: JSON.stringify(messageId ? { message_id: messageId } : {}),
})
setParticipantState((prev) => prev.map((participant) => (
participant.user_id === currentUserId
? {
...participant,
last_read_at: response.last_read_at ?? new Date().toISOString(),
last_read_message_id: response.last_read_message_id ?? messageId ?? participant.last_read_message_id,
}
: participant
)))
onMarkRead?.(conversationId)
} catch {
// no-op
}
}, [apiFetch, conversationId, currentUserId, onMarkRead])
const queueReadReceipt = useCallback((messageId = null) => {
if (readReceiptRef.current) {
window.clearTimeout(readReceiptRef.current)
}
readReceiptRef.current = window.setTimeout(() => {
markConversationRead(messageId)
}, 220)
}, [markConversationRead])
useEffect(() => {
initialLoadRef.current = true
shouldStickToBottomRef.current = true
previousScrollHeightRef.current = 0
pendingPrependRef.current = false
pendingComposerScrollRef.current = false
knownMessageIdsRef.current = new Set()
animatedMessageIdsRef.current = new Set()
setMessages([])
setPresenceUsers([])
setTypingUsers([])
setNextCursor(null)
setBody('')
setFiles([])
setDraftTitle(conversation?.title ?? '')
loadMessages()
loadTyping()
}, [conversation?.title, conversationId, loadMessages, loadTyping])
if (!realtimeEnabled) {
loadTyping()
}
}, [conversationId, loadMessages, loadTyping, realtimeEnabled])
useEffect(() => {
apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' })
.then(() => onMarkRead?.(conversationId))
.catch(() => {})
}, [apiFetch, conversationId, onMarkRead])
setParticipantState(conversation?.all_participants ?? [])
setDraftTitle(conversation?.title ?? '')
}, [conversation?.all_participants, conversation?.title])
useEffect(() => {
markConversationRead()
}, [markConversationRead])
useEffect(() => {
if (realtimeEnabled) {
return undefined
}
const timer = window.setInterval(() => {
loadTyping()
if (!realtimeEnabled) {
loadMessages({ silent: true })
}
}, realtimeEnabled ? 5000 : 8000)
loadMessages({ silent: true })
}, 8000)
return () => window.clearInterval(timer)
}, [loadMessages, loadTyping, realtimeEnabled])
@@ -127,21 +246,176 @@ export default function ConversationThread({
useEffect(() => () => {
if (typingRef.current) window.clearTimeout(typingRef.current)
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
if (readReceiptRef.current) window.clearTimeout(readReceiptRef.current)
typingExpiryTimersRef.current.forEach((timer) => window.clearTimeout(timer))
}, [])
useEffect(() => {
if (!listRef.current) return
if (initialLoadRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight
initialLoadRef.current = false
messagesRef.current = messages
}, [messages])
useLayoutEffect(() => {
const container = listRef.current
if (!container) {
return
}
const nearBottom = listRef.current.scrollHeight - listRef.current.scrollTop - listRef.current.clientHeight < 180
if (nearBottom) {
listRef.current.scrollTop = listRef.current.scrollHeight
if (initialLoadRef.current) {
scrollToBottom()
initialLoadRef.current = false
previousScrollHeightRef.current = container.scrollHeight
return
}
}, [messages.length])
if (pendingPrependRef.current) {
const heightDelta = container.scrollHeight - previousScrollHeightRef.current
container.scrollTop += heightDelta
pendingPrependRef.current = false
previousScrollHeightRef.current = container.scrollHeight
return
}
const latestMessage = messages[messages.length - 1] ?? null
const shouldScroll = pendingComposerScrollRef.current
|| shouldStickToBottomRef.current
|| latestMessage?._optimistic
|| latestMessage?.sender_id === currentUserId
if (shouldScroll) {
scrollToBottom()
}
pendingComposerScrollRef.current = false
previousScrollHeightRef.current = container.scrollHeight
}, [currentUserId, messages, scrollToBottom])
useEffect(() => {
if (!realtimeEnabled) {
return undefined
}
const echo = getEcho()
if (!echo) {
return undefined
}
const syncMissedMessages = async () => {
const lastServerMessage = [...messagesRef.current]
.reverse()
.find((message) => Number.isFinite(Number(message.id)) && !message._optimistic)
if (!lastServerMessage?.id) {
return
}
try {
const data = await apiFetch(`/api/messages/${conversationId}?after_id=${encodeURIComponent(lastServerMessage.id)}`)
const incoming = normalizeMessages(data.data ?? [], currentUserId)
if (incoming.length > 0) {
setMessages((prev) => mergeMessageLists(prev, incoming))
}
} catch {
// no-op
}
}
const handleMessageCreated = (payload) => {
if (!payload?.message) return
const incoming = normalizeMessage(payload.message, currentUserId)
setMessages((prev) => mergeMessageLists(prev, [incoming]))
patchLastMessage(incoming, { unread_count: 0 })
if (incoming.sender_id !== currentUserId && document.visibilityState === 'visible') {
queueReadReceipt(incoming.id)
}
}
const handleMessageUpdated = (payload) => {
if (!payload?.message) return
const updated = normalizeMessage(payload.message, currentUserId)
setMessages((prev) => mergeMessageLists(prev, [updated]))
patchLastMessage(updated)
}
const handleMessageDeleted = (payload) => {
const deletedAt = payload?.deleted_at ?? new Date().toISOString()
setMessages((prev) => prev.map((message) => (
messagesMatch(message, payload)
? { ...message, body: '', deleted_at: deletedAt, attachments: [] }
: message
)))
patchConversation({ last_message_at: deletedAt })
}
const handleMessageRead = (payload) => {
if (!payload?.user?.id) return
setParticipantState((prev) => prev.map((participant) => (
participant.user_id === payload.user.id
? {
...participant,
last_read_at: payload.last_read_at ?? participant.last_read_at,
last_read_message_id: payload.last_read_message_id ?? participant.last_read_message_id,
}
: participant
)))
}
const removeTypingUser = (userId) => {
const existingTimer = typingExpiryTimersRef.current.get(userId)
if (existingTimer) {
window.clearTimeout(existingTimer)
typingExpiryTimersRef.current.delete(userId)
}
setTypingUsers((prev) => prev.filter((user) => user.user_id !== userId && user.id !== userId))
}
const handleTypingStarted = (payload) => {
const user = payload?.user
if (!user?.id || user.id === currentUserId) return
setTypingUsers((prev) => mergeTypingUsers(prev, {
user_id: user.id,
username: user.username,
}))
removeTypingUser(user.id)
const timeout = window.setTimeout(() => removeTypingUser(user.id), Number(payload?.expires_in_ms ?? 3500))
typingExpiryTimersRef.current.set(user.id, timeout)
}
const handleTypingStopped = (payload) => {
const userId = payload?.user?.id
if (!userId) return
removeTypingUser(userId)
}
const privateChannel = echo.private(`conversation.${conversationId}`)
privateChannel.listen('.message.created', handleMessageCreated)
privateChannel.listen('.message.updated', handleMessageUpdated)
privateChannel.listen('.message.deleted', handleMessageDeleted)
privateChannel.listen('.message.read', handleMessageRead)
const presenceChannel = echo.join(`conversation.${conversationId}`)
presenceChannel
.here((users) => setPresenceUsers(normalizePresenceUsers(users, currentUserId)))
.joining((user) => setPresenceUsers((prev) => mergePresenceUsers(prev, user, currentUserId)))
.leaving((user) => setPresenceUsers((prev) => prev.filter((member) => member.id !== user?.id)))
.listen('.typing.started', handleTypingStarted)
.listen('.typing.stopped', handleTypingStopped)
const connection = echo.connector?.pusher?.connection
connection?.bind?.('connected', syncMissedMessages)
syncMissedMessages()
return () => {
connection?.unbind?.('connected', syncMissedMessages)
typingExpiryTimersRef.current.forEach((timer) => window.clearTimeout(timer))
typingExpiryTimersRef.current.clear()
echo.leave(`conversation.${conversationId}`)
}
}, [apiFetch, conversationId, currentUserId, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled])
useEffect(() => {
const known = knownMessageIdsRef.current
@@ -183,6 +457,12 @@ export default function ConversationThread({
const handleBodyChange = useCallback((value) => {
setBody(value)
if (value.trim() === '') {
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
return
}
const now = Date.now()
if (now - lastStartRef.current > 2500) {
lastStartRef.current = now
@@ -208,8 +488,10 @@ export default function ConversationThread({
if (!trimmed && files.length === 0) return
const optimisticId = `optimistic-${Date.now()}`
const clientTempId = `tmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
const optimisticMessage = normalizeMessage({
id: optimisticId,
client_temp_id: clientTempId,
body: trimmed,
sender: { id: currentUserId, username: currentUsername },
sender_id: currentUserId,
@@ -223,6 +505,8 @@ export default function ConversationThread({
_optimistic: true,
}, currentUserId)
pendingComposerScrollRef.current = true
shouldStickToBottomRef.current = true
setMessages((prev) => mergeMessageLists(prev, [optimisticMessage]))
setBody('')
setFiles([])
@@ -232,6 +516,7 @@ export default function ConversationThread({
const formData = new FormData()
if (trimmed) formData.append('body', trimmed)
formData.append('client_temp_id', clientTempId)
files.forEach((file) => formData.append('attachments[]', file))
try {
@@ -241,10 +526,10 @@ export default function ConversationThread({
})
const normalized = normalizeMessage(created, currentUserId)
setMessages((prev) => prev.map((message) => message.id === optimisticId ? normalized : message))
onConversationUpdated?.()
setMessages((prev) => mergeMessageLists(prev, [normalized]))
patchLastMessage(normalized, { unread_count: 0 })
} catch (err) {
setMessages((prev) => prev.filter((message) => message.id !== optimisticId))
setMessages((prev) => prev.filter((message) => !messagesMatch(message, { id: optimisticId, client_temp_id: clientTempId })))
setBody(trimmed)
setFiles(files)
setError(err.message)
@@ -252,7 +537,7 @@ export default function ConversationThread({
setSending(false)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
}
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, onConversationUpdated, sending])
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, sending])
const updateReactions = useCallback((messageId, summary) => {
setMessages((prev) => prev.map((message) => {
@@ -289,8 +574,8 @@ export default function ConversationThread({
setMessages((prev) => prev.map((message) => (
message.id === messageId ? normalizeMessage({ ...message, ...updated }, currentUserId) : message
)))
onConversationUpdated?.()
}, [apiFetch, currentUserId, onConversationUpdated])
patchLastMessage(normalizeMessage(updated, currentUserId))
}, [apiFetch, currentUserId, patchLastMessage])
const handleDelete = useCallback(async (messageId) => {
await apiFetch(`/api/messages/message/${messageId}`, { method: 'DELETE' })
@@ -299,8 +584,8 @@ export default function ConversationThread({
? { ...message, body: '', deleted_at: new Date().toISOString(), attachments: [] }
: message
)))
onConversationUpdated?.()
}, [apiFetch, onConversationUpdated])
patchConversation({ last_message_at: new Date().toISOString() })
}, [apiFetch, patchConversation])
const runConversationAction = useCallback(async (action, url, apply) => {
setBusyAction(action)
@@ -308,14 +593,13 @@ export default function ConversationThread({
try {
const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' })
apply?.(response)
onConversationUpdated?.()
if (action === 'leave') onBack?.()
} catch (e) {
setError(e.message)
} finally {
setBusyAction(null)
}
}, [apiFetch, onBack, onConversationUpdated])
}, [apiFetch, onBack])
const handleRename = useCallback(async () => {
const title = draftTitle.trim()
@@ -329,17 +613,21 @@ export default function ConversationThread({
method: 'POST',
body: JSON.stringify({ title }),
})
onConversationUpdated?.()
patchConversation({ title })
} catch (e) {
setError(e.message)
} finally {
setBusyAction(null)
}
}, [apiFetch, conversation?.title, conversationId, draftTitle, onConversationUpdated])
}, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation])
const visibleMessages = filteredMessages
const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at])
const typingLabel = buildTypingLabel(typingUsers)
const presenceLabel = presenceUsers.length > 0 ? `${presenceUsers.length} active now` : null
const typingSummary = typingUsers.length > 0
? `${typingLabel} ${conversation?.type === 'group' ? '' : 'Reply will appear here instantly.'}`.trim()
: null
return (
<div className="flex min-h-0 flex-1 flex-col">
@@ -356,6 +644,7 @@ export default function ConversationThread({
<div className="mt-1 flex flex-wrap items-center gap-2">
<h2 className="truncate text-2xl font-semibold text-white">{conversationLabel}</h2>
{conversation?.type === 'group' ? <span className="rounded-full border border-fuchsia-400/20 bg-fuchsia-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-fuchsia-200">Group</span> : <span className="rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200">Direct</span>}
{realtimeEnabled ? <RealtimeStatusBadge status={realtimeStatus} /> : null}
{myParticipant?.is_pinned ? <span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-200">Pinned</span> : null}
{myParticipant?.is_muted ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Muted</span> : null}
{myParticipant?.is_archived ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Archived</span> : null}
@@ -366,12 +655,18 @@ export default function ConversationThread({
? `Participants: ${participantNames.join(', ')}`
: `Private thread with ${participantNames.find((name) => name !== currentUsername) ?? conversationLabel}.`}
</p>
{typingSummary ? (
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-emerald-400/18 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100/90">
<TypingPulse />
<span>{typingSummary}</span>
</div>
) : presenceLabel ? <p className="mt-1 text-xs text-emerald-200/70">{presenceLabel}</p> : conversation?.type === 'direct' && remoteParticipantNames.length > 0 ? <p className="mt-1 text-xs text-white/38">Chatting with @{remoteParticipantNames[0]} in realtime.</p> : null}
</div>
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 lg:mx-0 lg:flex-wrap lg:overflow-visible lg:px-0 lg:pb-0">
<button
onClick={() => runConversationAction('archive', `/api/messages/${conversationId}/archive`, (response) => {
if (conversation?.my_participant) conversation.my_participant.is_archived = !!response.is_archived
patchMyParticipantState({ is_archived: !!response.is_archived })
})}
className={actionButtonClass(busyAction === 'archive')}
>
@@ -380,7 +675,7 @@ export default function ConversationThread({
</button>
<button
onClick={() => runConversationAction('mute', `/api/messages/${conversationId}/mute`, (response) => {
if (conversation?.my_participant) conversation.my_participant.is_muted = !!response.is_muted
patchMyParticipantState({ is_muted: !!response.is_muted })
})}
className={actionButtonClass(busyAction === 'mute')}
>
@@ -389,7 +684,10 @@ export default function ConversationThread({
</button>
<button
onClick={() => runConversationAction(myParticipant?.is_pinned ? 'unpin' : 'pin', `/api/messages/${conversationId}/${myParticipant?.is_pinned ? 'unpin' : 'pin'}`, (response) => {
if (conversation?.my_participant) conversation.my_participant.is_pinned = !!response.is_pinned
patchMyParticipantState({
is_pinned: !!response.is_pinned,
pinned_at: response.is_pinned ? new Date().toISOString() : null,
})
})}
className={actionButtonClass(busyAction === 'pin' || busyAction === 'unpin')}
>
@@ -449,7 +747,17 @@ export default function ConversationThread({
</div>
) : null}
<div ref={listRef} className="flex-1 overflow-y-auto px-3 py-4 sm:px-6 sm:py-5">
<div
ref={listRef}
onScroll={() => {
if (!listRef.current) {
return
}
shouldStickToBottomRef.current = isNearBottom(listRef.current)
}}
className="nova-scrollbar-message flex-1 overflow-y-auto px-3 py-4 pr-2 sm:px-6 sm:py-5"
>
{nextCursor ? (
<div className="mb-4 flex justify-center">
<button
@@ -650,11 +958,66 @@ function summaryToReactionArray(summary, currentUserId) {
}
function mergeMessageLists(existing, incoming) {
const map = new Map()
for (const message of [...existing, ...incoming]) {
map.set(message.id, message)
const next = [...existing]
for (const incomingMessage of incoming) {
const existingIndex = next.findIndex((message) => messagesMatch(message, incomingMessage))
if (existingIndex >= 0) {
next[existingIndex] = {
...next[existingIndex],
...incomingMessage,
_optimistic: false,
}
continue
}
next.push(incomingMessage)
}
return Array.from(map.values()).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
return next.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
}
function messagesMatch(left, right) {
if (!left || !right) return false
if (left.id && right.id && String(left.id) === String(right.id)) return true
if (left.uuid && right.uuid && left.uuid === right.uuid) return true
if (left.client_temp_id && right.client_temp_id && left.client_temp_id === right.client_temp_id) return true
return false
}
function mergeTypingUsers(existing, incoming) {
const matchIndex = existing.findIndex((user) => String(user.user_id ?? user.id) === String(incoming.user_id ?? incoming.id))
if (matchIndex === -1) {
return [...existing, incoming]
}
const next = [...existing]
next[matchIndex] = { ...next[matchIndex], ...incoming }
return next
}
function normalizePresenceUsers(users, currentUserId) {
return (users ?? []).filter((user) => user?.id !== currentUserId)
}
function mergePresenceUsers(existing, incoming, currentUserId) {
if (!incoming?.id || incoming.id === currentUserId) {
return existing
}
const next = [...existing]
const index = next.findIndex((user) => user.id === incoming.id)
if (index >= 0) {
next[index] = { ...next[index], ...incoming }
return next
}
next.push(incoming)
return next
}
function isNearBottom(container, threshold = 120) {
return container.scrollHeight - container.scrollTop - container.clientHeight <= threshold
}
function buildTypingLabel(users) {
@@ -759,6 +1122,43 @@ function UnreadMarker({ prefersReducedMotion }) {
)
}
function TypingPulse() {
return (
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-300 animate-[pulse_1s_ease-in-out_infinite]" />
<span className="h-2 w-2 rounded-full bg-emerald-300/80 animate-[pulse_1s_ease-in-out_160ms_infinite]" />
<span className="h-2 w-2 rounded-full bg-emerald-300/60 animate-[pulse_1s_ease-in-out_320ms_infinite]" />
</span>
)
}
function RealtimeStatusBadge({ status }) {
const label = status === 'connected'
? 'Socket live'
: status === 'connecting'
? 'Socket connecting'
: 'Socket offline'
const tone = status === 'connected'
? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
: status === 'connecting'
? 'border-amber-400/20 bg-amber-500/10 text-amber-200'
: 'border-rose-400/18 bg-rose-500/10 text-rose-200'
const dot = status === 'connected'
? 'bg-emerald-300'
: status === 'connecting'
? 'bg-amber-300'
: 'bg-rose-300'
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone}`}>
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />
{label}
</span>
)
}
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)