feat: add Reverb realtime messaging
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user