Phase 1 of frontend fixup

This commit is contained in:
Jack Kingsman
2026-03-16 16:48:58 -07:00
parent d373585527
commit a1a749283e
10 changed files with 321 additions and 137 deletions

View File

@@ -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) => {

View File

@@ -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<boolean>;
hasSetDefaultConversation: MutableRefObject<boolean>;
removeConversationMessages: (conversationId: string) => void;
}
export function useContactsAndChannels({
setActiveConversation,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
removeConversationMessages,
}: UseContactsAndChannelsArgs) {
const [contacts, setContacts] = useState<Contact[]>([]);
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 {

View File

@@ -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<AbortController | null>(null);
const fetchingConversationIdRef = useRef<string | null>(null);
const latestReconcileRequestIdRef = useRef(0);
const pendingReconnectReconcileRef = useRef(false);
const messagesRef = useRef<Message[]>([]);
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,
};
}

View File

@@ -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<SetStateAction<HealthStatus | null>>;
fetchConfig: () => void | Promise<void>;
setRawPackets: Dispatch<SetStateAction<RawPacket[]>>;
triggerReconcile: () => void;
reconcileOnReconnect: () => void;
refreshUnreads: () => Promise<void>;
setChannels: Dispatch<SetStateAction<Channel[]>>;
fetchAllContacts: () => Promise<Contact[]>;
@@ -37,15 +35,16 @@ interface UseRealtimeAppStateArgs {
blockedKeysRef: MutableRefObject<string[]>;
blockedNamesRef: MutableRefObject<string[]>;
activeConversationRef: MutableRefObject<Conversation | null>;
hasNewerMessagesRef: MutableRefObject<boolean>;
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<boolean>;
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,
]
);

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);
});

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" />,
}));

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" />,
}));

View File

@@ -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,
})
);
}

View File

@@ -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' };

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
);