diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4b0c7b2..a76ce69 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -373,11 +373,29 @@ export function Sidebar({ ); + const getSectionUnreadCount = (rows: ConversationRow[]): number => + rows.reduce((total, row) => total + row.unreadCount, 0); + + const favoriteRows = favoriteItems.map((item) => + item.type === 'channel' + ? buildChannelRow(item.channel, 'fav-chan') + : buildContactRow(item.contact, 'fav-contact') + ); + const channelRows = nonFavoriteChannels.map((channel) => buildChannelRow(channel, 'chan')); + const contactRows = nonFavoriteContacts.map((contact) => buildContactRow(contact, 'contact')); + const repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater')); + + const favoritesUnreadCount = getSectionUnreadCount(favoriteRows); + const channelsUnreadCount = getSectionUnreadCount(channelRows); + const contactsUnreadCount = getSectionUnreadCount(contactRows); + const repeatersUnreadCount = getSectionUnreadCount(repeaterRows); + const renderSectionHeader = ( title: string, collapsed: boolean, onToggle: () => void, - showSortToggle = false + showSortToggle = false, + unreadCount = 0 ) => { const effectiveCollapsed = isSearching ? false : collapsed; @@ -396,14 +414,23 @@ export function Sidebar({ {effectiveCollapsed ? '▸' : '▾'} {title} - {showSortToggle && ( - + {(showSortToggle || unreadCount > 0) && ( +
+ {showSortToggle && ( + + )} + {unreadCount > 0 && ( + + {unreadCount} + + )} +
)} ); @@ -549,14 +576,11 @@ export function Sidebar({ 'Favorites', favoritesCollapsed, () => setFavoritesCollapsed((prev) => !prev), - false + false, + favoritesUnreadCount )} {(isSearching || !favoritesCollapsed) && - favoriteItems.map((item) => - item.type === 'channel' - ? renderConversationRow(buildChannelRow(item.channel, 'fav-chan')) - : renderConversationRow(buildContactRow(item.contact, 'fav-contact')) - )} + favoriteRows.map((row) => renderConversationRow(row))} )} @@ -567,12 +591,11 @@ export function Sidebar({ 'Channels', channelsCollapsed, () => setChannelsCollapsed((prev) => !prev), - true + true, + channelsUnreadCount )} {(isSearching || !channelsCollapsed) && - nonFavoriteChannels.map((channel) => - renderConversationRow(buildChannelRow(channel, 'chan')) - )} + channelRows.map((row) => renderConversationRow(row))} )} @@ -583,12 +606,11 @@ export function Sidebar({ 'Contacts', contactsCollapsed, () => setContactsCollapsed((prev) => !prev), - true + true, + contactsUnreadCount )} {(isSearching || !contactsCollapsed) && - nonFavoriteContacts.map((contact) => - renderConversationRow(buildContactRow(contact, 'contact')) - )} + contactRows.map((row) => renderConversationRow(row))} )} @@ -599,12 +621,11 @@ export function Sidebar({ 'Repeaters', repeatersCollapsed, () => setRepeatersCollapsed((prev) => !prev), - true + true, + repeatersUnreadCount )} {(isSearching || !repeatersCollapsed) && - nonFavoriteRepeaters.map((contact) => - renderConversationRow(buildContactRow(contact, 'repeater')) - )} + repeaterRows.map((row) => renderConversationRow(row))} )} diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx new file mode 100644 index 0000000..c37c26c --- /dev/null +++ b/frontend/src/test/sidebar.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Sidebar } from '../components/Sidebar'; +import { CONTACT_TYPE_REPEATER, type Channel, type Contact, type Favorite } from '../types'; +import { getStateKey, type ConversationTimes } from '../utils/conversationState'; + +function makeChannel(key: string, name: string): Channel { + return { + key, + name, + is_hashtag: false, + on_radio: false, + last_read_at: null, + }; +} + +function makeContact(public_key: string, name: string, type = 1): Contact { + return { + public_key, + name, + type, + 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, + }; +} + +function renderSidebar(overrides?: { + unreadCounts?: Record; + favorites?: Favorite[]; + lastMessageTimes?: ConversationTimes; +}) { + const publicChannel = makeChannel('AA'.repeat(16), 'Public'); + const flightChannel = makeChannel('BB'.repeat(16), '#flight'); + const opsChannel = makeChannel('CC'.repeat(16), '#ops'); + const alice = makeContact('11'.repeat(32), 'Alice'); + const relay = makeContact('22'.repeat(32), 'Relay', CONTACT_TYPE_REPEATER); + + const unreadCounts = overrides?.unreadCounts ?? { + [getStateKey('channel', flightChannel.key)]: 2, + [getStateKey('channel', opsChannel.key)]: 1, + [getStateKey('contact', alice.public_key)]: 3, + [getStateKey('contact', relay.public_key)]: 4, + }; + + const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }]; + + render( + + ); + + return { flightChannel, opsChannel, alice }; +} + +function getSectionHeaderContainer(title: string): HTMLElement { + const btn = screen.getByRole('button', { name: new RegExp(title, 'i') }); + const container = btn.closest('div'); + if (!container) throw new Error(`Missing header container for section ${title}`); + return container; +} + +describe('Sidebar section summaries', () => { + it('shows muted section unread totals in each visible section header', () => { + renderSidebar(); + + expect(within(getSectionHeaderContainer('Favorites')).getByText('2')).toBeInTheDocument(); + expect(within(getSectionHeaderContainer('Channels')).getByText('1')).toBeInTheDocument(); + expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toBeInTheDocument(); + expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument(); + }); + + it('expands collapsed sections during search and restores collapse state after clearing search', async () => { + const { opsChannel, alice } = renderSidebar(); + + fireEvent.click(screen.getByRole('button', { name: /Channels/i })); + fireEvent.click(screen.getByRole('button', { name: /Contacts/i })); + + expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); + expect(screen.queryByText(alice.name)).not.toBeInTheDocument(); + + const search = screen.getByPlaceholderText('Search...'); + fireEvent.change(search, { target: { value: 'alice' } }); + + await waitFor(() => { + expect(screen.getByText(alice.name)).toBeInTheDocument(); + }); + + fireEvent.change(search, { target: { value: '' } }); + + await waitFor(() => { + expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); + expect(screen.queryByText(alice.name)).not.toBeInTheDocument(); + }); + }); +}); +