From a5d9632a67b259f7d011629142cac46a1155632c Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 16 Mar 2026 16:48:58 -0700 Subject: [PATCH] Phase 1 of frontend fixup --- frontend/src/App.tsx | 27 ++-- frontend/src/hooks/useContactsAndChannels.ts | 21 +++- frontend/src/hooks/useConversationMessages.ts | 117 +++++++++++++++++- frontend/src/hooks/useRealtimeAppState.ts | 76 ++++-------- frontend/src/test/appFavorites.test.tsx | 25 ++-- frontend/src/test/appSearchJump.test.tsx | 15 +-- frontend/src/test/appStartupHash.test.tsx | 15 +-- .../src/test/useContactsAndChannels.test.ts | 7 +- .../test/useConversationMessages.race.test.ts | 113 +++++++++++++++++ frontend/src/test/useRealtimeAppState.test.ts | 42 ++----- 10 files changed, 321 insertions(+), 137 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0751b65..685e56c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,5 @@ import { useEffect, useCallback, useRef, useState } from 'react'; import { api } from './api'; -import * as messageCache from './messageCache'; import { takePrefetchOrFetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; import { @@ -109,6 +108,7 @@ export function App() { // useConversationRouter, but useConversationRouter needs channels/contacts from // useContactsAndChannels. We break the cycle with a ref-based indirection. const setActiveConversationRef = useRef<(conv: Conversation | null) => void>(() => {}); + const removeConversationMessagesRef = useRef<(conversationId: string) => void>(() => {}); // --- Extracted hooks --- @@ -180,6 +180,8 @@ export function App() { setActiveConversation: (conv) => setActiveConversationRef.current(conv), pendingDeleteFallbackRef, hasSetDefaultConversation, + removeConversationMessages: (conversationId) => + removeConversationMessagesRef.current(conversationId), }); // useConversationRouter is called second — it receives channels/contacts as inputs @@ -228,15 +230,19 @@ export function App() { hasOlderMessages, hasNewerMessages, loadingNewer, - hasNewerMessagesRef, fetchOlderMessages, fetchNewerMessages, jumpToBottom, reloadCurrentConversation, addMessageIfNew, - updateMessageAck, - triggerReconcile, + receiveRealtimeMessage, + receiveMessageAck, + reconcileOnReconnect, + renameConversationMessages, + removeConversationMessages, + clearConversationMessages, } = useConversationMessages(activeConversation, targetMessageId); + removeConversationMessagesRef.current = removeConversationMessages; const { unreadCounts, @@ -308,7 +314,7 @@ export function App() { setHealth, fetchConfig, setRawPackets, - triggerReconcile, + reconcileOnReconnect, refreshUnreads, setChannels, fetchAllContacts, @@ -316,23 +322,24 @@ export function App() { blockedKeysRef, blockedNamesRef, activeConversationRef, - hasNewerMessagesRef, - addMessageIfNew, + receiveRealtimeMessage, trackNewMessage, incrementUnread, renameConversationState, checkMention, pendingDeleteFallbackRef, setActiveConversation, - updateMessageAck, + renameConversationMessages, + removeConversationMessages, + receiveMessageAck, notifyIncomingMessage, }); const handleVisibilityPolicyChanged = useCallback(() => { - messageCache.clear(); + clearConversationMessages(); reloadCurrentConversation(); void refreshUnreads(); setVisibilityVersion((current) => current + 1); - }, [refreshUnreads, reloadCurrentConversation]); + }, [clearConversationMessages, refreshUnreads, reloadCurrentConversation]); const handleBlockKey = useCallback( async (key: string) => { diff --git a/frontend/src/hooks/useContactsAndChannels.ts b/frontend/src/hooks/useContactsAndChannels.ts index 0f8a371..a12f183 100644 --- a/frontend/src/hooks/useContactsAndChannels.ts +++ b/frontend/src/hooks/useContactsAndChannels.ts @@ -2,7 +2,6 @@ import { useState, useCallback, type MutableRefObject } from 'react'; import { api } from '../api'; import { takePrefetchOrFetch } from '../prefetch'; import { toast } from '../components/ui/sonner'; -import * as messageCache from '../messageCache'; import { getContactDisplayName } from '../utils/pubkey'; import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel'; import type { Channel, Contact, Conversation } from '../types'; @@ -11,12 +10,14 @@ interface UseContactsAndChannelsArgs { setActiveConversation: (conv: Conversation | null) => void; pendingDeleteFallbackRef: MutableRefObject; hasSetDefaultConversation: MutableRefObject; + removeConversationMessages: (conversationId: string) => void; } export function useContactsAndChannels({ setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation, + removeConversationMessages, }: UseContactsAndChannelsArgs) { const [contacts, setContacts] = useState([]); const [contactsLoaded, setContactsLoaded] = useState(false); @@ -117,7 +118,7 @@ export function useContactsAndChannels({ try { pendingDeleteFallbackRef.current = true; await api.deleteChannel(key); - messageCache.remove(key); + removeConversationMessages(key); const refreshedChannels = await api.getChannels(); setChannels(refreshedChannels); const publicChannel = findPublicChannel(refreshedChannels); @@ -135,7 +136,12 @@ export function useContactsAndChannels({ }); } }, - [setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation] + [ + hasSetDefaultConversation, + pendingDeleteFallbackRef, + removeConversationMessages, + setActiveConversation, + ] ); const handleDeleteContact = useCallback( @@ -144,7 +150,7 @@ export function useContactsAndChannels({ try { pendingDeleteFallbackRef.current = true; await api.deleteContact(publicKey); - messageCache.remove(publicKey); + removeConversationMessages(publicKey); setContacts((prev) => prev.filter((c) => c.public_key !== publicKey)); const refreshedChannels = await api.getChannels(); setChannels(refreshedChannels); @@ -163,7 +169,12 @@ export function useContactsAndChannels({ }); } }, - [setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation] + [ + hasSetDefaultConversation, + pendingDeleteFallbackRef, + removeConversationMessages, + setActiveConversation, + ] ); return { diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index fdc2578..21c2495 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -81,12 +81,32 @@ interface UseConversationMessagesResult { addMessageIfNew: (msg: Message) => boolean; updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void; triggerReconcile: () => void; + receiveRealtimeMessage: (msg: Message) => { added: boolean; activeConversation: boolean }; + receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void; + reconcileOnReconnect: () => void; + renameConversationMessages: (oldId: string, newId: string) => void; + removeConversationMessages: (conversationId: string) => void; + clearConversationMessages: () => void; } function isMessageConversation(conversation: Conversation | null): conversation is Conversation { return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type); } +function isActiveConversationMessage( + activeConversation: Conversation | null, + msg: Message +): boolean { + if (!activeConversation) return false; + if (msg.type === 'CHAN' && activeConversation.type === 'channel') { + return msg.conversation_key === activeConversation.id; + } + if (msg.type === 'PRIV' && activeConversation.type === 'contact') { + return msg.conversation_key === activeConversation.id; + } + return false; +} + function appendUniqueMessages(current: Message[], incoming: Message[]): Message[] { if (incoming.length === 0) return current; @@ -165,6 +185,7 @@ export function useConversationMessages( const newerAbortControllerRef = useRef(null); const fetchingConversationIdRef = useRef(null); const latestReconcileRequestIdRef = useRef(0); + const pendingReconnectReconcileRef = useRef(false); const messagesRef = useRef([]); const loadingOlderRef = useRef(false); const hasOlderMessagesRef = useRef(false); @@ -208,6 +229,7 @@ export function useConversationMessages( } const conversationId = activeConversation.id; + pendingReconnectReconcileRef.current = false; if (showLoading) { setMessagesLoading(true); @@ -401,7 +423,15 @@ export function useConversationMessages( seenMessageContent.current.add(getMessageContentKey(msg)); } } - setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); + const stillHasNewerMessages = dataWithPendingAck.length >= MESSAGE_PAGE_SIZE; + setHasNewerMessages(stillHasNewerMessages); + if (!stillHasNewerMessages && pendingReconnectReconcileRef.current) { + pendingReconnectReconcileRef.current = false; + const requestId = latestReconcileRequestIdRef.current + 1; + latestReconcileRequestIdRef.current = requestId; + const reconcileController = new AbortController(); + reconcileFromBackend(activeConversation, reconcileController.signal, requestId); + } } catch (err) { if (isAbortError(err)) { return; @@ -416,7 +446,14 @@ export function useConversationMessages( } setLoadingNewer(false); } - }, [activeConversation, applyPendingAck, hasNewerMessages, loadingNewer, messages]); + }, [ + activeConversation, + applyPendingAck, + hasNewerMessages, + loadingNewer, + messages, + reconcileFromBackend, + ]); const jumpToBottom = useCallback(() => { if (!activeConversation) return; @@ -440,6 +477,23 @@ export function useConversationMessages( reconcileFromBackend(activeConversation, controller.signal, requestId); }, [activeConversation, reconcileFromBackend]); + const reconcileOnReconnect = useCallback(() => { + if (!isMessageConversation(activeConversation)) { + return; + } + + if (hasNewerMessagesRef.current) { + pendingReconnectReconcileRef.current = true; + return; + } + + pendingReconnectReconcileRef.current = false; + const controller = new AbortController(); + const requestId = latestReconcileRequestIdRef.current + 1; + latestReconcileRequestIdRef.current = requestId; + reconcileFromBackend(activeConversation, controller.signal, requestId); + }, [activeConversation, reconcileFromBackend]); + useEffect(() => { if (abortControllerRef.current) { abortControllerRef.current.abort(); @@ -461,6 +515,7 @@ export function useConversationMessages( prevConversationIdRef.current = newId; prevReloadVersionRef.current = reloadVersion; latestReconcileRequestIdRef.current = 0; + pendingReconnectReconcileRef.current = false; // Preserve around-loaded context on the same conversation when search clears targetMessageId. if (!conversationChanged && !targetMessageId && !reloadRequested) { @@ -616,6 +671,58 @@ export function useConversationMessages( [messagesRef, setMessages, setPendingAck] ); + const receiveMessageAck = useCallback( + (messageId: number, ackCount: number, paths?: MessagePath[]) => { + updateMessageAck(messageId, ackCount, paths); + messageCache.updateAck(messageId, ackCount, paths); + }, + [updateMessageAck] + ); + + const receiveRealtimeMessage = useCallback( + (msg: Message): { added: boolean; activeConversation: boolean } => { + const msgWithPendingAck = applyPendingAck(msg); + const activeConversationMessage = isActiveConversationMessage( + activeConversation, + msgWithPendingAck + ); + + if (activeConversationMessage) { + if (hasNewerMessagesRef.current) { + return { added: false, activeConversation: true }; + } + + return { + added: addMessageIfNew(msgWithPendingAck), + activeConversation: true, + }; + } + + const contentKey = getMessageContentKey(msgWithPendingAck); + return { + added: messageCache.addMessage( + msgWithPendingAck.conversation_key, + msgWithPendingAck, + contentKey + ), + activeConversation: false, + }; + }, + [activeConversation, addMessageIfNew, applyPendingAck, hasNewerMessagesRef] + ); + + const renameConversationMessages = useCallback((oldId: string, newId: string) => { + messageCache.rename(oldId, newId); + }, []); + + const removeConversationMessages = useCallback((conversationId: string) => { + messageCache.remove(conversationId); + }, []); + + const clearConversationMessages = useCallback(() => { + messageCache.clear(); + }, []); + return { messages, messagesLoading, @@ -632,5 +739,11 @@ export function useConversationMessages( addMessageIfNew, updateMessageAck, triggerReconcile, + receiveRealtimeMessage, + receiveMessageAck, + reconcileOnReconnect, + renameConversationMessages, + removeConversationMessages, + clearConversationMessages, }; } diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts index 2611762..3e25f6a 100644 --- a/frontend/src/hooks/useRealtimeAppState.ts +++ b/frontend/src/hooks/useRealtimeAppState.ts @@ -6,14 +6,12 @@ import { type SetStateAction, } from 'react'; import { api } from '../api'; -import * as messageCache from '../messageCache'; import type { UseWebSocketOptions } from '../useWebSocket'; import { toast } from '../components/ui/sonner'; import { getStateKey } from '../utils/conversationState'; import { mergeContactIntoList } from '../utils/contactMerge'; import { getContactDisplayName } from '../utils/pubkey'; import { appendRawPacketUnique } from '../utils/rawPacketIdentity'; -import { getMessageContentKey } from './useConversationMessages'; import type { Channel, Contact, @@ -29,7 +27,7 @@ interface UseRealtimeAppStateArgs { setHealth: Dispatch>; fetchConfig: () => void | Promise; setRawPackets: Dispatch>; - triggerReconcile: () => void; + reconcileOnReconnect: () => void; refreshUnreads: () => Promise; setChannels: Dispatch>; fetchAllContacts: () => Promise; @@ -37,15 +35,16 @@ interface UseRealtimeAppStateArgs { blockedKeysRef: MutableRefObject; blockedNamesRef: MutableRefObject; activeConversationRef: MutableRefObject; - hasNewerMessagesRef: MutableRefObject; - addMessageIfNew: (msg: Message) => boolean; + receiveRealtimeMessage: (msg: Message) => { added: boolean; activeConversation: boolean }; trackNewMessage: (msg: Message) => void; incrementUnread: (stateKey: string, hasMention?: boolean) => void; renameConversationState: (oldStateKey: string, newStateKey: string) => void; checkMention: (text: string) => boolean; pendingDeleteFallbackRef: MutableRefObject; setActiveConversation: (conv: Conversation | null) => void; - updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void; + renameConversationMessages: (oldId: string, newId: string) => void; + removeConversationMessages: (conversationId: string) => void; + receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void; notifyIncomingMessage?: (msg: Message) => void; maxRawPackets?: number; } @@ -71,30 +70,12 @@ function isMessageBlocked(msg: Message, blockedKeys: string[], blockedNames: str return blockedNames.length > 0 && !!msg.sender_name && blockedNames.includes(msg.sender_name); } -function isActiveConversationMessage( - activeConversation: Conversation | null, - msg: Message -): boolean { - if (!activeConversation) return false; - if (msg.type === 'CHAN' && activeConversation.type === 'channel') { - return msg.conversation_key === activeConversation.id; - } - if (msg.type === 'PRIV' && activeConversation.type === 'contact') { - return msg.conversation_key === activeConversation.id; - } - return false; -} - -function isMessageConversation(conversation: Conversation | null): boolean { - return conversation?.type === 'channel' || conversation?.type === 'contact'; -} - export function useRealtimeAppState({ prevHealthRef, setHealth, fetchConfig, setRawPackets, - triggerReconcile, + reconcileOnReconnect, refreshUnreads, setChannels, fetchAllContacts, @@ -102,15 +83,16 @@ export function useRealtimeAppState({ blockedKeysRef, blockedNamesRef, activeConversationRef, - hasNewerMessagesRef, - addMessageIfNew, + receiveRealtimeMessage, trackNewMessage, incrementUnread, renameConversationState, checkMention, pendingDeleteFallbackRef, setActiveConversation, - updateMessageAck, + renameConversationMessages, + removeConversationMessages, + receiveMessageAck, notifyIncomingMessage, maxRawPackets = 500, }: UseRealtimeAppStateArgs): UseWebSocketOptions { @@ -184,11 +166,7 @@ export function useRealtimeAppState({ }, onReconnect: () => { setRawPackets([]); - if ( - !(hasNewerMessagesRef.current && isMessageConversation(activeConversationRef.current)) - ) { - triggerReconcile(); - } + reconcileOnReconnect(); refreshUnreads(); api.getChannels().then(setChannels).catch(console.error); fetchAllContacts() @@ -200,22 +178,12 @@ export function useRealtimeAppState({ return; } - const isForActiveConversation = isActiveConversationMessage( - activeConversationRef.current, - msg - ); - let isNewMessage = false; - - if (isForActiveConversation && !hasNewerMessagesRef.current) { - isNewMessage = addMessageIfNew(msg); - } + const { added: isNewMessage, activeConversation: isForActiveConversation } = + receiveRealtimeMessage(msg); trackNewMessage(msg); - const contentKey = getMessageContentKey(msg); if (!isForActiveConversation) { - isNewMessage = messageCache.addMessage(msg.conversation_key, msg, contentKey); - if (!msg.outgoing && isNewMessage) { let stateKey: string | null = null; if (msg.type === 'CHAN' && msg.conversation_key) { @@ -243,7 +211,7 @@ export function useRealtimeAppState({ contact ) ); - messageCache.rename(previousPublicKey, contact.public_key); + renameConversationMessages(previousPublicKey, contact.public_key); renameConversationState( getStateKey('contact', previousPublicKey), getStateKey('contact', contact.public_key) @@ -263,7 +231,7 @@ export function useRealtimeAppState({ }, onContactDeleted: (publicKey: string) => { setContacts((prev) => prev.filter((c) => c.public_key !== publicKey)); - messageCache.remove(publicKey); + removeConversationMessages(publicKey); const active = activeConversationRef.current; if (active?.type === 'contact' && active.id === publicKey) { pendingDeleteFallbackRef.current = true; @@ -272,7 +240,7 @@ export function useRealtimeAppState({ }, onChannelDeleted: (key: string) => { setChannels((prev) => prev.filter((c) => c.key !== key)); - messageCache.remove(key); + removeConversationMessages(key); const active = activeConversationRef.current; if (active?.type === 'channel' && active.id === key) { pendingDeleteFallbackRef.current = true; @@ -283,34 +251,34 @@ export function useRealtimeAppState({ setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets)); }, onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => { - updateMessageAck(messageId, ackCount, paths); - messageCache.updateAck(messageId, ackCount, paths); + receiveMessageAck(messageId, ackCount, paths); }, }), [ activeConversationRef, - addMessageIfNew, blockedKeysRef, blockedNamesRef, checkMention, fetchAllContacts, fetchConfig, - hasNewerMessagesRef, incrementUnread, renameConversationState, + renameConversationMessages, maxRawPackets, mergeChannelIntoList, pendingDeleteFallbackRef, prevHealthRef, + receiveMessageAck, + receiveRealtimeMessage, refreshUnreads, + reconcileOnReconnect, + removeConversationMessages, setActiveConversation, setChannels, setContacts, setHealth, setRawPackets, trackNewMessage, - triggerReconcile, - updateMessageAck, notifyIncomingMessage, ] ); diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 420b1c8..b6564d4 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -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); }); diff --git a/frontend/src/test/appSearchJump.test.tsx b/frontend/src/test/appSearchJump.test.tsx index 6caf69a..b2c9c8a 100644 --- a/frontend/src/test/appSearchJump.test.tsx +++ b/frontend/src/test/appSearchJump.test.tsx @@ -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: () =>
, })); diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index 797a2fd..c1dac7c 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -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: () =>
, })); diff --git a/frontend/src/test/useContactsAndChannels.test.ts b/frontend/src/test/useContactsAndChannels.test.ts index 37f50ee..d12c1ac 100644 --- a/frontend/src/test/useContactsAndChannels.test.ts +++ b/frontend/src/test/useContactsAndChannels.test.ts @@ -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, }) ); } diff --git a/frontend/src/test/useConversationMessages.race.test.ts b/frontend/src/test/useConversationMessages.race.test.ts index e67dfb6..8d8314b 100644 --- a/frontend/src/test/useConversationMessages.race.test.ts +++ b/frontend/src/test/useConversationMessages.race.test.ts @@ -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' }; diff --git a/frontend/src/test/useRealtimeAppState.test.ts b/frontend/src/test/useRealtimeAppState.test.ts index 4879e6f..9f555bf 100644 --- a/frontend/src/test/useRealtimeAppState.test.ts +++ b/frontend/src/test/useRealtimeAppState.test.ts @@ -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 {}), setChannels, fetchAllContacts: vi.fn(async () => [] as Contact[]), @@ -74,15 +66,16 @@ function createRealtimeArgs(overrides: Partial ({ 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 );