storing analytics data

This commit is contained in:
2026-02-27 09:46:51 +01:00
parent 15b7b77d20
commit f0cca76eb3
57 changed files with 3478 additions and 466 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import MessageBubble from './MessageBubble'
/**
@@ -7,6 +7,7 @@ import MessageBubble from './MessageBubble'
export default function ConversationThread({
conversationId,
conversation,
realtimeEnabled = false,
currentUserId,
currentUsername,
apiFetch,
@@ -22,9 +23,11 @@ export default function ConversationThread({
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)
@@ -34,6 +37,22 @@ export default function ConversationThread({
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 () => {
@@ -58,37 +77,42 @@ export default function ConversationThread({
setBody(storedDraft ?? '')
loadMessages()
// Phase 1 polling: check new messages every 10 seconds
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 () => clearInterval(pollRef.current)
}, [conversationId, draftKey])
useEffect(() => {
typingPollRef.current = setInterval(async () => {
try {
const data = await apiFetch(`/api/messages/${conversationId}/typing`)
setTypingUsers(data.typing ?? [])
} catch (_) {}
}, 2_000)
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 () => {
clearInterval(typingPollRef.current)
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])
}, [conversationId, apiFetch, realtimeEnabled])
useEffect(() => {
const content = body.trim()
@@ -190,10 +214,10 @@ export default function ConversationThread({
const formData = new FormData()
formData.append('body', text)
attachments.forEach(file => formData.append('attachments[]', file))
setUploadProgress(0)
const msg = await apiFetch(`/api/messages/${conversationId}`, {
method: 'POST',
body: formData,
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
@@ -203,6 +227,7 @@ export default function ConversationThread({
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])
@@ -292,6 +317,24 @@ export default function ConversationThread({
}
}, [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()
@@ -330,6 +373,7 @@ export default function ConversationThread({
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
@@ -365,7 +409,21 @@ export default function ConversationThread({
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"
>
{conversation?.my_participant?.is_pinned ? 'Unpin' : 'Pin'}
{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>
@@ -429,6 +487,7 @@ export default function ConversationThread({
onUnreact={handleUnreact}
onEdit={handleEdit}
onReport={handleReportMessage}
onOpenImage={setLightboxImage}
seenText={buildSeenText({
message: msg,
isMine: msg.sender_id === currentUserId,
@@ -490,14 +549,41 @@ export default function ConversationThread({
{attachments.length > 0 && (
<div className="px-4 pb-3 flex flex-wrap gap-2">
{attachments.map((file, idx) => (
{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>
)
}
@@ -585,3 +671,42 @@ function isSameDay(a, b) {
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)
})
}