optimizations
This commit is contained in:
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user