Phase 1 of frontend fixup

This commit is contained in:
Jack Kingsman
2026-03-16 16:48:58 -07:00
parent 24747ecd17
commit a5d9632a67
10 changed files with 321 additions and 137 deletions
+13 -12
View File
@@ -35,8 +35,12 @@ const mocks = vi.hoisted(() => ({
fetchMessages: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
receiveRealtimeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
clearConversationMessages: vi.fn(),
incrementUnread: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
@@ -71,8 +75,12 @@ vi.mock('../hooks', async (importOriginal) => {
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: mocks.hookFns.addMessageIfNew,
updateMessageAck: mocks.hookFns.updateMessageAck,
triggerReconcile: mocks.hookFns.triggerReconcile,
receiveRealtimeMessage: mocks.hookFns.receiveRealtimeMessage,
receiveMessageAck: mocks.hookFns.receiveMessageAck,
reconcileOnReconnect: mocks.hookFns.reconcileOnReconnect,
renameConversationMessages: mocks.hookFns.renameConversationMessages,
removeConversationMessages: mocks.hookFns.removeConversationMessages,
clearConversationMessages: mocks.hookFns.clearConversationMessages,
}),
useUnreadCounts: () => ({
unreadCounts: {},
@@ -85,16 +93,9 @@ vi.mock('../hooks', async (importOriginal) => {
trackNewMessage: mocks.hookFns.trackNewMessage,
refreshUnreads: mocks.hookFns.refreshUnreads,
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: ({
settingsMode,
@@ -295,7 +296,7 @@ describe('App favorite toggle flow', () => {
await waitFor(() => {
expect(mocks.api.getChannels).toHaveBeenCalledTimes(2);
});
expect(mocks.hookFns.triggerReconcile).toHaveBeenCalledTimes(1);
expect(mocks.hookFns.reconcileOnReconnect).toHaveBeenCalledTimes(1);
expect(mocks.hookFns.refreshUnreads).toHaveBeenCalledTimes(1);
});
+6 -9
View File
@@ -44,8 +44,12 @@ vi.mock('../hooks', async (importOriginal) => {
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
receiveRealtimeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
clearConversationMessages: vi.fn(),
};
},
useUnreadCounts: () => ({
@@ -59,16 +63,9 @@ vi.mock('../hooks', async (importOriginal) => {
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(),
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));
+6 -9
View File
@@ -40,8 +40,12 @@ vi.mock('../hooks', async (importOriginal) => {
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
receiveRealtimeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
clearConversationMessages: vi.fn(),
}),
useUnreadCounts: () => ({
unreadCounts: {},
@@ -54,16 +58,9 @@ vi.mock('../hooks', async (importOriginal) => {
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(async () => {}),
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));
@@ -35,11 +35,6 @@ vi.mock('../components/ui/sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// Mock messageCache
vi.mock('../messageCache', () => ({
remove: vi.fn(),
}));
function makeContact(suffix: string): Contact {
const key = suffix.padStart(64, '0');
return {
@@ -69,6 +64,7 @@ function makeContacts(count: number, startIndex = 0): Contact[] {
describe('useContactsAndChannels', () => {
const setActiveConversation = vi.fn();
const removeConversationMessages = vi.fn();
const pendingDeleteFallbackRef = { current: false };
const hasSetDefaultConversation = { current: false };
@@ -88,6 +84,7 @@ describe('useContactsAndChannels', () => {
setActiveConversation,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
removeConversationMessages,
})
);
}
@@ -627,6 +627,70 @@ describe('useConversationMessages forward pagination', () => {
expect(dupes).toHaveLength(1);
});
it('defers reconnect reconcile until forward pagination reaches the live tail', async () => {
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
mockGetMessagesAround.mockResolvedValueOnce({
messages: [
createMessage({
id: 1,
conversation_key: 'ch1',
text: 'older-context',
sender_timestamp: 1700000000,
received_at: 1700000000,
}),
],
has_older: false,
has_newer: true,
});
const { result } = renderHook(
({ conv, target }: { conv: Conversation; target: number | null }) =>
useConversationMessages(conv, target),
{ initialProps: { conv, target: 1 } }
);
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.hasNewerMessages).toBe(true);
act(() => {
result.current.reconcileOnReconnect();
});
expect(mockGetMessages).not.toHaveBeenCalled();
mockGetMessages
.mockResolvedValueOnce([
createMessage({
id: 2,
conversation_key: 'ch1',
text: 'newer-page',
sender_timestamp: 1700000001,
received_at: 1700000001,
}),
])
.mockResolvedValueOnce([
createMessage({
id: 2,
conversation_key: 'ch1',
text: 'newer-page',
sender_timestamp: 1700000001,
received_at: 1700000001,
acked: 3,
}),
]);
await act(async () => {
await result.current.fetchNewerMessages();
});
await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(result.current.messages.find((message) => message.id === 2)?.acked).toBe(3)
);
expect(result.current.hasNewerMessages).toBe(false);
});
it('jumpToBottom clears hasNewerMessages and refetches latest', async () => {
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
@@ -677,6 +741,55 @@ describe('useConversationMessages forward pagination', () => {
expect(result.current.messages[0].text).toBe('latest-msg');
});
it('jumpToBottom clears deferred reconnect reconcile without an extra reconcile fetch', async () => {
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
mockGetMessagesAround.mockResolvedValueOnce({
messages: [
createMessage({
id: 5,
conversation_key: 'ch1',
text: 'around-msg',
sender_timestamp: 1700000005,
received_at: 1700000005,
}),
],
has_older: true,
has_newer: true,
});
const { result } = renderHook(
({ conv, target }: { conv: Conversation; target: number | null }) =>
useConversationMessages(conv, target),
{ initialProps: { conv, target: 5 } }
);
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
act(() => {
result.current.reconcileOnReconnect();
});
mockGetMessages.mockResolvedValueOnce([
createMessage({
id: 10,
conversation_key: 'ch1',
text: 'latest-msg',
sender_timestamp: 1700000010,
received_at: 1700000010,
}),
]);
act(() => {
result.current.jumpToBottom();
});
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(1));
expect(result.current.messages[0].text).toBe('latest-msg');
expect(result.current.hasNewerMessages).toBe(false);
});
it('aborts stale newer-page requests on conversation switch without toasting', async () => {
const convA: Conversation = { type: 'channel', id: 'ch1', name: 'Channel A' };
const convB: Conversation = { type: 'channel', id: 'ch2', name: 'Channel B' };
+11 -31
View File
@@ -12,12 +12,6 @@ const mocks = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
},
messageCache: {
addMessage: vi.fn(),
remove: vi.fn(),
rename: vi.fn(),
updateAck: vi.fn(),
},
}));
vi.mock('../api', () => ({
@@ -28,8 +22,6 @@ vi.mock('../components/ui/sonner', () => ({
toast: mocks.toast,
}));
vi.mock('../messageCache', () => mocks.messageCache);
const publicChannel: Channel = {
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
name: 'Public',
@@ -66,7 +58,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
setHealth,
fetchConfig: vi.fn(),
setRawPackets,
triggerReconcile: vi.fn(),
reconcileOnReconnect: vi.fn(),
refreshUnreads: vi.fn(async () => {}),
setChannels,
fetchAllContacts: vi.fn(async () => [] as Contact[]),
@@ -74,15 +66,16 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
blockedKeysRef: { current: [] as string[] },
blockedNamesRef: { current: [] as string[] },
activeConversationRef: { current: null as Conversation | null },
hasNewerMessagesRef: { current: false },
addMessageIfNew: vi.fn(),
receiveRealtimeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
trackNewMessage: vi.fn(),
incrementUnread: vi.fn(),
renameConversationState: vi.fn(),
checkMention: vi.fn(() => false),
pendingDeleteFallbackRef: { current: false },
setActiveConversation: vi.fn(),
updateMessageAck: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
receiveMessageAck: vi.fn(),
notifyIncomingMessage: vi.fn(),
...overrides,
},
@@ -133,7 +126,7 @@ describe('useRealtimeAppState', () => {
});
await waitFor(() => {
expect(args.triggerReconcile).toHaveBeenCalledTimes(1);
expect(args.reconcileOnReconnect).toHaveBeenCalledTimes(1);
expect(args.refreshUnreads).toHaveBeenCalledTimes(1);
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
expect(args.fetchAllContacts).toHaveBeenCalledTimes(1);
@@ -166,14 +159,6 @@ describe('useRealtimeAppState', () => {
const { args, fns } = createRealtimeArgs({
fetchAllContacts: vi.fn(async () => contacts),
activeConversationRef: {
current: {
type: 'channel',
id: publicChannel.key,
name: publicChannel.name,
} satisfies Conversation,
},
hasNewerMessagesRef: { current: true },
});
const { result } = renderHook(() => useRealtimeAppState(args));
@@ -183,7 +168,7 @@ describe('useRealtimeAppState', () => {
});
await waitFor(() => {
expect(args.triggerReconcile).not.toHaveBeenCalled();
expect(args.reconcileOnReconnect).toHaveBeenCalledTimes(1);
expect(args.refreshUnreads).toHaveBeenCalledTimes(1);
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
expect(args.fetchAllContacts).toHaveBeenCalledTimes(1);
@@ -194,9 +179,9 @@ describe('useRealtimeAppState', () => {
});
it('tracks unread state for a new non-active incoming message', () => {
mocks.messageCache.addMessage.mockReturnValue(true);
const { args } = createRealtimeArgs({
checkMention: vi.fn(() => true),
receiveRealtimeMessage: vi.fn(() => ({ added: true, activeConversation: false })),
});
const { result } = renderHook(() => useRealtimeAppState(args));
@@ -205,13 +190,8 @@ describe('useRealtimeAppState', () => {
result.current.onMessage?.(incomingDm);
});
expect(args.addMessageIfNew).not.toHaveBeenCalled();
expect(args.receiveRealtimeMessage).toHaveBeenCalledWith(incomingDm);
expect(args.trackNewMessage).toHaveBeenCalledWith(incomingDm);
expect(mocks.messageCache.addMessage).toHaveBeenCalledWith(
incomingDm.conversation_key,
incomingDm,
expect.any(String)
);
expect(args.incrementUnread).toHaveBeenCalledWith(
`contact-${incomingDm.conversation_key}`,
true
@@ -240,7 +220,7 @@ describe('useRealtimeAppState', () => {
});
expect(fns.setContacts).toHaveBeenCalledWith(expect.any(Function));
expect(mocks.messageCache.remove).toHaveBeenCalledWith(incomingDm.conversation_key);
expect(args.removeConversationMessages).toHaveBeenCalledWith(incomingDm.conversation_key);
expect(args.setActiveConversation).toHaveBeenCalledWith(null);
expect(pendingDeleteFallbackRef.current).toBe(true);
});
@@ -282,7 +262,7 @@ describe('useRealtimeAppState', () => {
});
expect(fns.setContacts).toHaveBeenCalledWith(expect.any(Function));
expect(mocks.messageCache.rename).toHaveBeenCalledWith(
expect(args.renameConversationMessages).toHaveBeenCalledWith(
previousPublicKey,
resolvedContact.public_key
);