mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add unread badge at section level
This commit is contained in:
@@ -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))}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
120
frontend/src/test/sidebar.test.tsx
Normal file
120
frontend/src/test/sidebar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user