Files
SkinbaseNova/resources/js/components/messaging/ConversationThread.jsx
2026-03-28 19:15:39 +01:00

1288 lines
49 KiB
JavaScript

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,
onlineUserIds,
apiFetch,
onBack,
onMarkRead,
onConversationPatched,
onUnreadTotalPatched,
}) {
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [nextCursor, setNextCursor] = useState(null)
const [body, setBody] = useState('')
const [files, setFiles] = useState([])
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)
const [draftTitle, setDraftTitle] = useState(conversation?.title ?? '')
const listRef = useRef(null)
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({})
const prefersReducedMotion = usePrefersReducedMotion()
const draftStorageKey = useMemo(() => (conversationId ? `nova_draft_${conversationId}` : null), [conversationId])
const readDraftFromStorage = useCallback(() => {
if (!draftStorageKey || typeof window === 'undefined') {
return ''
}
try {
return window.localStorage.getItem(draftStorageKey) ?? ''
} catch {
return ''
}
}, [draftStorageKey])
const persistDraftToStorage = useCallback((value) => {
if (!draftStorageKey || typeof window === 'undefined') {
return
}
try {
if (value.trim() === '') {
window.localStorage.removeItem(draftStorageKey)
} else {
window.localStorage.setItem(draftStorageKey, value)
}
} catch {
// no-op
}
}, [draftStorageKey])
const myParticipant = useMemo(() => (
conversation?.my_participant
?? participantState.find((participant) => participant.user_id === currentUserId)
?? null
), [conversation, currentUserId, participantState])
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 directParticipant = useMemo(() => (
participants.find((participant) => participant.user_id !== currentUserId) ?? null
), [currentUserId, participants])
const remoteIsOnline = directParticipant ? onlineUserIds.includes(Number(directParticipant.user_id)) : false
const remoteIsViewingConversation = directParticipant
? presenceUsers.some((user) => Number(user?.id) === Number(directParticipant.user_id))
: false
const filteredMessages = useMemo(() => {
const query = threadSearch.trim().toLowerCase()
if (!query) return messages
return messages.filter((message) => {
const sender = message.sender?.username?.toLowerCase() ?? ''
const text = message.body?.toLowerCase() ?? ''
const attachmentNames = (message.attachments ?? []).map((attachment) => attachment.original_name?.toLowerCase() ?? '').join(' ')
return sender.includes(query) || text.includes(query) || attachmentNames.includes(query)
})
}, [messages, threadSearch])
const conversationLabel = useMemo(() => {
if (!conversation) return 'Conversation'
if (conversation.type === 'group') return conversation.title ?? 'Group conversation'
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)}`
: `/api/messages/${conversationId}`
const data = await apiFetch(url)
const incoming = normalizeMessages(data.data ?? [], currentUserId)
setNextCursor(data.next_cursor ?? null)
setMessages((prev) => append ? mergeMessageLists(incoming, prev) : incoming)
setError(null)
} catch (e) {
setError(e.message)
} finally {
if (!silent) setLoading(false)
setLoadingMore(false)
}
}, [apiFetch, conversationId, currentUserId])
const loadTyping = useCallback(async () => {
try {
const data = await apiFetch(`/api/messages/${conversationId}/typing`)
setTypingUsers(data.typing ?? [])
} catch {
setTypingUsers([])
}
}, [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, response?.unread_total ?? null)
} 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(readDraftFromStorage())
setFiles([])
loadMessages()
if (!realtimeEnabled) {
loadTyping()
}
}, [conversationId, loadMessages, loadTyping, readDraftFromStorage, realtimeEnabled])
useEffect(() => {
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()
loadMessages({ silent: true })
}, 8000)
return () => window.clearInterval(timer)
}, [loadMessages, loadTyping, realtimeEnabled])
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(() => {
messagesRef.current = messages
}, [messages])
useLayoutEffect(() => {
const container = listRef.current
if (!container) {
return
}
if (initialLoadRef.current) {
scrollToBottom()
initialLoadRef.current = false
previousScrollHeightRef.current = container.scrollHeight
return
}
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}/delta?after_message_id=${encodeURIComponent(lastServerMessage.id)}`)
const incoming = normalizeMessages(data.data ?? [], currentUserId)
if (data?.conversation?.id) {
patchConversation(data.conversation)
}
if (Number.isFinite(Number(data?.summary?.unread_total))) {
onUnreadTotalPatched?.(data.summary.unread_total)
}
if (incoming.length > 0) {
setMessages((prev) => mergeMessageLists(prev, incoming))
const latestIncoming = incoming[incoming.length - 1] ?? null
const latestRemoteIncoming = [...incoming].reverse().find((message) => message.sender_id !== currentUserId) ?? null
if (latestIncoming) {
patchLastMessage(latestIncoming)
}
if (latestRemoteIncoming && document.visibilityState === 'visible') {
queueReadReceipt(latestRemoteIncoming.id)
}
}
} 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, onUnreadTotalPatched, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled])
useEffect(() => {
const known = knownMessageIdsRef.current
const nextAnimatedIds = []
for (const message of messages) {
if (known.has(message.id)) continue
known.add(message.id)
if (!initialLoadRef.current && !message._optimistic && !animatedMessageIdsRef.current.has(message.id)) {
animatedMessageIdsRef.current.add(message.id)
nextAnimatedIds.push(message.id)
}
}
if (prefersReducedMotion || nextAnimatedIds.length === 0) return undefined
setAnimatedMessageIds((prev) => {
const next = { ...prev }
nextAnimatedIds.forEach((id) => {
next[id] = true
})
return next
})
const timer = window.setTimeout(() => {
setAnimatedMessageIds((prev) => {
const next = { ...prev }
nextAnimatedIds.forEach((id) => {
delete next[id]
})
return next
})
}, 2200)
return () => window.clearTimeout(timer)
}, [messages, prefersReducedMotion])
const handleBodyChange = useCallback((value) => {
setBody(value)
persistDraftToStorage(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
apiFetch(`/api/messages/${conversationId}/typing`, { method: 'POST' }).catch(() => {})
}
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
stopTypingRef.current = window.setTimeout(() => {
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
}, 1200)
}, [apiFetch, conversationId, persistDraftToStorage])
const handleFiles = useCallback((selectedFiles) => {
const nextFiles = Array.from(selectedFiles || []).slice(0, 5)
setFiles(nextFiles)
}, [])
const handleSend = useCallback(async (e) => {
e.preventDefault()
if (sending) return
const trimmed = body.trim()
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,
created_at: new Date().toISOString(),
attachments: files.map((file, index) => ({
id: `${optimisticId}-${index}`,
type: file.type?.startsWith('image/') ? 'image' : 'file',
original_name: file.name,
})),
reactions: [],
_optimistic: true,
}, currentUserId)
pendingComposerScrollRef.current = true
shouldStickToBottomRef.current = true
setMessages((prev) => mergeMessageLists(prev, [optimisticMessage]))
setBody('')
persistDraftToStorage('')
setFiles([])
if (fileInputRef.current) fileInputRef.current.value = ''
setSending(true)
setError(null)
const formData = new FormData()
if (trimmed) formData.append('body', trimmed)
formData.append('client_temp_id', clientTempId)
files.forEach((file) => formData.append('attachments[]', file))
try {
const created = await apiFetch(`/api/messages/${conversationId}`, {
method: 'POST',
body: formData,
})
if (draftStorageKey && typeof window !== 'undefined') {
try {
window.localStorage.removeItem(draftStorageKey)
} catch {
// no-op
}
}
const normalized = normalizeMessage(created, currentUserId)
setMessages((prev) => mergeMessageLists(prev, [normalized]))
patchLastMessage(normalized, { unread_count: 0 })
} catch (err) {
setMessages((prev) => prev.filter((message) => !messagesMatch(message, { id: optimisticId, client_temp_id: clientTempId })))
setBody(trimmed)
persistDraftToStorage(trimmed)
setFiles(files)
setError(err.message)
} finally {
setSending(false)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
}
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, persistDraftToStorage, sending])
const updateReactions = useCallback((messageId, summary) => {
setMessages((prev) => prev.map((message) => {
if (message.id !== messageId) return message
return {
...message,
reactions: summaryToReactionArray(summary, currentUserId),
}
}))
}, [currentUserId])
const handleReact = useCallback(async (messageId, reaction) => {
const summary = await apiFetch(`/api/messages/${messageId}/reactions`, {
method: 'POST',
body: JSON.stringify({ reaction }),
})
updateReactions(messageId, summary)
}, [apiFetch, updateReactions])
const handleUnreact = useCallback(async (messageId, reaction) => {
const summary = await apiFetch(`/api/messages/${messageId}/reactions`, {
method: 'DELETE',
body: JSON.stringify({ reaction }),
})
updateReactions(messageId, summary)
}, [apiFetch, updateReactions])
const handleEdit = useCallback(async (messageId, nextBody) => {
const updated = await apiFetch(`/api/messages/message/${messageId}`, {
method: 'PATCH',
body: JSON.stringify({ body: nextBody }),
})
setMessages((prev) => prev.map((message) => (
message.id === messageId ? normalizeMessage({ ...message, ...updated }, currentUserId) : message
)))
patchLastMessage(normalizeMessage(updated, currentUserId))
}, [apiFetch, currentUserId, patchLastMessage])
const handleDelete = useCallback(async (messageId) => {
await apiFetch(`/api/messages/message/${messageId}`, { method: 'DELETE' })
setMessages((prev) => prev.map((message) => (
message.id === messageId
? { ...message, body: '', deleted_at: new Date().toISOString(), attachments: [] }
: message
)))
patchConversation({ last_message_at: new Date().toISOString() })
}, [apiFetch, patchConversation])
const runConversationAction = useCallback(async (action, url, apply) => {
setBusyAction(action)
setError(null)
try {
const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' })
apply?.(response)
if (action === 'leave') onBack?.()
} catch (e) {
setError(e.message)
} finally {
setBusyAction(null)
}
}, [apiFetch, onBack])
const handleRename = useCallback(async () => {
const title = draftTitle.trim()
if (!title || title === conversation?.title) return
setBusyAction('rename')
setError(null)
try {
await apiFetch(`/api/messages/${conversationId}/rename`, {
method: 'POST',
body: JSON.stringify({ title }),
})
patchConversation({ title })
} catch (e) {
setError(e.message)
} finally {
setBusyAction(null)
}
}, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation])
const visibleMessages = filteredMessages
const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant), [visibleMessages, currentUserId, myParticipant])
const typingLabel = buildTypingLabel(typingUsers)
const presenceLabel = conversation?.type === 'group'
? (presenceUsers.length > 0 ? `${presenceUsers.length} active now` : null)
: (remoteIsViewingConversation ? 'Viewing this conversation' : (remoteIsOnline ? 'Online 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">
<header className="border-b border-white/[0.06] px-3 py-4 sm:px-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 lg:hidden">
<button onClick={onBack} className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] text-white/60 transition hover:bg-white/[0.08] hover:text-white">
<i className="fa-solid fa-arrow-left text-sm" />
</button>
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Inbox</span>
</div>
<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}
</div>
<p className="mt-2 max-w-3xl text-sm leading-6 text-white/50">
{conversation?.type === 'group'
? `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) => {
patchMyParticipantState({ is_archived: !!response.is_archived })
})}
className={actionButtonClass(busyAction === 'archive')}
>
<i className="fa-solid fa-box-archive text-[11px]" />
{myParticipant?.is_archived ? 'Unarchive' : 'Archive'}
</button>
<button
onClick={() => runConversationAction('mute', `/api/messages/${conversationId}/mute`, (response) => {
patchMyParticipantState({ is_muted: !!response.is_muted })
})}
className={actionButtonClass(busyAction === 'mute')}
>
<i className="fa-solid fa-bell-slash text-[11px]" />
{myParticipant?.is_muted ? 'Unmute' : 'Mute'}
</button>
<button
onClick={() => runConversationAction(myParticipant?.is_pinned ? 'unpin' : 'pin', `/api/messages/${conversationId}/${myParticipant?.is_pinned ? 'unpin' : 'pin'}`, (response) => {
patchMyParticipantState({
is_pinned: !!response.is_pinned,
pinned_at: response.is_pinned ? new Date().toISOString() : null,
})
})}
className={actionButtonClass(busyAction === 'pin' || busyAction === 'unpin')}
>
<i className="fa-solid fa-thumbtack text-[11px]" />
{myParticipant?.is_pinned ? 'Unpin' : 'Pin'}
</button>
{conversation?.type === 'group' ? (
<button
onClick={() => runConversationAction('leave', `/api/messages/${conversationId}/leave`)}
className="inline-flex items-center gap-2 rounded-full border border-rose-400/18 bg-rose-500/10 px-4 py-2 text-sm font-medium text-rose-200 transition hover:bg-rose-500/16"
>
<i className="fa-solid fa-right-from-bracket text-[11px]" />
Leave
</button>
) : null}
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1fr)_260px]">
<div className="rounded-2xl border border-white/[0.08] bg-black/15 px-3 py-2.5 transition focus-within:border-sky-400/30 focus-within:bg-black/25">
<div className="flex items-center gap-3">
<i className="fa-solid fa-magnifying-glass text-xs text-white/30" />
<input
type="search"
value={threadSearch}
onChange={(e) => setThreadSearch(e.target.value)}
placeholder="Search inside this thread…"
className="w-full bg-transparent text-sm text-white outline-none placeholder:text-white/25"
/>
</div>
</div>
{conversation?.type === 'group' ? (
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="Rename group"
className="min-w-0 flex-1 rounded-2xl border border-white/[0.08] bg-black/15 px-3 py-2.5 text-sm text-white outline-none placeholder:text-white/25 focus:border-sky-400/30 focus:bg-black/25"
/>
<button onClick={handleRename} disabled={busyAction === 'rename'} className={actionButtonClass(busyAction === 'rename')}>
Save
</button>
</div>
) : (
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.03] px-4 py-2.5 text-sm text-white/45">
{participantNames.length} participant{participantNames.length === 1 ? '' : 's'}
</div>
)}
</div>
</header>
{error ? (
<div className="border-b border-rose-500/14 bg-rose-500/8 px-4 py-3 text-sm text-rose-200 sm:px-6">
{error}
</div>
) : null}
<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
onClick={() => loadMessages({ cursor: nextCursor, append: true })}
disabled={loadingMore}
className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-xs font-medium text-white/60 transition hover:bg-white/[0.08] hover:text-white disabled:opacity-50"
>
{loadingMore ? 'Loading older messages…' : 'Load older messages'}
</button>
</div>
) : null}
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] px-5 py-4 text-sm text-white/45">Loading conversation</div>
</div>
) : visibleMessages.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="max-w-md rounded-[28px] border border-white/[0.06] bg-white/[0.03] px-6 py-8 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-[22px] border border-white/[0.08] bg-white/[0.04] text-white/35">
<i className="fa-solid fa-message text-2xl" />
</div>
<h3 className="mt-4 text-lg font-semibold text-white">{threadSearch.trim() ? 'No matching messages' : 'No messages yet'}</h3>
<p className="mt-2 text-sm leading-6 text-white/45">
{threadSearch.trim()
? 'Try a different search term or clear the filter to see the full thread.'
: 'Start the conversation with a message, file, or quick note.'}
</p>
</div>
</div>
) : (
<div className="space-y-1.5 sm:space-y-1">
{messagesWithDecorators.map(({ message, showUnreadMarker, dayLabel }, index) => {
const previous = messagesWithDecorators[index - 1]?.message
const next = messagesWithDecorators[index + 1]?.message
const showAvatar = !previous || previous.sender_id !== message.sender_id
const endsSequence = !next || next.sender_id !== message.sender_id
const seenText = isLastMineMessage(visibleMessages, index, currentUserId)
? buildSeenText(participants, currentUserId, message)
: null
return (
<div key={message.id} className="relative">
{dayLabel ? (
<div className="sticky top-2 z-[1] my-4 flex justify-center first:mt-0">
<span className="rounded-full border border-white/[0.08] bg-[#0b1220]/90 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/45 shadow-[0_10px_30px_rgba(0,0,0,0.22)] backdrop-blur">
{dayLabel}
</span>
</div>
) : null}
{showUnreadMarker ? <UnreadMarker prefersReducedMotion={prefersReducedMotion} /> : null}
<MessageBubble
message={message}
isMine={message.sender_id === currentUserId}
showAvatar={showAvatar}
endsSequence={endsSequence}
isNewlyArrived={!!animatedMessageIds[message.id]}
prefersReducedMotion={prefersReducedMotion}
onReact={handleReact}
onUnreact={handleUnreact}
onEdit={handleEdit}
onDelete={handleDelete}
onOpenImage={setLightbox}
seenText={seenText}
/>
</div>
)
})}
</div>
)}
</div>
<div className="border-t border-white/[0.06] bg-white/[0.02] px-3 py-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:px-6">
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-white/45">
{typingLabel ? <span>{typingLabel}</span> : <span>Markdown supported. Images and files can be sent inline.</span>}
</div>
{files.length > 0 ? <span className="text-xs text-white/30">{files.length}/5 attachments</span> : null}
</div>
{files.length > 0 ? (
<div className="mb-3 flex flex-wrap gap-2">
{files.map((file, index) => (
<span key={`${file.name}-${index}`} className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1.5 text-xs text-white/65">
<i className={`fa-solid ${file.type?.startsWith('image/') ? 'fa-image' : 'fa-paperclip'} text-[10px]`} />
{file.name}
</span>
))}
</div>
) : null}
<form onSubmit={handleSend} className="rounded-[28px] border border-white/[0.08] bg-black/15 p-3 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-3.5">
<textarea
value={body}
onChange={(e) => handleBodyChange(e.target.value)}
placeholder="Write a message…"
rows={3}
maxLength={5000}
className="w-full resize-none bg-transparent px-2 py-2 text-sm leading-6 text-white outline-none placeholder:text-white/25"
/>
<div className="mt-3 flex flex-col gap-3 border-t border-white/[0.06] pt-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
onChange={(e) => handleFiles(e.target.files)}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/65 transition hover:bg-white/[0.08] hover:text-white"
>
<i className="fa-solid fa-paperclip text-[11px]" />
Attach files
</button>
{files.length > 0 ? (
<button
type="button"
onClick={() => {
setFiles([])
if (fileInputRef.current) fileInputRef.current.value = ''
}}
className="inline-flex min-h-11 items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/45 transition hover:bg-white/[0.08] hover:text-white"
>
Clear
</button>
) : null}
</div>
<button
type="submit"
disabled={sending || (!body.trim() && files.length === 0)}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-full bg-sky-500 px-5 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-sky-400 disabled:opacity-50"
>
<i className={`fa-solid ${sending ? 'fa-spinner fa-spin' : 'fa-paper-plane'} text-[11px]`} />
{sending ? 'Sending…' : 'Send message'}
</button>
</div>
</form>
</div>
{lightbox ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#020611e6] p-4 backdrop-blur-md" onClick={() => setLightbox(null)}>
<div className="relative max-h-[90vh] max-w-5xl" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => setLightbox(null)}
className="absolute right-3 top-3 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/15 bg-black/30 text-white/80 transition hover:bg-black/45"
>
<i className="fa-solid fa-xmark" />
</button>
<img src={lightbox.url} alt={lightbox.original_name ?? 'Attachment'} className="max-h-[90vh] rounded-[28px] border border-white/10" />
</div>
</div>
) : null}
</div>
)
}
function actionButtonClass(isBusy) {
return `inline-flex min-h-11 shrink-0 items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/65 transition hover:bg-white/[0.08] hover:text-white ${isBusy ? 'opacity-60' : ''}`
}
function normalizeMessages(messages, currentUserId) {
return messages.map((message) => normalizeMessage(message, currentUserId))
}
function normalizeMessage(message, currentUserId) {
const reactionSummary = message.reaction_summary ?? null
return {
...message,
sender_id: message.sender_id ?? message.sender?.id ?? null,
reactions: reactionSummary ? summaryToReactionArray(reactionSummary, currentUserId) : normalizeReactionArray(message.reactions ?? [], currentUserId),
}
}
function normalizeReactionArray(reactions, currentUserId) {
return reactions.map((reaction) => ({
...reaction,
_iMine: reaction._iMine ?? reaction.user_id === currentUserId,
}))
}
function summaryToReactionArray(summary, currentUserId) {
if (!summary || typeof summary !== 'object') return []
const mine = Array.isArray(summary.me) ? summary.me : []
return Object.entries(summary)
.filter(([emoji]) => emoji !== 'me')
.flatMap(([emoji, count]) => Array.from({ length: Number(count) || 0 }, (_, index) => ({
id: `${emoji}-${index}`,
reaction: emoji,
user_id: mine.includes(emoji) && index === 0 ? currentUserId : null,
_iMine: mine.includes(emoji) && index === 0,
})))
}
function mergeMessageLists(existing, incoming) {
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 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) {
if (!users || users.length === 0) return ''
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.slice(0, 2).join(', ')} and ${names.length - 2} others are typing…`
}
function isLastMineMessage(messages, index, currentUserId) {
const current = messages[index]
if (!current || current.sender_id !== currentUserId) return false
for (let i = index + 1; i < messages.length; i += 1) {
if (messages[i].sender_id === currentUserId) return false
}
return true
}
function buildSeenText(participants, currentUserId, message) {
const seenBy = participants.filter((participant) => participant.user_id !== currentUserId && participantHasReadMessage(participant, message))
if (seenBy.length === 0) return 'Sent'
if (seenBy.length === 1) {
const readAt = seenBy[0]?.last_read_at
return readAt ? `Seen ${formatSeenTime(readAt)}` : 'Seen'
}
return `Seen by ${seenBy.length} people`
}
function decorateMessages(messages, currentUserId, participant) {
let unreadMarked = false
const lastReadMessageId = Number(participant?.last_read_message_id ?? 0)
const lastReadAt = participant?.last_read_at ?? null
return messages.map((message, index) => {
const previous = messages[index - 1]
const currentDay = dayKey(message.created_at)
const previousDay = previous ? dayKey(previous.created_at) : null
const shouldMarkUnread = !unreadMarked
&& message.sender_id !== currentUserId
&& !message.deleted_at
&& (
lastReadMessageId > 0
? Number(message.id) > lastReadMessageId
: lastReadAt
? new Date(message.created_at).getTime() > new Date(lastReadAt).getTime()
: true
)
if (shouldMarkUnread) unreadMarked = true
return {
message,
showUnreadMarker: shouldMarkUnread,
dayLabel: currentDay !== previousDay ? formatDayLabel(message.created_at) : null,
}
})
}
function participantHasReadMessage(participant, message) {
const lastReadMessageId = Number(participant?.last_read_message_id ?? 0)
if (lastReadMessageId > 0) {
return Number(message?.id ?? 0) > 0 && lastReadMessageId >= Number(message.id)
}
if (participant?.last_read_at && message?.created_at) {
return new Date(participant.last_read_at).getTime() >= new Date(message.created_at).getTime()
}
return false
}
function formatSeenTime(iso) {
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) {
return 'moments ago'
}
const diffSeconds = Math.max(0, Math.floor((Date.now() - then) / 1000))
if (diffSeconds < 60) return 'moments ago'
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`
if (diffSeconds < 604800) return `${Math.floor(diffSeconds / 86400)}d ago`
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})
}
function dayKey(iso) {
const date = new Date(iso)
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
}
function formatDayLabel(iso) {
const date = new Date(iso)
const today = new Date()
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate())
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.round((startOfToday.getTime() - startOfDate.getTime()) / 86400000)
if (diffDays === 0) return 'Today'
if (diffDays === 1) return 'Yesterday'
return date.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
})
}
function UnreadMarker({ prefersReducedMotion }) {
const [isVisible, setIsVisible] = useState(prefersReducedMotion)
useEffect(() => {
if (prefersReducedMotion) return undefined
setIsVisible(false)
const frame = window.requestAnimationFrame(() => setIsVisible(true))
return () => window.cancelAnimationFrame(frame)
}, [prefersReducedMotion])
return (
<div
className="my-4 flex items-center gap-3"
style={prefersReducedMotion ? undefined : {
opacity: isVisible ? 1 : 0.45,
transform: isVisible ? 'scale(1)' : 'scale(0.985)',
transition: 'opacity 280ms ease, transform 360ms ease',
}}
>
<span className="h-px flex-1 bg-sky-400/25" />
<span className="rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200">
Unread messages
</span>
<span className="h-px flex-1 bg-sky-400/25" />
</div>
)
}
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)
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const handleChange = () => setPrefersReducedMotion(mediaQuery.matches)
handleChange()
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
return prefersReducedMotion
}