diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d50cdae..b34a350 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -300,6 +300,7 @@ export function App() {
// Silently recover any data missed during the disconnect window
triggerReconcile();
refreshUnreads();
+ api.getChannels().then(setChannels).catch(console.error);
fetchAllContacts()
.then((data) => setContacts(data))
.catch(console.error);
diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts
index 5564cd7..9a043c7 100644
--- a/frontend/src/hooks/useUnreadCounts.ts
+++ b/frontend/src/hooks/useUnreadCounts.ts
@@ -88,10 +88,12 @@ export function useUnreadCounts(
// On mount, consume the prefetched promise (started in index.html before
// React loaded) or fall back to a fresh fetch.
// Re-fetch when channel/contact count changes mid-session (new sync, cracker
- // channel created, etc.) but skip the initial 0→N load to avoid double calls.
+ // channel created, etc.). Skip only the very first run of this effect; after
+ // that, any count change should trigger a refresh, even if the other
+ // collection is still empty.
const channelsLen = channels.length;
const contactsLen = contacts.length;
- const prevLens = useRef({ channels: 0, contacts: 0 });
+ const hasObservedCountsRef = useRef(false);
useEffect(() => {
takePrefetchOrFetch('unreads', api.getUnreads)
.then(applyUnreads)
@@ -100,10 +102,10 @@ export function useUnreadCounts(
});
}, [applyUnreads]);
useEffect(() => {
- const prev = prevLens.current;
- prevLens.current = { channels: channelsLen, contacts: contactsLen };
- // Skip the initial load (0→N); only refetch on mid-session count changes
- if (prev.channels === 0 || prev.contacts === 0) return;
+ if (!hasObservedCountsRef.current) {
+ hasObservedCountsRef.current = true;
+ return;
+ }
fetchUnreads();
}, [channelsLen, contactsLen, fetchUnreads]);
diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx
index 7037da5..313e05c 100644
--- a/frontend/src/test/appFavorites.test.tsx
+++ b/frontend/src/test/appFavorites.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
@@ -36,9 +36,11 @@ const mocks = vi.hoisted(() => ({
fetchOlderMessages: vi.fn(async () => {}),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
+ triggerReconcile: vi.fn(),
incrementUnread: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
+ refreshUnreads: vi.fn(async () => {}),
},
}));
@@ -59,11 +61,17 @@ vi.mock('../hooks', async (importOriginal) => {
messagesLoading: false,
loadingOlder: false,
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(),
addMessageIfNew: mocks.hookFns.addMessageIfNew,
updateMessageAck: mocks.hookFns.updateMessageAck,
+ triggerReconcile: mocks.hookFns.triggerReconcile,
}),
useUnreadCounts: () => ({
unreadCounts: {},
@@ -72,6 +80,7 @@ vi.mock('../hooks', async (importOriginal) => {
incrementUnread: mocks.hookFns.incrementUnread,
markAllRead: mocks.hookFns.markAllRead,
trackNewMessage: mocks.hookFns.trackNewMessage,
+ refreshUnreads: mocks.hookFns.refreshUnreads,
}),
getMessageContentKey: () => 'content-key',
};
@@ -165,6 +174,7 @@ vi.mock('../utils/urlHash', () => ({
}));
import { App } from '../App';
+import { useWebSocket } from '../useWebSocket';
const baseConfig = {
public_key: 'aa'.repeat(32),
@@ -264,6 +274,28 @@ describe('App favorite toggle flow', () => {
});
});
+ it('re-fetches channels after WebSocket reconnect', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
+ });
+
+ const wsHandlers = vi.mocked(useWebSocket).mock.calls[0]?.[0];
+ expect(wsHandlers?.onReconnect).toBeTypeOf('function');
+
+ await act(async () => {
+ wsHandlers?.onReconnect?.();
+ await Promise.resolve();
+ });
+
+ await waitFor(() => {
+ expect(mocks.api.getChannels).toHaveBeenCalledTimes(2);
+ });
+ expect(mocks.hookFns.triggerReconcile).toHaveBeenCalledTimes(1);
+ expect(mocks.hookFns.refreshUnreads).toHaveBeenCalledTimes(1);
+ });
+
it('toggles settings page mode and syncs selected section into SettingsModal', async () => {
render();
diff --git a/frontend/src/test/useUnreadCounts.test.ts b/frontend/src/test/useUnreadCounts.test.ts
index 47bc492..c936608 100644
--- a/frontend/src/test/useUnreadCounts.test.ts
+++ b/frontend/src/test/useUnreadCounts.test.ts
@@ -228,6 +228,62 @@ describe('useUnreadCounts', () => {
expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined();
});
+ it('re-fetches when channels change while contacts remain empty', async () => {
+ const mocks = await getMockedApi();
+ const initialChannels = [makeChannel(CHANNEL_KEY, 'Test')];
+ const addedChannelKey = '11223344556677889900AABBCCDDEEFF';
+
+ const { rerender } = renderWith({ channels: initialChannels, contacts: [] });
+
+ await act(async () => {
+ await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(1));
+ });
+
+ mocks.getUnreads.mockResolvedValueOnce({
+ counts: { [`channel-${addedChannelKey}`]: 2 },
+ mentions: {},
+ last_message_times: {},
+ });
+
+ rerender({
+ channels: [...initialChannels, makeChannel(addedChannelKey, 'Added')],
+ contacts: [],
+ activeConversation: null,
+ });
+
+ await act(async () => {
+ await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(2));
+ });
+ });
+
+ it('re-fetches when contacts change while channels remain empty', async () => {
+ const mocks = await getMockedApi();
+ const initialContact = makeContact(CONTACT_KEY);
+ const addedContactKey = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100';
+
+ const { rerender } = renderWith({ channels: [], contacts: [initialContact] });
+
+ await act(async () => {
+ await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(1));
+ });
+
+ mocks.getUnreads.mockResolvedValueOnce({
+ counts: { [`contact-${addedContactKey}`]: 1 },
+ mentions: {},
+ last_message_times: {},
+ });
+
+ rerender({
+ channels: [],
+ contacts: [initialContact, makeContact(addedContactKey)],
+ activeConversation: null,
+ });
+
+ await act(async () => {
+ await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(2));
+ });
+ });
+
it('does not filter when no active conversation', async () => {
const mocks = await getMockedApi();
mocks.getUnreads.mockResolvedValue({