From 3c0d6a44668804dc01842bab538edc5157fcad94 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 30 Mar 2026 21:29:01 -0700 Subject: [PATCH] Fix some misc. frontend correctness bugs --- frontend/src/components/MapView.tsx | 14 ++++++++ frontend/src/components/MessageList.tsx | 17 ++++++++- frontend/src/hooks/useUnreadCounts.ts | 27 +++++--------- frontend/src/test/useUnreadCounts.test.ts | 43 +++++++++++++++++++++++ 4 files changed, 82 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index 7106594..8166e16 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -131,9 +131,23 @@ export function MapView({ contacts, focusedKey }: MapViewProps) { // Store ref for a marker const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => { + if (ref === null) { + delete markerRefs.current[key]; + return; + } + markerRefs.current[key] = ref; }, []); + useEffect(() => { + const currentKeys = new Set(mappableContacts.map((contact) => contact.public_key)); + for (const key of Object.keys(markerRefs.current)) { + if (!currentKeys.has(key)) { + delete markerRefs.current[key]; + } + } + }, [mappableContacts]); + // Open popup for focused contact after map is ready useEffect(() => { if (focusedContact && markerRefs.current[focusedContact.public_key]) { diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 06f46f8..ea39063 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -373,7 +373,22 @@ export function MessageList({ } } - setResendableIds(newResendable); + setResendableIds((prev) => { + if (prev.size === newResendable.size) { + let changed = false; + for (const id of newResendable) { + if (!prev.has(id)) { + changed = true; + break; + } + } + if (!changed) { + return prev; + } + } + + return newResendable; + }); return () => { for (const timer of timers.values()) clearTimeout(timer); diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts index 97f3382..0180901 100644 --- a/frontend/src/hooks/useUnreadCounts.ts +++ b/frontend/src/hooks/useUnreadCounts.ts @@ -10,6 +10,12 @@ import { import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types'; import { takePrefetchOrFetch } from '../prefetch'; +function isUnreadTrackedConversation( + conversation: Conversation | null +): conversation is Extract { + return conversation?.type === 'channel' || conversation?.type === 'contact'; +} + interface UseUnreadCountsResult { unreadCounts: Record; /** Tracks which conversations have unread messages that mention the user */ @@ -48,14 +54,7 @@ export function useUnreadCounts( // (the user is already viewing it, so its count should stay at 0). const applyUnreads = useCallback((data: UnreadCounts) => { 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; + const activeKey = isUnreadTrackedConversation(ac) ? getStateKey(ac.type, ac.id) : null; if (activeKey) { const counts = { ...data.counts }; @@ -123,16 +122,8 @@ export function useUnreadCounts( // Mark conversation as read when user views it // Calls server API to persist read state across devices useEffect(() => { - if ( - activeConversation && - activeConversation.type !== 'raw' && - activeConversation.type !== 'map' && - activeConversation.type !== 'visualizer' - ) { - const key = getStateKey( - activeConversation.type as 'channel' | 'contact', - activeConversation.id - ); + if (isUnreadTrackedConversation(activeConversation)) { + const key = getStateKey(activeConversation.type, activeConversation.id); // Update local state immediately for responsive UI setUnreadCounts((prev) => { diff --git a/frontend/src/test/useUnreadCounts.test.ts b/frontend/src/test/useUnreadCounts.test.ts index 82bfb87..cf90124 100644 --- a/frontend/src/test/useUnreadCounts.test.ts +++ b/frontend/src/test/useUnreadCounts.test.ts @@ -221,6 +221,49 @@ describe('useUnreadCounts', () => { }); }); + it('does not treat search or trace views as readable conversations', async () => { + const mocks = await getMockedApi(); + mocks.getUnreads.mockResolvedValue({ + counts: { + [getStateKey('channel', CHANNEL_KEY)]: 4, + [getStateKey('contact', CONTACT_KEY)]: 2, + }, + mentions: { + [getStateKey('channel', CHANNEL_KEY)]: true, + }, + last_message_times: {}, + last_read_ats: {}, + }); + + const { result, rerender } = renderWith({ + channels: [makeChannel(CHANNEL_KEY, 'Test')], + contacts: [makeContact(CONTACT_KEY)], + activeConversation: { type: 'search', id: 'search', name: 'Message Search' }, + }); + + await act(async () => { + await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled()); + }); + + expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBe(4); + expect(result.current.unreadCounts[getStateKey('contact', CONTACT_KEY)]).toBe(2); + expect(mocks.markChannelRead).not.toHaveBeenCalled(); + expect(mocks.markContactRead).not.toHaveBeenCalled(); + + rerender({ + channels: [makeChannel(CHANNEL_KEY, 'Test')], + contacts: [makeContact(CONTACT_KEY)], + activeConversation: { type: 'trace', id: 'trace', name: 'Trace' }, + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mocks.markChannelRead).not.toHaveBeenCalled(); + expect(mocks.markContactRead).not.toHaveBeenCalled(); + }); + it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => { const mocks = await getMockedApi(); const channels = [makeChannel(CHANNEL_KEY, 'Test')];