Add unread badge at section level

This commit is contained in:
Jack Kingsman
2026-02-14 17:58:21 -08:00
parent b34bc1491a
commit 8bb408180e
2 changed files with 168 additions and 27 deletions

View File

@@ -373,11 +373,29 @@ export function Sidebar({
</div>
);
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({
<span className="text-[10px]">{effectiveCollapsed ? '▸' : '▾'}</span>
<span>{title}</span>
</button>
{showSortToggle && (
<button
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
onClick={handleSortToggle}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
{(showSortToggle || unreadCount > 0) && (
<div className="ml-auto flex items-center gap-1.5">
{showSortToggle && (
<button
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground mr-0.5"
onClick={handleSortToggle}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
)}
{unreadCount > 0 && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
{unreadCount}
</span>
)}
</div>
)}
</div>
);
@@ -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))}
</>
)}

View File

@@ -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<string, number>;
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(
<Sidebar
contacts={[alice, relay]}
channels={[publicChannel, flightChannel, opsChannel]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
unreadCounts={unreadCounts}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={favorites}
sortOrder="recent"
onSortOrderChange={vi.fn()}
/>
);
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();
});
});
});