713 lines
26 KiB
JavaScript
713 lines
26 KiB
JavaScript
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|
import MessageBubble from './MessageBubble'
|
|
|
|
/**
|
|
* Right panel: scrollable thread of messages with send form.
|
|
*/
|
|
export default function ConversationThread({
|
|
conversationId,
|
|
conversation,
|
|
realtimeEnabled = false,
|
|
currentUserId,
|
|
currentUsername,
|
|
apiFetch,
|
|
onBack,
|
|
onMarkRead,
|
|
onConversationUpdated,
|
|
}) {
|
|
const [messages, setMessages] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [sending, setSending] = useState(false)
|
|
const [body, setBody] = useState('')
|
|
const [nextCursor, setNextCursor] = useState(null)
|
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
const [error, setError] = useState(null)
|
|
const [attachments, setAttachments] = useState([])
|
|
const [uploadProgress, setUploadProgress] = useState(null)
|
|
const [typingUsers, setTypingUsers] = useState([])
|
|
const [threadSearch, setThreadSearch] = useState('')
|
|
const [threadSearchResults, setThreadSearchResults] = useState([])
|
|
const [lightboxImage, setLightboxImage] = useState(null)
|
|
const fileInputRef = useRef(null)
|
|
const bottomRef = useRef(null)
|
|
const threadRef = useRef(null)
|
|
const pollRef = useRef(null)
|
|
const typingPollRef = useRef(null)
|
|
const typingStopTimerRef = useRef(null)
|
|
const latestIdRef = useRef(null)
|
|
const shouldAutoScrollRef = useRef(true)
|
|
const draftKey = `nova_draft_${conversationId}`
|
|
const previewAttachments = useMemo(() => {
|
|
return attachments.map(file => ({
|
|
file,
|
|
previewUrl: isImageLike(file) ? URL.createObjectURL(file) : null,
|
|
}))
|
|
}, [attachments])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
for (const item of previewAttachments) {
|
|
if (item.previewUrl) {
|
|
URL.revokeObjectURL(item.previewUrl)
|
|
}
|
|
}
|
|
}
|
|
}, [previewAttachments])
|
|
|
|
// ── Initial load ─────────────────────────────────────────────────────────
|
|
const loadMessages = useCallback(async () => {
|
|
try {
|
|
const data = await apiFetch(`/api/messages/${conversationId}`)
|
|
const msgs = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
|
|
setMessages(msgs)
|
|
setNextCursor(data.next_cursor ?? null)
|
|
setLoading(false)
|
|
if (msgs.length) latestIdRef.current = msgs[msgs.length - 1].id
|
|
shouldAutoScrollRef.current = true
|
|
} catch (e) {
|
|
setError(e.message)
|
|
setLoading(false)
|
|
}
|
|
}, [conversationId, currentUserId, apiFetch])
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
setMessages([])
|
|
const storedDraft = window.localStorage.getItem(draftKey)
|
|
setBody(storedDraft ?? '')
|
|
loadMessages()
|
|
|
|
if (!realtimeEnabled) {
|
|
pollRef.current = setInterval(async () => {
|
|
try {
|
|
const data = await apiFetch(`/api/messages/${conversationId}`)
|
|
const latestChunk = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
|
|
if (latestChunk.length && latestChunk[latestChunk.length - 1].id !== latestIdRef.current) {
|
|
shouldAutoScrollRef.current = true
|
|
setMessages(prev => mergeMessageLists(prev, latestChunk))
|
|
latestIdRef.current = latestChunk[latestChunk.length - 1].id
|
|
onConversationUpdated()
|
|
}
|
|
} catch (_) {}
|
|
}, 10_000)
|
|
}
|
|
|
|
return () => {
|
|
if (pollRef.current) clearInterval(pollRef.current)
|
|
}
|
|
}, [conversationId, draftKey, realtimeEnabled, currentUserId, apiFetch, loadMessages, onConversationUpdated])
|
|
|
|
useEffect(() => {
|
|
if (!realtimeEnabled) {
|
|
typingPollRef.current = setInterval(async () => {
|
|
try {
|
|
const data = await apiFetch(`/api/messages/${conversationId}/typing`)
|
|
setTypingUsers(data.typing ?? [])
|
|
} catch (_) {}
|
|
}, 2_000)
|
|
}
|
|
|
|
return () => {
|
|
if (typingPollRef.current) clearInterval(typingPollRef.current)
|
|
clearTimeout(typingStopTimerRef.current)
|
|
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
|
}
|
|
}, [conversationId, apiFetch, realtimeEnabled])
|
|
|
|
useEffect(() => {
|
|
const content = body.trim()
|
|
if (!content) {
|
|
clearTimeout(typingStopTimerRef.current)
|
|
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
|
return
|
|
}
|
|
|
|
apiFetch(`/api/messages/${conversationId}/typing`, { method: 'POST' }).catch(() => {})
|
|
clearTimeout(typingStopTimerRef.current)
|
|
typingStopTimerRef.current = setTimeout(() => {
|
|
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
|
|
}, 2500)
|
|
}, [body, conversationId, apiFetch])
|
|
|
|
useEffect(() => {
|
|
if (body.trim()) {
|
|
window.localStorage.setItem(draftKey, body)
|
|
return
|
|
}
|
|
|
|
window.localStorage.removeItem(draftKey)
|
|
}, [body, draftKey])
|
|
|
|
// ── Scroll to bottom on first load and new messages ───────────────────────
|
|
useEffect(() => {
|
|
if (!loading && shouldAutoScrollRef.current) {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
shouldAutoScrollRef.current = false
|
|
}
|
|
}, [loading, messages.length])
|
|
|
|
// ── Mark as read when thread is viewed ────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!loading) {
|
|
apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' })
|
|
.then(() => onMarkRead(conversationId))
|
|
.catch(() => {})
|
|
}
|
|
}, [loading, conversationId])
|
|
|
|
// ── Load older messages ───────────────────────────────────────────────────
|
|
const loadMore = useCallback(async () => {
|
|
if (!nextCursor || loadingMore) return
|
|
setLoadingMore(true)
|
|
const container = threadRef.current
|
|
const prevHeight = container?.scrollHeight ?? 0
|
|
|
|
try {
|
|
const data = await apiFetch(`/api/messages/${conversationId}?cursor=${nextCursor}`)
|
|
const older = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
|
|
shouldAutoScrollRef.current = false
|
|
setMessages(prev => [...older, ...prev])
|
|
setNextCursor(data.next_cursor ?? null)
|
|
|
|
requestAnimationFrame(() => {
|
|
if (!container) return
|
|
const newHeight = container.scrollHeight
|
|
container.scrollTop = Math.max(0, newHeight - prevHeight + container.scrollTop)
|
|
})
|
|
} catch (_) {}
|
|
setLoadingMore(false)
|
|
}, [nextCursor, loadingMore, apiFetch, conversationId, currentUserId])
|
|
|
|
const handleThreadScroll = useCallback((e) => {
|
|
if (e.currentTarget.scrollTop < 120) {
|
|
loadMore()
|
|
}
|
|
}, [loadMore])
|
|
|
|
// ── Send message ──────────────────────────────────────────────────────────
|
|
const handleSend = useCallback(async (e) => {
|
|
e.preventDefault()
|
|
const text = body.trim()
|
|
if ((!text && attachments.length === 0) || sending) return
|
|
|
|
setSending(true)
|
|
const optimistic = {
|
|
id: `opt-${Date.now()}`,
|
|
sender_id: currentUserId,
|
|
sender: { id: currentUserId, username: currentUsername },
|
|
body: text,
|
|
created_at: new Date().toISOString(),
|
|
_optimistic: true,
|
|
attachments: attachments.map((file, index) => ({
|
|
id: `tmp-${Date.now()}-${index}`,
|
|
type: file.type.startsWith('image/') ? 'image' : 'file',
|
|
original_name: file.name,
|
|
})),
|
|
}
|
|
setMessages(prev => [...prev, optimistic])
|
|
setBody('')
|
|
window.localStorage.removeItem(draftKey)
|
|
shouldAutoScrollRef.current = true
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('body', text)
|
|
attachments.forEach(file => formData.append('attachments[]', file))
|
|
setUploadProgress(0)
|
|
|
|
const msg = await sendMessageWithProgress(`/api/messages/${conversationId}`, formData, (progress) => {
|
|
setUploadProgress(progress)
|
|
})
|
|
setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m))
|
|
latestIdRef.current = msg.id
|
|
onConversationUpdated()
|
|
setAttachments([])
|
|
} catch (e) {
|
|
setMessages(prev => prev.filter(m => m.id !== optimistic.id))
|
|
setError(e.message)
|
|
} finally {
|
|
setUploadProgress(null)
|
|
setSending(false)
|
|
}
|
|
}, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey])
|
|
|
|
const handleKeyDown = useCallback((e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend(e)
|
|
}
|
|
}, [handleSend])
|
|
|
|
// ── Reaction ──────────────────────────────────────────────────────────────
|
|
const handleReact = useCallback(async (messageId, emoji) => {
|
|
try {
|
|
await apiFetch(`/api/messages/${messageId}/reactions`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reaction: emoji }),
|
|
})
|
|
// Optimistically add reaction with _iMine flag
|
|
setMessages(prev => prev.map(m => {
|
|
if (m.id !== messageId) return m
|
|
const existing = (m.reactions ?? []).some(r => r._iMine && r.reaction === emoji)
|
|
if (existing) return m
|
|
return { ...m, reactions: [...(m.reactions ?? []), { reaction: emoji, user_id: currentUserId, _iMine: true }] }
|
|
}))
|
|
} catch (_) {}
|
|
}, [currentUserId, apiFetch])
|
|
|
|
const handleUnreact = useCallback(async (messageId, emoji) => {
|
|
try {
|
|
await apiFetch(`/api/messages/${messageId}/reactions`, {
|
|
method: 'DELETE',
|
|
body: JSON.stringify({ reaction: emoji }),
|
|
})
|
|
// Optimistically remove reaction
|
|
setMessages(prev => prev.map(m => {
|
|
if (m.id !== messageId) return m
|
|
return { ...m, reactions: (m.reactions ?? []).filter(r => !(r._iMine && r.reaction === emoji)) }
|
|
}))
|
|
} catch (_) {}
|
|
}, [apiFetch])
|
|
|
|
const handleEdit = useCallback(async (messageId, newBody) => {
|
|
const updated = await apiFetch(`/api/messages/message/${messageId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ body: newBody }),
|
|
})
|
|
setMessages(prev => prev.map(m => m.id === messageId ? { ...m, body: updated.body, edited_at: updated.edited_at } : m))
|
|
}, [apiFetch])
|
|
|
|
const handleReportMessage = useCallback(async (messageId) => {
|
|
try {
|
|
await apiFetch('/api/reports', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
target_type: 'message',
|
|
target_id: messageId,
|
|
reason: 'inappropriate',
|
|
details: '',
|
|
}),
|
|
})
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}, [apiFetch])
|
|
|
|
const handlePickAttachments = useCallback((e) => {
|
|
const next = Array.from(e.target.files ?? [])
|
|
if (!next.length) return
|
|
setAttachments(prev => [...prev, ...next].slice(0, 5))
|
|
e.target.value = ''
|
|
}, [])
|
|
|
|
const removeAttachment = useCallback((index) => {
|
|
setAttachments(prev => prev.filter((_, i) => i !== index))
|
|
}, [])
|
|
|
|
const togglePin = useCallback(async () => {
|
|
const me = conversation?.all_participants?.find(p => p.user_id === currentUserId)
|
|
const isPinned = !!me?.is_pinned
|
|
const endpoint = isPinned ? 'unpin' : 'pin'
|
|
try {
|
|
await apiFetch(`/api/messages/${conversationId}/${endpoint}`, { method: 'POST' })
|
|
onConversationUpdated()
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}, [conversation, currentUserId, apiFetch, conversationId, onConversationUpdated])
|
|
|
|
const toggleMute = useCallback(async () => {
|
|
try {
|
|
await apiFetch(`/api/messages/${conversationId}/mute`, { method: 'POST' })
|
|
onConversationUpdated()
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}, [apiFetch, conversationId, onConversationUpdated])
|
|
|
|
const toggleArchive = useCallback(async () => {
|
|
try {
|
|
await apiFetch(`/api/messages/${conversationId}/archive`, { method: 'POST' })
|
|
onConversationUpdated()
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}, [apiFetch, conversationId, onConversationUpdated])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const q = threadSearch.trim()
|
|
if (q.length < 2) {
|
|
setThreadSearchResults([])
|
|
return
|
|
}
|
|
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}&conversation_id=${conversationId}`)
|
|
if (!cancelled) {
|
|
setThreadSearchResults(data.data ?? [])
|
|
}
|
|
} catch (_) {
|
|
if (!cancelled) {
|
|
setThreadSearchResults([])
|
|
}
|
|
}
|
|
}, 250)
|
|
|
|
return () => {
|
|
cancelled = true
|
|
clearTimeout(timer)
|
|
}
|
|
}, [threadSearch, conversationId, apiFetch])
|
|
|
|
const jumpToMessage = useCallback((messageId) => {
|
|
const target = document.getElementById(`message-${messageId}`)
|
|
if (target) {
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
}
|
|
}, [])
|
|
|
|
// ── Thread header label ───────────────────────────────────────────────────
|
|
const threadLabel = conversation?.type === 'group'
|
|
? (conversation?.title ?? 'Group conversation')
|
|
: (conversation?.all_participants?.find(p => p.user_id !== currentUserId)?.user?.username ?? 'Direct message')
|
|
const myParticipant = conversation?.my_participant ?? conversation?.all_participants?.find(p => p.user_id === currentUserId)
|
|
const otherParticipant = conversation?.all_participants?.find(p => p.user_id !== currentUserId)
|
|
const otherLastReadAt = otherParticipant?.last_read_at ?? null
|
|
const lastMessageId = messages[messages.length - 1]?.id ?? null
|
|
|
|
// ── Group date separators from messages ──────────────────────────────────
|
|
const grouped = groupByDate(messages)
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
<button
|
|
onClick={onBack}
|
|
className="sm:hidden p-1 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
<div>
|
|
<p className="font-medium text-sm text-gray-900 dark:text-gray-100">{threadLabel}</p>
|
|
{conversation?.type === 'group' && (
|
|
<p className="text-xs text-gray-400">
|
|
{conversation.all_participants?.filter(p => !p.left_at).length ?? 0} members
|
|
</p>
|
|
)}
|
|
{typingUsers.length > 0 && (
|
|
<p className="text-xs text-blue-400">{typingUsers.map(u => u.username).join(', ')} typing…</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={togglePin}
|
|
className="ml-auto text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
>
|
|
{myParticipant?.is_pinned ? 'Unpin' : 'Pin'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={toggleMute}
|
|
className="text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
>
|
|
{myParticipant?.is_muted ? 'Unmute' : 'Mute'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={toggleArchive}
|
|
className="text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
>
|
|
{myParticipant?.is_archived ? 'Unarchive' : 'Archive'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
|
<input
|
|
type="search"
|
|
value={threadSearch}
|
|
onChange={e => setThreadSearch(e.target.value)}
|
|
placeholder="Search in this conversation…"
|
|
className="w-full rounded-lg bg-gray-100 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
{threadSearch.trim().length >= 2 && (
|
|
<div className="mt-2 max-h-28 overflow-y-auto rounded border border-gray-200 dark:border-gray-700">
|
|
{threadSearchResults.length === 0 && (
|
|
<p className="px-2 py-1 text-xs text-gray-400">No matches</p>
|
|
)}
|
|
{threadSearchResults.map(item => (
|
|
<button
|
|
key={`thread-search-${item.id}`}
|
|
onClick={() => jumpToMessage(item.id)}
|
|
className="w-full text-left px-2 py-1 text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
>
|
|
<span className="text-gray-500">@{item.sender?.username ?? 'unknown'}: </span>
|
|
<span className="text-gray-800 dark:text-gray-200">{item.body || '(attachment)'}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div ref={threadRef} onScroll={handleThreadScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
|
|
{loadingMore && (
|
|
<div className="text-center py-2 text-xs text-gray-400">Loading older messages…</div>
|
|
)}
|
|
|
|
{loading && (
|
|
<div className="text-center text-sm text-gray-400 py-12">Loading messages…</div>
|
|
)}
|
|
|
|
{!loading && messages.length === 0 && (
|
|
<div className="text-center text-sm text-gray-400 py-12">
|
|
No messages yet. Say hello!
|
|
</div>
|
|
)}
|
|
|
|
{grouped.map(({ date, messages: dayMessages }) => (
|
|
<React.Fragment key={date}>
|
|
<div className="flex items-center gap-3 my-4">
|
|
<hr className="flex-1 border-gray-200 dark:border-gray-700" />
|
|
<span className="text-xs text-gray-400 flex-shrink-0">{date}</span>
|
|
<hr className="flex-1 border-gray-200 dark:border-gray-700" />
|
|
</div>
|
|
{dayMessages.map((msg, idx) => (
|
|
<div key={msg.id} id={`message-${msg.id}`}>
|
|
<MessageBubble
|
|
message={msg}
|
|
isMine={msg.sender_id === currentUserId}
|
|
showAvatar={idx === 0 || dayMessages[idx - 1]?.sender_id !== msg.sender_id}
|
|
onReact={handleReact}
|
|
onUnreact={handleUnreact}
|
|
onEdit={handleEdit}
|
|
onReport={handleReportMessage}
|
|
onOpenImage={setLightboxImage}
|
|
seenText={buildSeenText({
|
|
message: msg,
|
|
isMine: msg.sender_id === currentUserId,
|
|
isDirect: conversation?.type === 'direct',
|
|
isLastMessage: msg.id === lastMessageId,
|
|
otherLastReadAt,
|
|
})}
|
|
/>
|
|
</div>
|
|
))}
|
|
</React.Fragment>
|
|
))}
|
|
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="mx-4 mb-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/30 rounded-lg">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Compose */}
|
|
<form onSubmit={handleSend} className="flex gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handlePickAttachments}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="flex-shrink-0 rounded-xl border border-gray-200 dark:border-gray-700 px-3 py-2 text-sm text-gray-600 dark:text-gray-300"
|
|
title="Attach files"
|
|
>
|
|
📎
|
|
</button>
|
|
<textarea
|
|
value={body}
|
|
onChange={e => setBody(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Write a message… (Enter to send, Shift+Enter for new line)"
|
|
rows={1}
|
|
maxLength={5000}
|
|
className="flex-1 resize-none rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto"
|
|
style={{ minHeight: '2.5rem' }}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={(!body.trim() && attachments.length === 0) || sending}
|
|
className="flex-shrink-0 rounded-xl bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white px-4 py-2 text-sm font-medium transition-colors"
|
|
>
|
|
Send
|
|
</button>
|
|
</form>
|
|
|
|
{attachments.length > 0 && (
|
|
<div className="px-4 pb-3 flex flex-wrap gap-2">
|
|
{previewAttachments.map(({ file, previewUrl }, idx) => (
|
|
<div key={`${file.name}-${idx}`} className="inline-flex items-center gap-2 rounded-lg bg-gray-100 dark:bg-gray-800 px-2 py-1 text-xs text-gray-700 dark:text-gray-300">
|
|
{previewUrl && (
|
|
<img
|
|
src={previewUrl}
|
|
alt={file.name}
|
|
className="h-10 w-10 object-cover rounded"
|
|
/>
|
|
)}
|
|
<span className="truncate max-w-[220px]">{file.name}</span>
|
|
<button type="button" onClick={() => removeAttachment(idx)} className="text-red-500">✕</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{sending && uploadProgress !== null && (
|
|
<div className="px-4 pb-3">
|
|
<div className="h-2 rounded bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
|
<div className="h-full bg-blue-500" style={{ width: `${uploadProgress}%` }} />
|
|
</div>
|
|
<p className="mt-1 text-[11px] text-gray-500">Uploading {uploadProgress}%</p>
|
|
</div>
|
|
)}
|
|
|
|
{lightboxImage && (
|
|
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-6" onClick={() => setLightboxImage(null)}>
|
|
<img
|
|
src={lightboxImage.url}
|
|
alt={lightboxImage.original_name || 'Attachment'}
|
|
className="max-h-full max-w-full rounded-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function tagReactions(msg, currentUserId) {
|
|
if (!msg.reactions?.length) return msg
|
|
return {
|
|
...msg,
|
|
reactions: msg.reactions.map(r => ({ ...r, _iMine: r.user_id === currentUserId })),
|
|
}
|
|
}
|
|
|
|
function groupByDate(messages) {
|
|
const map = new Map()
|
|
for (const msg of messages) {
|
|
const date = formatDate(msg.created_at)
|
|
if (!map.has(date)) map.set(date, [])
|
|
map.get(date).push(msg)
|
|
}
|
|
return Array.from(map.entries()).map(([date, messages]) => ({ date, messages }))
|
|
}
|
|
|
|
function mergeMessageLists(existing, incoming) {
|
|
const byId = new Map()
|
|
|
|
for (const msg of existing) {
|
|
byId.set(String(msg.id), msg)
|
|
}
|
|
|
|
for (const msg of incoming) {
|
|
byId.set(String(msg.id), msg)
|
|
}
|
|
|
|
return Array.from(byId.values()).sort((a, b) => {
|
|
const at = new Date(a.created_at).getTime()
|
|
const bt = new Date(b.created_at).getTime()
|
|
if (at !== bt) return at - bt
|
|
const aid = Number(a.id)
|
|
const bid = Number(b.id)
|
|
if (!Number.isNaN(aid) && !Number.isNaN(bid)) {
|
|
return aid - bid
|
|
}
|
|
return String(a.id).localeCompare(String(b.id))
|
|
})
|
|
}
|
|
|
|
function buildSeenText({ message, isMine, isDirect, isLastMessage, otherLastReadAt }) {
|
|
if (!isDirect || !isMine || !isLastMessage || !otherLastReadAt || !message?.created_at) {
|
|
return null
|
|
}
|
|
|
|
const seenAt = new Date(otherLastReadAt)
|
|
const sentAt = new Date(message.created_at)
|
|
|
|
if (Number.isNaN(seenAt.getTime()) || Number.isNaN(sentAt.getTime()) || seenAt < sentAt) {
|
|
return null
|
|
}
|
|
|
|
return `Seen ${relativeTimeFromNow(otherLastReadAt)} ago`
|
|
}
|
|
|
|
function relativeTimeFromNow(iso) {
|
|
const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
|
|
if (seconds < 60) return `${seconds}s`
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
|
|
return `${Math.floor(seconds / 86400)}d`
|
|
}
|
|
|
|
function formatDate(iso) {
|
|
const d = new Date(iso)
|
|
const today = new Date()
|
|
const yesterday = new Date(today)
|
|
yesterday.setDate(today.getDate() - 1)
|
|
|
|
if (isSameDay(d, today)) return 'Today'
|
|
if (isSameDay(d, yesterday)) return 'Yesterday'
|
|
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
function isSameDay(a, b) {
|
|
return a.getFullYear() === b.getFullYear() &&
|
|
a.getMonth() === b.getMonth() &&
|
|
a.getDate() === b.getDate()
|
|
}
|
|
|
|
function isImageLike(file) {
|
|
return file?.type?.startsWith('image/')
|
|
}
|
|
|
|
function getCsrf() {
|
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
|
|
}
|
|
|
|
function sendMessageWithProgress(url, formData, onProgress) {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest()
|
|
xhr.open('POST', url)
|
|
xhr.setRequestHeader('X-CSRF-TOKEN', getCsrf())
|
|
xhr.setRequestHeader('Accept', 'application/json')
|
|
|
|
xhr.upload.onprogress = (event) => {
|
|
if (!event.lengthComputable) return
|
|
const progress = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)))
|
|
onProgress(progress)
|
|
}
|
|
|
|
xhr.onload = () => {
|
|
try {
|
|
const json = JSON.parse(xhr.responseText || '{}')
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
resolve(json)
|
|
return
|
|
}
|
|
reject(new Error(json.message || `HTTP ${xhr.status}`))
|
|
} catch (_) {
|
|
reject(new Error(`HTTP ${xhr.status}`))
|
|
}
|
|
}
|
|
|
|
xhr.onerror = () => reject(new Error('Network error'))
|
|
xhr.send(formData)
|
|
})
|
|
}
|