diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 06829bd..8c2d3f4 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -140,11 +140,12 @@ export function Sidebar({ [lastMessageTimes] ); - // Deduplicate channels by name, keeping the first (lowest index) + // Deduplicate channels by key only. + // Channel names are not unique; distinct keys must remain visible. const uniqueChannels = useMemo( () => channels.reduce((acc, channel) => { - if (!acc.some((c) => c.name === channel.name)) { + if (!acc.some((c) => c.key === channel.key)) { acc.push(channel); } return acc; diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index 53ba4e0..b6055de 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -100,21 +100,24 @@ export function useConversationMessages( hasOlderMessagesRef.current = hasOlderMessages; }, [hasOlderMessages]); - const setPendingAck = useCallback((messageId: number, ackCount: number, paths?: MessagePath[]) => { - const existing = pendingAcksRef.current.get(messageId); - const merged = mergePendingAck(existing, ackCount, paths); + const setPendingAck = useCallback( + (messageId: number, ackCount: number, paths?: MessagePath[]) => { + const existing = pendingAcksRef.current.get(messageId); + const merged = mergePendingAck(existing, ackCount, paths); - // Update insertion order so most recent updates remain in the buffer longest. - pendingAcksRef.current.delete(messageId); - pendingAcksRef.current.set(messageId, merged); + // Update insertion order so most recent updates remain in the buffer longest. + pendingAcksRef.current.delete(messageId); + pendingAcksRef.current.set(messageId, merged); - if (pendingAcksRef.current.size > MAX_PENDING_ACKS) { - const oldestMessageId = pendingAcksRef.current.keys().next().value as number | undefined; - if (oldestMessageId !== undefined) { - pendingAcksRef.current.delete(oldestMessageId); + if (pendingAcksRef.current.size > MAX_PENDING_ACKS) { + const oldestMessageId = pendingAcksRef.current.keys().next().value as number | undefined; + if (oldestMessageId !== undefined) { + pendingAcksRef.current.delete(oldestMessageId); + } } - } - }, []); + }, + [] + ); const applyPendingAck = useCallback((msg: Message): Message => { const pending = pendingAcksRef.current.get(msg.id); @@ -345,30 +348,33 @@ export function useConversationMessages( // Add a message if it's new (deduplication) // Returns true if the message was added, false if it was a duplicate - const addMessageIfNew = useCallback((msg: Message): boolean => { - const msgWithPendingAck = applyPendingAck(msg); - const contentKey = getMessageContentKey(msgWithPendingAck); - if (seenMessageContent.current.has(contentKey)) { - console.debug('Duplicate message content ignored:', contentKey.slice(0, 50)); - return false; - } - seenMessageContent.current.add(contentKey); - - // Limit set size to prevent memory issues (keep last 500) - if (seenMessageContent.current.size > 1000) { - const entries = Array.from(seenMessageContent.current); - seenMessageContent.current = new Set(entries.slice(-500)); - } - - setMessages((prev) => { - if (prev.some((m) => m.id === msgWithPendingAck.id)) { - return prev; + const addMessageIfNew = useCallback( + (msg: Message): boolean => { + const msgWithPendingAck = applyPendingAck(msg); + const contentKey = getMessageContentKey(msgWithPendingAck); + if (seenMessageContent.current.has(contentKey)) { + console.debug('Duplicate message content ignored:', contentKey.slice(0, 50)); + return false; } - return [...prev, msgWithPendingAck]; - }); + seenMessageContent.current.add(contentKey); - return true; - }, [applyPendingAck]); + // Limit set size to prevent memory issues (keep last 500) + if (seenMessageContent.current.size > 1000) { + const entries = Array.from(seenMessageContent.current); + seenMessageContent.current = new Set(entries.slice(-500)); + } + + setMessages((prev) => { + if (prev.some((m) => m.id === msgWithPendingAck.id)) { + return prev; + } + return [...prev, msgWithPendingAck]; + }); + + return true; + }, + [applyPendingAck] + ); // Update a message's ack count and paths const updateMessageAck = useCallback( @@ -388,7 +394,9 @@ export function useConversationMessages( const current = prev[idx]; const nextAck = Math.max(current.acked, ackCount); const nextPaths = - paths !== undefined && paths.length >= (current.paths?.length ?? 0) ? paths : current.paths; + paths !== undefined && paths.length >= (current.paths?.length ?? 0) + ? paths + : current.paths; const updated = [...prev]; updated[idx] = { diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index f5230c4..5402c04 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -37,6 +37,7 @@ function renderSidebar(overrides?: { unreadCounts?: Record; favorites?: Favorite[]; lastMessageTimes?: ConversationTimes; + channels?: Channel[]; }) { const aliceName = 'Alice'; const publicChannel = makeChannel('AA'.repeat(16), 'Public'); @@ -53,11 +54,12 @@ function renderSidebar(overrides?: { }; const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }]; + const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel]; const view = render( { expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); }); + + it('renders same-name channels when keys differ and allows selecting both', () => { + const publicChannel = makeChannel('AA'.repeat(16), 'Public'); + const channelA = makeChannel('DD'.repeat(16), '#shared'); + const channelB = makeChannel('EE'.repeat(16), '#shared'); + const onSelectConversation = vi.fn(); + + render( + + ); + + const sharedRows = screen.getAllByText('#shared'); + expect(sharedRows).toHaveLength(2); + + fireEvent.click(sharedRows[0]); + fireEvent.click(sharedRows[1]); + + const selectedIds = onSelectConversation.mock.calls.map(([conv]) => conv.id); + expect(new Set(selectedIds)).toEqual(new Set([channelA.key, channelB.key])); + }); }); diff --git a/frontend/src/test/useConversationMessages.race.test.ts b/frontend/src/test/useConversationMessages.race.test.ts index 8947b96..1ad7393 100644 --- a/frontend/src/test/useConversationMessages.race.test.ts +++ b/frontend/src/test/useConversationMessages.race.test.ts @@ -67,7 +67,9 @@ describe('useConversationMessages ACK ordering', () => { }); act(() => { - const added = result.current.addMessageIfNew(createMessage({ id: 42, acked: 0, paths: null })); + const added = result.current.addMessageIfNew( + createMessage({ id: 42, acked: 0, paths: null }) + ); expect(added).toBe(true); });