Frontend optimization part 2

This commit is contained in:
Jack Kingsman
2026-03-16 17:12:01 -07:00
parent a5d9632a67
commit 0e4828bf72
19 changed files with 261 additions and 219 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
export { useUnreadCounts } from './useUnreadCounts';
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
export { useConversationMessages } from './useConversationMessages';
export { useRadioControl } from './useRadioControl';
export { useRepeaterDashboard } from './useRepeaterDashboard';
export { useAppShell } from './useAppShell';
+6 -6
View File
@@ -10,7 +10,7 @@ interface UseConversationActionsArgs {
activeConversationRef: MutableRefObject<Conversation | null>;
setContacts: React.Dispatch<React.SetStateAction<Contact[]>>;
setChannels: React.Dispatch<React.SetStateAction<Channel[]>>;
addMessageIfNew: (msg: Message) => boolean;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
messageInputRef: RefObject<MessageInputHandle | null>;
}
@@ -31,7 +31,7 @@ export function useConversationActions({
activeConversationRef,
setContacts,
setChannels,
addMessageIfNew,
observeMessage,
messageInputRef,
}: UseConversationActionsArgs): UseConversationActionsResult {
const mergeChannelIntoList = useCallback(
@@ -60,10 +60,10 @@ export function useConversationActions({
: await api.sendDirectMessage(activeConversation.id, text);
if (activeConversationRef.current?.id === conversationId) {
addMessageIfNew(sent);
observeMessage(sent);
}
},
[activeConversation, activeConversationRef, addMessageIfNew]
[activeConversation, activeConversationRef, observeMessage]
);
const handleResendChannelMessage = useCallback(
@@ -77,7 +77,7 @@ export function useConversationActions({
activeConversationRef.current?.type === 'channel' &&
activeConversationRef.current.id === resentMessage.conversation_key
) {
addMessageIfNew(resentMessage);
observeMessage(resentMessage);
}
toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent');
} catch (err) {
@@ -86,7 +86,7 @@ export function useConversationActions({
});
}
},
[activeConversationRef, addMessageIfNew]
[activeConversationRef, observeMessage]
);
const handleSetChannelFloodScopeOverride = useCallback(
+13 -52
View File
@@ -1,16 +1,9 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from '../components/ui/sonner';
import { api, isAbortError } from '../api';
import * as messageCache from '../messageCache';
import type { Conversation, Message, MessagePath } from '../types';
import { getMessageContentKey } from '../utils/messageIdentity';
const MAX_PENDING_ACKS = 500;
const MESSAGE_PAGE_SIZE = 200;
@@ -56,15 +49,6 @@ export function mergePendingAck(
return existing;
}
// Generate a key for deduplicating messages by content
export function getMessageContentKey(msg: Message): string {
// When sender_timestamp exists, dedup by content (catches radio-path duplicates with different IDs).
// When null, include msg.id so each message gets a unique key — avoids silently dropping
// different messages that share the same text and received_at second.
const ts = msg.sender_timestamp ?? `r${msg.received_at}-${msg.id}`;
return `${msg.type}-${msg.conversation_key}-${msg.text}-${ts}`;
}
interface UseConversationMessagesResult {
messages: Message[];
messagesLoading: boolean;
@@ -72,16 +56,11 @@ interface UseConversationMessagesResult {
hasOlderMessages: boolean;
hasNewerMessages: boolean;
loadingNewer: boolean;
hasNewerMessagesRef: MutableRefObject<boolean>;
setMessages: Dispatch<SetStateAction<Message[]>>;
fetchOlderMessages: () => Promise<void>;
fetchNewerMessages: () => Promise<void>;
jumpToBottom: () => void;
reloadCurrentConversation: () => void;
addMessageIfNew: (msg: Message) => boolean;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
triggerReconcile: () => void;
receiveRealtimeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
reconcileOnReconnect: () => void;
renameConversationMessages: (oldId: string, newId: string) => void;
@@ -469,14 +448,6 @@ export function useConversationMessages(
setReloadVersion((current) => current + 1);
}, [activeConversation]);
const triggerReconcile = useCallback(() => {
if (!isMessageConversation(activeConversation)) return;
const controller = new AbortController();
const requestId = latestReconcileRequestIdRef.current + 1;
latestReconcileRequestIdRef.current = requestId;
reconcileFromBackend(activeConversation, controller.signal, requestId);
}, [activeConversation, reconcileFromBackend]);
const reconcileOnReconnect = useCallback(() => {
if (!isMessageConversation(activeConversation)) {
return;
@@ -537,7 +508,6 @@ export function useConversationMessages(
) {
messageCache.set(prevId, {
messages: messagesRef.current,
seenContent: new Set(seenMessageContent.current),
hasOlderMessages: hasOlderMessagesRef.current,
});
}
@@ -582,7 +552,9 @@ export function useConversationMessages(
const cached = messageCache.get(activeConversation.id);
if (cached) {
setMessages(cached.messages);
seenMessageContent.current = new Set(cached.seenContent);
seenMessageContent.current = new Set(
cached.messages.map((message) => getMessageContentKey(message))
);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
const requestId = latestReconcileRequestIdRef.current + 1;
@@ -599,9 +571,8 @@ export function useConversationMessages(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConversation?.id, activeConversation?.type, targetMessageId, reloadVersion]);
// Add a message if it's new (deduplication)
// Returns true if the message was added, false if it was a duplicate
const addMessageIfNew = useCallback(
// Add a message to the active conversation if it is new.
const appendActiveMessageIfNew = useCallback(
(msg: Message): boolean => {
const msgWithPendingAck = applyPendingAck(msg);
const contentKey = getMessageContentKey(msgWithPendingAck);
@@ -679,7 +650,7 @@ export function useConversationMessages(
[updateMessageAck]
);
const receiveRealtimeMessage = useCallback(
const observeMessage = useCallback(
(msg: Message): { added: boolean; activeConversation: boolean } => {
const msgWithPendingAck = applyPendingAck(msg);
const activeConversationMessage = isActiveConversationMessage(
@@ -693,22 +664,17 @@ export function useConversationMessages(
}
return {
added: addMessageIfNew(msgWithPendingAck),
added: appendActiveMessageIfNew(msgWithPendingAck),
activeConversation: true,
};
}
const contentKey = getMessageContentKey(msgWithPendingAck);
return {
added: messageCache.addMessage(
msgWithPendingAck.conversation_key,
msgWithPendingAck,
contentKey
),
added: messageCache.addMessage(msgWithPendingAck.conversation_key, msgWithPendingAck),
activeConversation: false,
};
},
[activeConversation, addMessageIfNew, applyPendingAck, hasNewerMessagesRef]
[activeConversation, appendActiveMessageIfNew, applyPendingAck, hasNewerMessagesRef]
);
const renameConversationMessages = useCallback((oldId: string, newId: string) => {
@@ -730,16 +696,11 @@ export function useConversationMessages(
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
setMessages,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
reloadCurrentConversation,
addMessageIfNew,
updateMessageAck,
triggerReconcile,
receiveRealtimeMessage,
observeMessage,
receiveMessageAck,
reconcileOnReconnect,
renameConversationMessages,
+18 -26
View File
@@ -35,9 +35,13 @@ interface UseRealtimeAppStateArgs {
blockedKeysRef: MutableRefObject<string[]>;
blockedNamesRef: MutableRefObject<string[]>;
activeConversationRef: MutableRefObject<Conversation | null>;
receiveRealtimeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
trackNewMessage: (msg: Message) => void;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
recordMessageEvent: (args: {
msg: Message;
activeConversation: boolean;
isNewMessage: boolean;
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
checkMention: (text: string) => boolean;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
@@ -83,9 +87,8 @@ export function useRealtimeAppState({
blockedKeysRef,
blockedNamesRef,
activeConversationRef,
receiveRealtimeMessage,
trackNewMessage,
incrementUnread,
observeMessage,
recordMessageEvent,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
@@ -179,23 +182,13 @@ export function useRealtimeAppState({
}
const { added: isNewMessage, activeConversation: isForActiveConversation } =
receiveRealtimeMessage(msg);
trackNewMessage(msg);
if (!isForActiveConversation) {
if (!msg.outgoing && isNewMessage) {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
stateKey = getStateKey('contact', msg.conversation_key);
}
if (stateKey) {
incrementUnread(stateKey, checkMention(msg.text));
}
}
}
observeMessage(msg);
recordMessageEvent({
msg,
activeConversation: isForActiveConversation,
isNewMessage,
hasMention: checkMention(msg.text),
});
if (!msg.outgoing && isNewMessage) {
notifyIncomingMessage?.(msg);
@@ -261,15 +254,15 @@ export function useRealtimeAppState({
checkMention,
fetchAllContacts,
fetchConfig,
incrementUnread,
renameConversationState,
renameConversationMessages,
maxRawPackets,
mergeChannelIntoList,
pendingDeleteFallbackRef,
prevHealthRef,
recordMessageEvent,
receiveMessageAck,
receiveRealtimeMessage,
observeMessage,
refreshUnreads,
reconcileOnReconnect,
removeConversationMessages,
@@ -278,7 +271,6 @@ export function useRealtimeAppState({
setContacts,
setHealth,
setRawPackets,
trackNewMessage,
notifyIncomingMessage,
]
);
+41 -21
View File
@@ -16,10 +16,14 @@ interface UseUnreadCountsResult {
mentions: Record<string, boolean>;
lastMessageTimes: ConversationTimes;
unreadLastReadAts: Record<string, number | null>;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
recordMessageEvent: (args: {
msg: Message;
activeConversation: boolean;
isNewMessage: boolean;
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
markAllRead: () => void;
trackNewMessage: (msg: Message) => void;
refreshUnreads: () => Promise<void>;
}
@@ -162,7 +166,6 @@ export function useUnreadCounts(
}
}, [activeConversation]);
// Increment unread count for a conversation
const incrementUnread = useCallback((stateKey: string, hasMention?: boolean) => {
setUnreadCounts((prev) => ({
...prev,
@@ -176,6 +179,40 @@ export function useUnreadCounts(
}
}, []);
const recordMessageEvent = useCallback(
({
msg,
activeConversation: isActiveConversation,
isNewMessage,
hasMention,
}: {
msg: Message;
activeConversation: boolean;
isNewMessage: boolean;
hasMention?: boolean;
}) => {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
stateKey = getStateKey('contact', msg.conversation_key);
}
if (!stateKey) {
return;
}
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
const updated = setLastMessageTime(stateKey, timestamp);
setLastMessageTimes(updated);
if (!isActiveConversation && !msg.outgoing && isNewMessage) {
incrementUnread(stateKey, hasMention);
}
},
[incrementUnread]
);
const renameConversationState = useCallback((oldStateKey: string, newStateKey: string) => {
if (oldStateKey === newStateKey) return;
@@ -212,31 +249,14 @@ export function useUnreadCounts(
});
}, []);
// Track a new incoming message for unread counts
const trackNewMessage = useCallback((msg: Message) => {
let conversationKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
conversationKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
conversationKey = getStateKey('contact', msg.conversation_key);
}
if (conversationKey) {
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
const updated = setLastMessageTime(conversationKey, timestamp);
setLastMessageTimes(updated);
}
}, []);
return {
unreadCounts,
mentions,
lastMessageTimes,
unreadLastReadAts,
incrementUnread,
recordMessageEvent,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads: fetchUnreads,
};
}