777 lines
32 KiB
JavaScript
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
|
|
}
|