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();
+ });
+ });
+});
+