mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-18 07:16:17 +02:00
Fix issue with backend restart letting unreads accumulate on open thread
This commit is contained in:
@@ -29,10 +29,36 @@ export function useUnreadCounts(
|
||||
const [mentions, setMentions] = useState<Record<string, boolean>>({});
|
||||
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
|
||||
|
||||
// Apply unreads data to state
|
||||
// Track active conversation via ref so applyUnreads can filter without
|
||||
// destabilizing the callback chain (avoids re-creating fetchUnreads on
|
||||
// every conversation switch).
|
||||
const activeConvRef = useRef(activeConversation);
|
||||
activeConvRef.current = activeConversation;
|
||||
|
||||
// Apply unreads data to state, filtering out the active conversation
|
||||
// (the user is already viewing it, so its count should stay at 0).
|
||||
const applyUnreads = useCallback((data: UnreadCounts) => {
|
||||
setUnreadCounts(data.counts);
|
||||
setMentions(data.mentions);
|
||||
const ac = activeConvRef.current;
|
||||
const activeKey =
|
||||
ac &&
|
||||
ac.type !== 'raw' &&
|
||||
ac.type !== 'map' &&
|
||||
ac.type !== 'visualizer' &&
|
||||
ac.type !== 'search'
|
||||
? getStateKey(ac.type as 'channel' | 'contact', ac.id)
|
||||
: null;
|
||||
|
||||
if (activeKey) {
|
||||
const counts = { ...data.counts };
|
||||
const mentionsData = { ...data.mentions };
|
||||
delete counts[activeKey];
|
||||
delete mentionsData[activeKey];
|
||||
setUnreadCounts(counts);
|
||||
setMentions(mentionsData);
|
||||
} else {
|
||||
setUnreadCounts(data.counts);
|
||||
setMentions(data.mentions);
|
||||
}
|
||||
|
||||
if (Object.keys(data.last_message_times).length > 0) {
|
||||
for (const [key, ts] of Object.entries(data.last_message_times)) {
|
||||
@@ -42,13 +68,21 @@ export function useUnreadCounts(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch unreads from the server-side endpoint
|
||||
// Fetch unreads from the server-side endpoint.
|
||||
// Also re-marks the active conversation as read so the server's last_read_at
|
||||
// stays current (otherwise subsequent fetches would re-report the same unreads).
|
||||
const fetchUnreads = useCallback(async () => {
|
||||
try {
|
||||
applyUnreads(await api.getUnreads());
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch unreads:', err);
|
||||
}
|
||||
const ac = activeConvRef.current;
|
||||
if (ac?.type === 'channel') {
|
||||
api.markChannelRead(ac.id).catch(() => {});
|
||||
} else if (ac?.type === 'contact') {
|
||||
api.markContactRead(ac.id).catch(() => {});
|
||||
}
|
||||
}, [applyUnreads]);
|
||||
|
||||
// On mount, consume the prefetched promise (started in index.html before
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Tests for useUnreadCounts hook.
|
||||
*
|
||||
* Focuses on the fix for stale server-side unreads overwriting local state
|
||||
* when the user is viewing a conversation (e.g. after WS reconnect or
|
||||
* contact/channel count change triggers a server re-fetch).
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
// Mock api module
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getUnreads: vi.fn(),
|
||||
markChannelRead: vi.fn().mockResolvedValue({ status: 'ok', key: '' }),
|
||||
markContactRead: vi.fn().mockResolvedValue({ status: 'ok', public_key: '' }),
|
||||
markAllRead: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock prefetch — takePrefetchOrFetch calls the fetcher directly
|
||||
vi.mock('../prefetch', () => ({
|
||||
takePrefetchOrFetch: vi.fn((_key: string, fetcher: () => Promise<unknown>) => fetcher()),
|
||||
}));
|
||||
|
||||
function makeChannel(key: string, name: string): Channel {
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContact(pubkey: string): Contact {
|
||||
return {
|
||||
public_key: pubkey,
|
||||
name: `Contact-${pubkey.slice(0, 6)}`,
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
}
|
||||
|
||||
const CHANNEL_KEY = 'AABB00112233445566778899AABBCCDD';
|
||||
const CONTACT_KEY = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
|
||||
|
||||
// Get typed references to the mocked api functions
|
||||
async function getMockedApi() {
|
||||
const { api } = await import('../api');
|
||||
return {
|
||||
getUnreads: vi.mocked(api.getUnreads),
|
||||
markChannelRead: vi.mocked(api.markChannelRead),
|
||||
markContactRead: vi.mocked(api.markContactRead),
|
||||
markAllRead: vi.mocked(api.markAllRead),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useUnreadCounts', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const mocks = await getMockedApi();
|
||||
// Re-establish default resolvers (clearAllMocks wipes them)
|
||||
mocks.getUnreads.mockResolvedValue({
|
||||
counts: {},
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
});
|
||||
mocks.markChannelRead.mockResolvedValue({ status: 'ok', key: '' });
|
||||
mocks.markContactRead.mockResolvedValue({ status: 'ok', public_key: '' });
|
||||
mocks.markAllRead.mockResolvedValue({ status: 'ok', timestamp: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderWith({
|
||||
channels = [] as Channel[],
|
||||
contacts = [] as Contact[],
|
||||
activeConversation = null as Conversation | null,
|
||||
} = {}) {
|
||||
return renderHook(
|
||||
({ channels: ch, contacts: ct, activeConversation: ac }) => useUnreadCounts(ch, ct, ac),
|
||||
{ initialProps: { channels, contacts, activeConversation } }
|
||||
);
|
||||
}
|
||||
|
||||
it('filters out active channel conversation from server unreads', async () => {
|
||||
const mocks = await getMockedApi();
|
||||
const channels = [makeChannel(CHANNEL_KEY, 'Test')];
|
||||
|
||||
// Server reports 5 unreads for the channel we're viewing
|
||||
mocks.getUnreads.mockResolvedValue({
|
||||
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
|
||||
mentions: { [`channel-${CHANNEL_KEY}`]: true },
|
||||
last_message_times: {},
|
||||
});
|
||||
|
||||
const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Test' };
|
||||
const { result } = renderWith({ channels, activeConversation: activeConv });
|
||||
|
||||
// Wait for the initial fetch + apply
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
// The active conversation should NOT have unreads
|
||||
expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined();
|
||||
expect(result.current.mentions[`channel-${CHANNEL_KEY}`]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('filters out active contact conversation from server unreads', async () => {
|
||||
const mocks = await getMockedApi();
|
||||
const contacts = [makeContact(CONTACT_KEY)];
|
||||
|
||||
mocks.getUnreads.mockResolvedValue({
|
||||
counts: { [`contact-${CONTACT_KEY}`]: 3 },
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
});
|
||||
|
||||
const activeConv: Conversation = { type: 'contact', id: CONTACT_KEY, name: 'Test' };
|
||||
const { result } = renderWith({ contacts, activeConversation: activeConv });
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
expect(result.current.unreadCounts[`contact-${CONTACT_KEY}`]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves unreads for non-active conversations', async () => {
|
||||
const mocks = await getMockedApi();
|
||||
const otherKey = 'FFEEDDCCBBAA99887766554433221100';
|
||||
const channels = [makeChannel(CHANNEL_KEY, 'Active'), makeChannel(otherKey, 'Other')];
|
||||
|
||||
mocks.getUnreads.mockResolvedValue({
|
||||
counts: {
|
||||
[`channel-${CHANNEL_KEY}`]: 5,
|
||||
[`channel-${otherKey}`]: 2,
|
||||
},
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
});
|
||||
|
||||
const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Active' };
|
||||
const { result } = renderWith({ channels, activeConversation: activeConv });
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
// Active channel filtered out, other channel preserved
|
||||
expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined();
|
||||
expect(result.current.unreadCounts[`channel-${otherKey}`]).toBe(2);
|
||||
});
|
||||
|
||||
it('calls mark-read API for active channel after fetching unreads', async () => {
|
||||
const mocks = await getMockedApi();
|
||||
const channels = [makeChannel(CHANNEL_KEY, 'Test')];
|
||||
const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Test' };
|
||||
|
||||
renderWith({ channels, activeConversation: activeConv });
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => expect(mocks.markChannelRead).toHaveBeenCalledWith(CHANNEL_KEY));
|
||||
});
|
||||
});
|
||||
|
||||
it('calls mark-read API for active contact after fetching unreads', async () => {
|
||||
const mocks = await getMockedApi();
|
||||
const contacts = [makeContact(CONTACT_KEY)];
|
||||
const activeConv: Conversation = { type: 'contact', id: CONTACT_KEY, name: 'Test' };
|
||||
|
||||
renderWith({ contacts, activeConversation: activeConv });
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => expect(mocks.markContactRead).toHaveBeenCalledWith(CONTACT_KEY));
|
||||
});
|
||||
});
|
||||
|
||||
it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => {
|
||||
const mocks = await getMockedApi();
|
||||
const channels = [makeChannel(CHANNEL_KEY, 'Test')];
|
||||
const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Test' };
|
||||
|
||||
// Initial fetch: no unreads
|
||||
mocks.getUnreads.mockResolvedValueOnce({
|
||||
counts: {},
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
});
|
||||
|
||||
const { result } = renderWith({ channels, activeConversation: activeConv });
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
// Simulate reconnect: server now reports unreads for the active conversation
|
||||
mocks.getUnreads.mockResolvedValueOnce({
|
||||
counts: { [`channel-${CHANNEL_KEY}`]: 7 },
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshUnreads();
|
||||
});
|
||||
|
||||
// Should still be filtered out
|
||||
expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not filter when no active conversation', async () => {
|
||||
const mocks = await getMockedApi();
|
||||
mocks.getUnreads.mockResolvedValue({
|
||||
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
});
|
||||
|
||||
const { result } = renderWith({});
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBe(5);
|
||||
});
|
||||
|
||||
it('does not filter for non-conversation views (raw, map, visualizer)', async () => {
|
||||
const mocks = await getMockedApi();
|
||||
mocks.getUnreads.mockResolvedValue({
|
||||
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
});
|
||||
|
||||
const activeConv: Conversation = { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
|
||||
const { result } = renderWith({ activeConversation: activeConv });
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
// Raw view doesn't filter any conversation's unreads
|
||||
expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBe(5);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user