1216 lines
46 KiB
JavaScript
1216 lines
46 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,
|
|
}) {
|
|
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 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('')
|
|
setFiles([])
|
|
loadMessages()
|
|
if (!realtimeEnabled) {
|
|
loadTyping()
|
|
}
|
|
}, [conversationId, loadMessages, loadTyping, 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 (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
|
|
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)
|
|
|
|
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])
|
|
|
|
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('')
|
|
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,
|
|
})
|
|
|
|
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)
|
|
setFiles(files)
|
|
setError(err.message)
|
|
} finally {
|
|
setSending(false)
|
|
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
|
}
|
|
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, 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) {
|
|
return new Date(iso).toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|