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) {
|
||||
|
||||
52
resources/js/bootstrap.js
vendored
52
resources/js/bootstrap.js
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user