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
+4 -13
View File
@@ -31,19 +31,15 @@ const mocks = vi.hoisted(() => ({
error: vi.fn(),
},
hookFns: {
setMessages: vi.fn(),
fetchMessages: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
addMessageIfNew: vi.fn(),
receiveRealtimeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
clearConversationMessages: vi.fn(),
incrementUnread: vi.fn(),
recordMessageEvent: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(async () => {}),
},
}));
@@ -67,15 +63,11 @@ vi.mock('../hooks', async (importOriginal) => {
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: mocks.hookFns.setMessages,
fetchMessages: mocks.hookFns.fetchMessages,
fetchOlderMessages: mocks.hookFns.fetchOlderMessages,
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: mocks.hookFns.addMessageIfNew,
receiveRealtimeMessage: mocks.hookFns.receiveRealtimeMessage,
observeMessage: mocks.hookFns.observeMessage,
receiveMessageAck: mocks.hookFns.receiveMessageAck,
reconcileOnReconnect: mocks.hookFns.reconcileOnReconnect,
renameConversationMessages: mocks.hookFns.renameConversationMessages,
@@ -87,10 +79,9 @@ vi.mock('../hooks', async (importOriginal) => {
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
incrementUnread: mocks.hookFns.incrementUnread,
recordMessageEvent: mocks.hookFns.recordMessageEvent,
renameConversationState: vi.fn(),
markAllRead: mocks.hookFns.markAllRead,
trackNewMessage: mocks.hookFns.trackNewMessage,
refreshUnreads: mocks.hookFns.refreshUnreads,
}),
};
+2 -6
View File
@@ -37,14 +37,11 @@ vi.mock('../hooks', async (importOriginal) => {
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: vi.fn(),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: vi.fn(),
receiveRealtimeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
@@ -57,10 +54,9 @@ vi.mock('../hooks', async (importOriginal) => {
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
incrementUnread: vi.fn(),
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(),
}),
};
+2 -7
View File
@@ -32,15 +32,11 @@ vi.mock('../hooks', async (importOriginal) => {
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: vi.fn(),
fetchMessages: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: vi.fn(),
receiveRealtimeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
@@ -52,10 +48,9 @@ vi.mock('../hooks', async (importOriginal) => {
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
incrementUnread: vi.fn(),
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(async () => {}),
}),
};
+7 -7
View File
@@ -10,9 +10,9 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fixtures from './fixtures/websocket_events.json';
import { getMessageContentKey } from '../hooks/useConversationMessages';
import { getStateKey } from '../utils/conversationState';
import { mergeContactIntoList } from '../utils/contactMerge';
import { getMessageContentKey } from '../utils/messageIdentity';
import * as messageCache from '../messageCache';
import type { Contact, Message } from '../types';
@@ -68,7 +68,7 @@ function handleMessageEvent(
state.lastMessageTimes[stateKey] = msg.received_at;
if (!isForActiveConversation) {
const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey);
const isNew = messageCache.addMessage(msg.conversation_key, msg);
if (!msg.outgoing && isNew) {
state.unreadCounts[stateKey] = (state.unreadCounts[stateKey] || 0) + 1;
unreadIncremented = true;
@@ -180,7 +180,7 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
// dual-set design the global set would drop msg-0's key during pruning,
// so a later mesh echo of msg-0 would pass the global check and
// phantom-increment unread. With the fix, messageCache's per-conversation
// seenContent is the single source of truth and is never pruned.
// Cached messages remain the source of truth for inactive-conversation dedup.
const MESSAGE_COUNT = 1001;
for (let i = 0; i < MESSAGE_COUNT; i++) {
const msg: Message = {
@@ -362,7 +362,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 0,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-100');
messageCache.addMessage('pk_abc', msg);
messageCache.updateAck(100, 1);
@@ -387,7 +387,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 1,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-101');
messageCache.addMessage('pk_abc', msg);
const longerPaths = [
{ path: 'aa', received_at: 1700000001 },
@@ -416,7 +416,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 5,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-102');
messageCache.addMessage('pk_abc', msg);
// Try to update with a lower ack count
messageCache.updateAck(102, 3);
@@ -441,7 +441,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 0,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-103');
messageCache.addMessage('pk_abc', msg);
// Update a non-existent message ID — should not throw or modify anything
messageCache.updateAck(999, 1);
+10 -31
View File
@@ -27,11 +27,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
}
function createEntry(messages: Message[] = [], hasOlderMessages = false) {
const seenContent = new Set<string>();
for (const msg of messages) {
seenContent.add(`${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`);
}
return { messages, seenContent, hasOlderMessages };
return { messages, hasOlderMessages };
}
describe('messageCache', () => {
@@ -155,11 +151,7 @@ describe('messageCache', () => {
messageCache.set('conv1', createEntry([]));
const msg = createMessage({ id: 10, text: 'New message' });
const result = messageCache.addMessage(
'conv1',
msg,
'CHAN-channel123-New message-1700000000'
);
const result = messageCache.addMessage('conv1', msg);
expect(result).toBe(true);
const entry = messageCache.get('conv1');
@@ -171,12 +163,11 @@ describe('messageCache', () => {
messageCache.set('conv1', createEntry([]));
const msg1 = createMessage({ id: 10, text: 'Hello' });
const contentKey = 'CHAN-channel123-Hello-1700000000';
expect(messageCache.addMessage('conv1', msg1, contentKey)).toBe(true);
expect(messageCache.addMessage('conv1', msg1)).toBe(true);
// Same content key, different message id
const msg2 = createMessage({ id: 11, text: 'Hello' });
expect(messageCache.addMessage('conv1', msg2, contentKey)).toBe(false);
expect(messageCache.addMessage('conv1', msg2)).toBe(false);
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(1);
@@ -187,9 +178,7 @@ describe('messageCache', () => {
// Same id, different content key
const msg = createMessage({ id: 10, text: 'Different' });
expect(messageCache.addMessage('conv1', msg, 'CHAN-channel123-Different-1700000000')).toBe(
false
);
expect(messageCache.addMessage('conv1', msg)).toBe(false);
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(1);
@@ -208,11 +197,7 @@ describe('messageCache', () => {
text: 'newest',
received_at: 1700000000 + MAX_MESSAGES_PER_ENTRY,
});
const result = messageCache.addMessage(
'conv1',
newMsg,
`CHAN-channel123-newest-${newMsg.sender_timestamp}`
);
const result = messageCache.addMessage('conv1', newMsg);
expect(result).toBe(true);
const entry = messageCache.get('conv1');
@@ -225,11 +210,7 @@ describe('messageCache', () => {
it('auto-creates a minimal entry for never-visited conversations and returns true', () => {
const msg = createMessage({ id: 10, text: 'First contact' });
const result = messageCache.addMessage(
'new_conv',
msg,
'CHAN-channel123-First contact-1700000000'
);
const result = messageCache.addMessage('new_conv', msg);
expect(result).toBe(true);
const entry = messageCache.get('new_conv');
@@ -237,7 +218,6 @@ describe('messageCache', () => {
expect(entry!.messages).toHaveLength(1);
expect(entry!.messages[0].text).toBe('First contact');
expect(entry!.hasOlderMessages).toBe(true);
expect(entry!.seenContent.has('CHAN-channel123-First contact-1700000000')).toBe(true);
});
it('promotes entry to MRU on addMessage', () => {
@@ -248,7 +228,7 @@ describe('messageCache', () => {
// addMessage to conv0 (currently LRU) should promote it
const msg = createMessage({ id: 999, text: 'Incoming WS message' });
messageCache.addMessage('conv0', msg, 'CHAN-channel123-Incoming WS message-1700000000');
messageCache.addMessage('conv0', msg);
// Add one more — conv1 should now be LRU and get evicted, not conv0
messageCache.set('conv_new', createEntry());
@@ -259,11 +239,10 @@ describe('messageCache', () => {
it('returns false for duplicate delivery to auto-created entry', () => {
const msg = createMessage({ id: 10, text: 'Echo' });
const contentKey = 'CHAN-channel123-Echo-1700000000';
expect(messageCache.addMessage('new_conv', msg, contentKey)).toBe(true);
expect(messageCache.addMessage('new_conv', msg)).toBe(true);
// Duplicate via mesh echo
expect(messageCache.addMessage('new_conv', msg, contentKey)).toBe(false);
expect(messageCache.addMessage('new_conv', msg)).toBe(false);
const entry = messageCache.get('new_conv');
expect(entry!.messages).toHaveLength(1);
@@ -63,7 +63,7 @@ function createArgs(overrides: Partial<Parameters<typeof useConversationActions>
activeConversationRef: { current: activeConversation },
setContacts: vi.fn(),
setChannels: vi.fn(),
addMessageIfNew: vi.fn(() => true),
observeMessage: vi.fn(() => ({ added: true, activeConversation: true })),
messageInputRef: { current: { appendText: vi.fn() } },
...overrides,
};
@@ -85,7 +85,7 @@ describe('useConversationActions', () => {
});
expect(mocks.api.sendChannelMessage).toHaveBeenCalledWith(publicChannel.key, sentMessage.text);
expect(args.addMessageIfNew).toHaveBeenCalledWith(sentMessage);
expect(args.observeMessage).toHaveBeenCalledWith(sentMessage);
});
it('does not append a sent message after the active conversation changes', async () => {
@@ -111,7 +111,7 @@ describe('useConversationActions', () => {
await sendPromise;
});
expect(args.addMessageIfNew).not.toHaveBeenCalled();
expect(args.observeMessage).not.toHaveBeenCalled();
});
it('appends sender mentions into the message input', () => {
@@ -146,7 +146,7 @@ describe('useConversationActions', () => {
});
expect(mocks.api.resendChannelMessage).toHaveBeenCalledWith(sentMessage.id, true);
expect(args.addMessageIfNew).toHaveBeenCalledWith(resentMessage);
expect(args.observeMessage).toHaveBeenCalledWith(resentMessage);
});
it('does not append a byte-perfect resend locally', async () => {
@@ -162,7 +162,7 @@ describe('useConversationActions', () => {
await result.current.handleResendChannelMessage(sentMessage.id, false);
});
expect(args.addMessageIfNew).not.toHaveBeenCalled();
expect(args.observeMessage).not.toHaveBeenCalled();
});
it('does not append a resend if the user has switched conversations', async () => {
@@ -190,7 +190,7 @@ describe('useConversationActions', () => {
await resendPromise;
});
expect(args.addMessageIfNew).not.toHaveBeenCalled();
expect(args.observeMessage).not.toHaveBeenCalled();
});
it('merges returned contact data after path discovery', async () => {
@@ -76,11 +76,11 @@ describe('useConversationMessages ACK ordering', () => {
const paths = [{ path: 'A1B2', received_at: 1700000010 }];
act(() => {
result.current.updateMessageAck(42, 2, paths);
result.current.receiveMessageAck(42, 2, paths);
});
act(() => {
const added = result.current.addMessageIfNew(
const { added } = result.current.observeMessage(
createMessage({ id: 42, acked: 0, paths: null })
);
expect(added).toBe(true);
@@ -100,7 +100,7 @@ describe('useConversationMessages ACK ordering', () => {
const paths = [{ path: 'C3D4', received_at: 1700000011 }];
act(() => {
result.current.updateMessageAck(42, 1, paths);
result.current.receiveMessageAck(42, 1, paths);
});
deferred.resolve([createMessage({ id: 42, acked: 0, paths: null })]);
@@ -118,7 +118,7 @@ describe('useConversationMessages ACK ordering', () => {
await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(1));
act(() => {
const added = result.current.addMessageIfNew(
const { added } = result.current.observeMessage(
createMessage({
id: 99,
text: 'ws-arrived',
@@ -153,7 +153,7 @@ describe('useConversationMessages ACK ordering', () => {
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
act(() => {
result.current.addMessageIfNew(createMessage({ id: 42, acked: 0, paths: null }));
result.current.observeMessage(createMessage({ id: 42, acked: 0, paths: null }));
});
const highAckPaths = [
@@ -163,8 +163,8 @@ describe('useConversationMessages ACK ordering', () => {
const staleAckPaths = [{ path: 'A1B2', received_at: 1700000010 }];
act(() => {
result.current.updateMessageAck(42, 3, highAckPaths);
result.current.updateMessageAck(42, 2, staleAckPaths);
result.current.receiveMessageAck(42, 3, highAckPaths);
result.current.receiveMessageAck(42, 2, staleAckPaths);
});
expect(result.current.messages[0].acked).toBe(3);
@@ -321,8 +321,8 @@ describe('useConversationMessages background reconcile ordering', () => {
.mockReturnValueOnce(secondReconcile.promise);
act(() => {
result.current.triggerReconcile();
result.current.triggerReconcile();
result.current.reconcileOnReconnect();
result.current.reconcileOnReconnect();
});
secondReconcile.resolve([createMessage({ id: 42, text: 'newer snapshot', acked: 2 })]);
@@ -344,9 +344,6 @@ describe('useConversationMessages background reconcile ordering', () => {
messageCache.set(conv.id, {
messages: [cachedMessage],
seenContent: new Set([
`PRIV-${cachedMessage.conversation_key}-${cachedMessage.text}-${cachedMessage.sender_timestamp}`,
]),
hasOlderMessages: true,
});
@@ -596,7 +593,7 @@ describe('useConversationMessages forward pagination', () => {
// Simulate WS adding a message with the same content key
act(() => {
result.current.addMessageIfNew(
result.current.observeMessage(
createMessage({
id: 2,
conversation_key: 'ch1',
@@ -5,7 +5,8 @@
*/
import { describe, it, expect } from 'vitest';
import { getMessageContentKey, mergePendingAck } from '../hooks/useConversationMessages';
import { mergePendingAck } from '../hooks/useConversationMessages';
import { getMessageContentKey } from '../utils/messageIdentity';
import type { Message } from '../types';
function createMessage(overrides: Partial<Message> = {}): Message {
+10 -10
View File
@@ -66,9 +66,8 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
blockedKeysRef: { current: [] as string[] },
blockedNamesRef: { current: [] as string[] },
activeConversationRef: { current: null as Conversation | null },
receiveRealtimeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
trackNewMessage: vi.fn(),
incrementUnread: vi.fn(),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
checkMention: vi.fn(() => false),
pendingDeleteFallbackRef: { current: false },
@@ -181,7 +180,7 @@ describe('useRealtimeAppState', () => {
it('tracks unread state for a new non-active incoming message', () => {
const { args } = createRealtimeArgs({
checkMention: vi.fn(() => true),
receiveRealtimeMessage: vi.fn(() => ({ added: true, activeConversation: false })),
observeMessage: vi.fn(() => ({ added: true, activeConversation: false })),
});
const { result } = renderHook(() => useRealtimeAppState(args));
@@ -190,12 +189,13 @@ describe('useRealtimeAppState', () => {
result.current.onMessage?.(incomingDm);
});
expect(args.receiveRealtimeMessage).toHaveBeenCalledWith(incomingDm);
expect(args.trackNewMessage).toHaveBeenCalledWith(incomingDm);
expect(args.incrementUnread).toHaveBeenCalledWith(
`contact-${incomingDm.conversation_key}`,
true
);
expect(args.observeMessage).toHaveBeenCalledWith(incomingDm);
expect(args.recordMessageEvent).toHaveBeenCalledWith({
msg: incomingDm,
activeConversation: false,
isNewMessage: true,
hasMention: true,
});
expect(args.notifyIncomingMessage).toHaveBeenCalledWith(incomingDm);
});
+91 -1
View File
@@ -10,7 +10,8 @@ import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useUnreadCounts } from '../hooks/useUnreadCounts';
import type { Channel, Contact, Conversation } from '../types';
import type { Channel, Contact, Conversation, Message } from '../types';
import { getStateKey } from '../utils/conversationState';
// Mock api module
vi.mock('../api', () => ({
@@ -57,6 +58,25 @@ function makeContact(pubkey: string): Contact {
};
}
function makeMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'PRIV',
conversation_key: CONTACT_KEY,
text: 'hello',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: null,
...overrides,
};
}
const CHANNEL_KEY = 'AABB00112233445566778899AABBCCDD';
const CONTACT_KEY = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
@@ -332,4 +352,74 @@ describe('useUnreadCounts', () => {
// Raw view doesn't filter any conversation's unreads
expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBe(5);
});
it('recordMessageEvent updates last-message time and unread count for new inactive incoming messages', async () => {
const mocks = await getMockedApi();
const { result } = renderWith({});
await act(async () => {
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
});
const msg = makeMessage({
id: 5,
type: 'CHAN',
conversation_key: CHANNEL_KEY,
received_at: 1700001234,
});
await act(async () => {
result.current.recordMessageEvent({
msg,
activeConversation: false,
isNewMessage: true,
hasMention: true,
});
});
expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBe(1);
expect(result.current.mentions[getStateKey('channel', CHANNEL_KEY)]).toBe(true);
expect(result.current.lastMessageTimes[getStateKey('channel', CHANNEL_KEY)]).toBe(1700001234);
});
it('recordMessageEvent skips unread increment for active or non-new messages but still tracks time', async () => {
const mocks = await getMockedApi();
const { result } = renderWith({});
await act(async () => {
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
});
const activeMsg = makeMessage({
id: 6,
type: 'PRIV',
conversation_key: CONTACT_KEY,
received_at: 1700002000,
});
await act(async () => {
result.current.recordMessageEvent({
msg: activeMsg,
activeConversation: true,
isNewMessage: true,
hasMention: true,
});
result.current.recordMessageEvent({
msg: makeMessage({
id: 7,
type: 'CHAN',
conversation_key: CHANNEL_KEY,
received_at: 1700002001,
}),
activeConversation: false,
isNewMessage: false,
hasMention: true,
});
});
expect(result.current.unreadCounts[getStateKey('contact', CONTACT_KEY)]).toBeUndefined();
expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBeUndefined();
expect(result.current.lastMessageTimes[getStateKey('contact', CONTACT_KEY)]).toBe(1700002000);
expect(result.current.lastMessageTimes[getStateKey('channel', CHANNEL_KEY)]).toBe(1700002001);
});
});