Files
SkinbaseNova/resources/js/components/messaging/ConversationThread.jsx

777 lines
32 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import MessageBubble from './MessageBubble'
export default function ConversationThread({
conversationId,
conversation,
realtimeEnabled,
currentUserId,
currentUsername,
apiFetch,
onBack,
onMarkRead,
onConversationUpdated,
}) {
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 [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 lastStartRef = useRef(0)
const initialLoadRef = useRef(true)
const knownMessageIdsRef = useRef(new Set())
const animatedMessageIdsRef = useRef(new Set())
const [animatedMessageIds, setAnimatedMessageIds] = useState({})
const prefersReducedMotion = usePrefersReducedMotion()
const myParticipant = useMemo(() => (
conversation?.my_participant
?? conversation?.all_participants?.find((participant) => participant.user_id === currentUserId)
?? null
), [conversation, currentUserId])
const participants = useMemo(() => conversation?.all_participants ?? [], [conversation])
const participantNames = useMemo(() => (
participants
.map((participant) => participant.user?.username)
.filter(Boolean)
), [participants])
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 loadMessages = useCallback(async ({ cursor = null, append = false, silent = false } = {}) => {
if (append) setLoadingMore(true)
else if (!silent) setLoading(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])
useEffect(() => {
initialLoadRef.current = true
setMessages([])
setNextCursor(null)
setBody('')
setFiles([])
setDraftTitle(conversation?.title ?? '')
loadMessages()
loadTyping()
}, [conversation?.title, conversationId, loadMessages, loadTyping])
useEffect(() => {
apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' })
.then(() => onMarkRead?.(conversationId))
.catch(() => {})
}, [apiFetch, conversationId, onMarkRead])
useEffect(() => {
const timer = window.setInterval(() => {
loadTyping()
if (!realtimeEnabled) {
loadMessages({ silent: true })
}
}, realtimeEnabled ? 5000 : 8000)
return () => window.clearInterval(timer)
}, [loadMessages, loadTyping, realtimeEnabled])
useEffect(() => () => {
if (typingRef.current) window.clearTimeout(typingRef.current)
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
}, [])
useEffect(() => {
if (!listRef.current) return
if (initialLoadRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight
initialLoadRef.current = false
return
}
const nearBottom = listRef.current.scrollHeight - listRef.current.scrollTop - listRef.current.clientHeight < 180
if (nearBottom) {
listRef.current.scrollTop = listRef.current.scrollHeight
}
}, [messages.length])
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)
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 optimisticMessage = normalizeMessage({
id: optimisticId,
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)
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)
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) => prev.map((message) => message.id === optimisticId ? normalized : message))
onConversationUpdated?.()
} catch (err) {
setMessages((prev) => prev.filter((message) => message.id !== optimisticId))
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, onConversationUpdated, 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
)))
onConversationUpdated?.()
}, [apiFetch, currentUserId, onConversationUpdated])
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
)))
onConversationUpdated?.()
}, [apiFetch, onConversationUpdated])
const runConversationAction = useCallback(async (action, url, apply) => {
setBusyAction(action)
setError(null)
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])
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 }),
})
onConversationUpdated?.()
} catch (e) {
setError(e.message)
} finally {
setBusyAction(null)
}
}, [apiFetch, conversation?.title, conversationId, draftTitle, onConversationUpdated])
const visibleMessages = filteredMessages
const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at])
const typingLabel = buildTypingLabel(typingUsers)
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>}
{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>
</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
})}
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) => {
if (conversation?.my_participant) conversation.my_participant.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) => {
if (conversation?.my_participant) conversation.my_participant.is_pinned = !!response.is_pinned
})}
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} className="flex-1 overflow-y-auto px-3 py-4 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)
: 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 map = new Map()
for (const message of [...existing, ...incoming]) {
map.set(message.id, message)
}
return Array.from(map.values()).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
}
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) {
const seenBy = participants
.filter((participant) => participant.user_id !== currentUserId && participant.last_read_at)
.map((participant) => participant.user?.username)
.filter(Boolean)
if (seenBy.length === 0) return 'Sent'
if (seenBy.length === 1) return `Seen by @${seenBy[0]}`
return `Seen by ${seenBy.length} people`
}
function decorateMessages(messages, currentUserId, lastReadAt) {
let unreadMarked = false
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
&& !!lastReadAt
&& message.sender_id !== currentUserId
&& !message.deleted_at
&& new Date(message.created_at).getTime() > new Date(lastReadAt).getTime()
if (shouldMarkUnread) unreadMarked = true
return {
message,
showUnreadMarker: shouldMarkUnread,
dayLabel: currentDay !== previousDay ? formatDayLabel(message.created_at) : null,
}
})
}
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 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
}