optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -104,7 +104,7 @@ function ConversationRow({ conv, isActive, currentUserId, onlineUserIds, typingU
<div className="mt-3 rounded-2xl border border-white/[0.04] bg-black/10 px-3 py-2.5">
{typingUsers.length === 0 && senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null}
<p className={`mt-1 flex items-center gap-2 truncate text-sm ${typingUsers.length > 0 ? 'text-emerald-200' : 'text-white/62'}`}>
<p data-testid={`conversation-preview-${conv.id}`} className={`mt-1 flex items-center gap-2 truncate text-sm ${typingUsers.length > 0 ? 'text-emerald-200' : 'text-white/62'}`}>
{typingUsers.length > 0 ? <SidebarTypingIcon /> : null}
<span className="truncate">{preview}</span>
</p>

View File

@@ -14,6 +14,7 @@ export default function ConversationThread({
onBack,
onMarkRead,
onConversationPatched,
onUnreadTotalPatched,
}) {
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
@@ -47,6 +48,35 @@ export default function ConversationThread({
const animatedMessageIdsRef = useRef(new Set())
const [animatedMessageIds, setAnimatedMessageIds] = useState({})
const prefersReducedMotion = usePrefersReducedMotion()
const draftStorageKey = useMemo(() => (conversationId ? `nova_draft_${conversationId}` : null), [conversationId])
const readDraftFromStorage = useCallback(() => {
if (!draftStorageKey || typeof window === 'undefined') {
return ''
}
try {
return window.localStorage.getItem(draftStorageKey) ?? ''
} catch {
return ''
}
}, [draftStorageKey])
const persistDraftToStorage = useCallback((value) => {
if (!draftStorageKey || typeof window === 'undefined') {
return
}
try {
if (value.trim() === '') {
window.localStorage.removeItem(draftStorageKey)
} else {
window.localStorage.setItem(draftStorageKey, value)
}
} catch {
// no-op
}
}, [draftStorageKey])
const myParticipant = useMemo(() => (
conversation?.my_participant
@@ -221,13 +251,13 @@ export default function ConversationThread({
setPresenceUsers([])
setTypingUsers([])
setNextCursor(null)
setBody('')
setBody(readDraftFromStorage())
setFiles([])
loadMessages()
if (!realtimeEnabled) {
loadTyping()
}
}, [conversationId, loadMessages, loadTyping, realtimeEnabled])
}, [conversationId, loadMessages, loadTyping, readDraftFromStorage, realtimeEnabled])
useEffect(() => {
setParticipantState(conversation?.all_participants ?? [])
@@ -319,8 +349,28 @@ export default function ConversationThread({
try {
const data = await apiFetch(`/api/messages/${conversationId}/delta?after_message_id=${encodeURIComponent(lastServerMessage.id)}`)
const incoming = normalizeMessages(data.data ?? [], currentUserId)
if (data?.conversation?.id) {
patchConversation(data.conversation)
}
if (Number.isFinite(Number(data?.summary?.unread_total))) {
onUnreadTotalPatched?.(data.summary.unread_total)
}
if (incoming.length > 0) {
setMessages((prev) => mergeMessageLists(prev, incoming))
const latestIncoming = incoming[incoming.length - 1] ?? null
const latestRemoteIncoming = [...incoming].reverse().find((message) => message.sender_id !== currentUserId) ?? null
if (latestIncoming) {
patchLastMessage(latestIncoming)
}
if (latestRemoteIncoming && document.visibilityState === 'visible') {
queueReadReceipt(latestRemoteIncoming.id)
}
}
} catch {
// no-op
@@ -423,7 +473,7 @@ export default function ConversationThread({
typingExpiryTimersRef.current.clear()
echo.leave(`conversation.${conversationId}`)
}
}, [apiFetch, conversationId, currentUserId, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled])
}, [apiFetch, conversationId, currentUserId, onUnreadTotalPatched, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled])
useEffect(() => {
const known = knownMessageIdsRef.current
@@ -464,6 +514,7 @@ export default function ConversationThread({
const handleBodyChange = useCallback((value) => {
setBody(value)
persistDraftToStorage(value)
if (value.trim() === '') {
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
@@ -481,7 +532,7 @@ export default function ConversationThread({
stopTypingRef.current = window.setTimeout(() => {
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
}, 1200)
}, [apiFetch, conversationId])
}, [apiFetch, conversationId, persistDraftToStorage])
const handleFiles = useCallback((selectedFiles) => {
const nextFiles = Array.from(selectedFiles || []).slice(0, 5)
@@ -517,6 +568,7 @@ export default function ConversationThread({
shouldStickToBottomRef.current = true
setMessages((prev) => mergeMessageLists(prev, [optimisticMessage]))
setBody('')
persistDraftToStorage('')
setFiles([])
if (fileInputRef.current) fileInputRef.current.value = ''
setSending(true)
@@ -533,19 +585,28 @@ export default function ConversationThread({
body: formData,
})
if (draftStorageKey && typeof window !== 'undefined') {
try {
window.localStorage.removeItem(draftStorageKey)
} catch {
// no-op
}
}
const normalized = normalizeMessage(created, currentUserId)
setMessages((prev) => mergeMessageLists(prev, [normalized]))
patchLastMessage(normalized, { unread_count: 0 })
} catch (err) {
setMessages((prev) => prev.filter((message) => !messagesMatch(message, { id: optimisticId, client_temp_id: clientTempId })))
setBody(trimmed)
persistDraftToStorage(trimmed)
setFiles(files)
setError(err.message)
} finally {
setSending(false)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
}
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, sending])
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, persistDraftToStorage, sending])
const updateReactions = useCallback((messageId, summary) => {
setMessages((prev) => prev.map((message) => {
@@ -1104,9 +1165,20 @@ function participantHasReadMessage(participant, message) {
}
function formatSeenTime(iso) {
return new Date(iso).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) {
return 'moments ago'
}
const diffSeconds = Math.max(0, Math.floor((Date.now() - then) / 1000))
if (diffSeconds < 60) return 'moments ago'
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`
if (diffSeconds < 604800) return `${Math.floor(diffSeconds / 86400)}d ago`
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})
}

View File

@@ -64,6 +64,7 @@ export default function MessageBubble({ message, isMine, showAvatar, endsSequenc
return (
<div
id={message?.id ? `message-${message.id}` : undefined}
className={`group flex items-end gap-2.5 sm:gap-3 ${isMine ? 'flex-row-reverse' : 'flex-row'} ${showAvatar ? 'mt-4' : 'mt-1'}`}
style={prefersReducedMotion ? undefined : {
opacity: isArrivalVisible ? 1 : 0.55,