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,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
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'
@@ -10,12 +11,17 @@ function getCsrf() {
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'
}
@@ -58,11 +64,12 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
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 pollRef = useRef(null)
const loadConversations = useCallback(async () => {
try {
@@ -81,28 +88,215 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
apiFetch('/api/messages/settings')
.then((data) => setRealtimeEnabled(!!data?.realtime_enabled))
.catch(() => setRealtimeEnabled(false))
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [loadConversations])
useEffect(() => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
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
}
pollRef.current = setInterval(loadConversations, 15000)
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 () => {
if (pollRef.current) clearInterval(pollRef.current)
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}`)
}
}, [loadConversations, realtimeEnabled])
}, [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)
@@ -124,6 +318,14 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
)))
}, [])
const handleConversationPatched = useCallback((patch) => {
if (!patch?.id) {
return
}
setConversations((prev) => mergeConversationSummary(prev, patch))
}, [])
useEffect(() => {
let cancelled = false
@@ -182,7 +384,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|| 'Conversation'
return (
<div className="px-4 pb-16 pt-4 md:px-6 lg:px-8 lg:pt-6">
<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">
@@ -209,9 +411,9 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
</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 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]" />
@@ -273,6 +475,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
loading={loadingConvs}
activeId={activeId}
currentUserId={userId}
typingByConversation={typingByConversation}
onSelect={handleSelectConversation}
/>
</aside>
@@ -284,6 +487,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
conversationId={activeId}
conversation={activeConversation}
realtimeEnabled={realtimeEnabled}
realtimeStatus={realtimeStatus}
currentUserId={userId}
currentUsername={username}
apiFetch={apiFetch}
@@ -292,7 +496,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
history.replaceState(null, '', '/messages')
}}
onMarkRead={handleMarkRead}
onConversationUpdated={loadConversations}
onConversationPatched={handleConversationPatched}
/>
) : (
<div className="flex flex-1 items-center justify-center p-8">
@@ -346,6 +550,83 @@ function StatChip({ label, value, tone = 'sky' }) {
)
}
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) {

View File

@@ -1,9 +1,51 @@
import axios from 'axios';
window.axios = axios;
import axios from 'axios'
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios = axios
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
if (csrfToken) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken;
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken
}
window.Pusher = Pusher
let echoInstance = null
export function getEcho() {
if (echoInstance !== null) {
return echoInstance || null
}
const key = import.meta.env.VITE_REVERB_APP_KEY
if (!key) {
echoInstance = false
return null
}
const scheme = import.meta.env.VITE_REVERB_SCHEME || window.location.protocol.replace(':', '') || 'https'
const forceTLS = scheme === 'https'
echoInstance = new Echo({
broadcaster: 'reverb',
key,
wsHost: import.meta.env.VITE_REVERB_HOST || window.location.hostname,
wsPort: Number(import.meta.env.VITE_REVERB_PORT || (forceTLS ? 443 : 80)),
wssPort: Number(import.meta.env.VITE_REVERB_PORT || 443),
forceTLS,
enabledTransports: ['ws', 'wss'],
authEndpoint: '/broadcasting/auth',
auth: {
headers: {
'X-CSRF-TOKEN': csrfToken || '',
Accept: 'application/json',
},
},
})
window.Echo = echoInstance
return echoInstance
}

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)