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) {