diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b6dab4d..a76ce69 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CONTACT_TYPE_REPEATER, type Contact, @@ -16,6 +16,25 @@ import { cn } from '@/lib/utils'; type SortOrder = 'alpha' | 'recent'; +type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact }; + +type ConversationRow = { + key: string; + type: 'channel' | 'contact'; + id: string; + name: string; + unreadCount: number; + isMention: boolean; + contact?: Contact; +}; + +type CollapseState = { + favorites: boolean; + channels: boolean; + contacts: boolean; + repeaters: boolean; +}; + interface SidebarProps { contacts: Contact[]; channels: Channel[]; @@ -56,6 +75,11 @@ export function Sidebar({ }: SidebarProps) { const sortOrder = sortOrderProp; const [searchQuery, setSearchQuery] = useState(''); + const [favoritesCollapsed, setFavoritesCollapsed] = useState(false); + const [channelsCollapsed, setChannelsCollapsed] = useState(false); + const [contactsCollapsed, setContactsCollapsed] = useState(false); + const [repeatersCollapsed, setRepeatersCollapsed] = useState(false); + const collapseSnapshotRef = useRef(null); const handleSortToggle = () => { const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha'; @@ -143,20 +167,9 @@ export function Sidebar({ [uniqueChannels, sortOrder, getLastMessageTime] ); - // Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha) - const sortedContacts = useMemo( - () => - [...uniqueContacts].sort((a, b) => { - const aIsRepeater = a.type === CONTACT_TYPE_REPEATER; - const bIsRepeater = b.type === CONTACT_TYPE_REPEATER; - - if (aIsRepeater && !bIsRepeater) return 1; - if (!aIsRepeater && bIsRepeater) return -1; - - if (aIsRepeater && bIsRepeater) { - return (a.name || a.public_key).localeCompare(b.name || b.public_key); - } - + const sortContactsByOrder = useCallback( + (items: Contact[]) => + [...items].sort((a, b) => { if (sortOrder === 'recent') { const timeA = getLastMessageTime('contact', a.public_key); const timeB = getLastMessageTime('contact', b.public_key); @@ -166,11 +179,24 @@ export function Sidebar({ } return (a.name || a.public_key).localeCompare(b.name || b.public_key); }), - [uniqueContacts, sortOrder, getLastMessageTime] + [sortOrder, getLastMessageTime] + ); + + // Split non-repeater contacts and repeater contacts into separate sorted lists + const sortedNonRepeaterContacts = useMemo( + () => sortContactsByOrder(uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER)), + [uniqueContacts, sortContactsByOrder] + ); + + const sortedRepeaters = useMemo( + () => sortContactsByOrder(uniqueContacts.filter((c) => c.type === CONTACT_TYPE_REPEATER)), + [uniqueContacts, sortContactsByOrder] ); // Filter by search query const query = searchQuery.toLowerCase().trim(); + const isSearching = query.length > 0; + const filteredChannels = useMemo( () => query @@ -180,27 +206,77 @@ export function Sidebar({ : sortedChannels, [sortedChannels, query] ); - const filteredContacts = useMemo( + + const filteredNonRepeaterContacts = useMemo( () => query - ? sortedContacts.filter( + ? sortedNonRepeaterContacts.filter( (c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) ) - : sortedContacts, - [sortedContacts, query] + : sortedNonRepeaterContacts, + [sortedNonRepeaterContacts, query] ); - // Separate favorites from regular items, and build combined favorites list - type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact }; + const filteredRepeaters = useMemo( + () => + query + ? sortedRepeaters.filter( + (c) => + c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) + ) + : sortedRepeaters, + [sortedRepeaters, query] + ); - const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts } = useMemo(() => { + // Expand sections while searching; restore prior collapse state when search ends. + useEffect(() => { + if (isSearching) { + if (!collapseSnapshotRef.current) { + collapseSnapshotRef.current = { + favorites: favoritesCollapsed, + channels: channelsCollapsed, + contacts: contactsCollapsed, + repeaters: repeatersCollapsed, + }; + } + + if (favoritesCollapsed || channelsCollapsed || contactsCollapsed || repeatersCollapsed) { + setFavoritesCollapsed(false); + setChannelsCollapsed(false); + setContactsCollapsed(false); + setRepeatersCollapsed(false); + } + return; + } + + if (collapseSnapshotRef.current) { + const prev = collapseSnapshotRef.current; + collapseSnapshotRef.current = null; + setFavoritesCollapsed(prev.favorites); + setChannelsCollapsed(prev.channels); + setContactsCollapsed(prev.contacts); + setRepeatersCollapsed(prev.repeaters); + } + }, [ + isSearching, + favoritesCollapsed, + channelsCollapsed, + contactsCollapsed, + repeatersCollapsed, + ]); + + // Separate favorites from regular items, and build combined favorites list + const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } = useMemo(() => { const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key)); - const favContacts = filteredContacts.filter((c) => + const favContacts = [...filteredNonRepeaterContacts, ...filteredRepeaters].filter((c) => isFavorite(favorites, 'contact', c.public_key) ); const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key)); - const nonFavContacts = filteredContacts.filter( + const nonFavContacts = filteredNonRepeaterContacts.filter( + (c) => !isFavorite(favorites, 'contact', c.public_key) + ); + const nonFavRepeaters = filteredRepeaters.filter( (c) => !isFavorite(favorites, 'contact', c.public_key) ); @@ -228,8 +304,137 @@ export function Sidebar({ favoriteItems: items, nonFavoriteChannels: nonFavChannels, nonFavoriteContacts: nonFavContacts, + nonFavoriteRepeaters: nonFavRepeaters, }; - }, [filteredChannels, filteredContacts, favorites, getLastMessageTime]); + }, [ + filteredChannels, + filteredNonRepeaterContacts, + filteredRepeaters, + favorites, + getLastMessageTime, + ]); + + const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({ + key: `${keyPrefix}-${channel.key}`, + type: 'channel', + id: channel.key, + name: channel.name, + unreadCount: getUnreadCount('channel', channel.key), + isMention: hasMention('channel', channel.key), + }); + + const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({ + key: `${keyPrefix}-${contact.public_key}`, + type: 'contact', + id: contact.public_key, + name: getContactDisplayName(contact.name, contact.public_key), + unreadCount: getUnreadCount('contact', contact.public_key), + isMention: hasMention('contact', contact.public_key), + contact, + }); + + const renderConversationRow = (row: ConversationRow) => ( +
0 && '[&_.name]:font-bold [&_.name]:text-foreground' + )} + onClick={() => + handleSelectConversation({ + type: row.type, + id: row.id, + name: row.name, + }) + } + > + {row.type === 'contact' && row.contact && ( + + )} + {row.name} + {row.unreadCount > 0 && ( + + {row.unreadCount} + + )} +
+ ); + + 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, + unreadCount = 0 + ) => { + const effectiveCollapsed = isSearching ? false : collapsed; + + return ( +
+ + {(showSortToggle || unreadCount > 0) && ( +
+ {showSortToggle && ( + + )} + {unreadCount > 0 && ( + + {unreadCount} + + )} +
+ )} +
+ ); + }; return (
@@ -367,208 +572,67 @@ export function Sidebar({ {/* Favorites */} {favoriteItems.length > 0 && ( <> -
- Favorites -
- {favoriteItems.map((item) => { - if (item.type === 'channel') { - const channel = item.channel; - const unreadCount = getUnreadCount('channel', channel.key); - const isMention = hasMention('channel', channel.key); - return ( -
0 && '[&_.name]:font-bold [&_.name]:text-foreground' - )} - onClick={() => - handleSelectConversation({ - type: 'channel', - id: channel.key, - name: channel.name, - }) - } - > - {channel.name} - {unreadCount > 0 && ( - - {unreadCount} - - )} -
- ); - } else { - const contact = item.contact; - const unreadCount = getUnreadCount('contact', contact.public_key); - const isMention = hasMention('contact', contact.public_key); - return ( -
0 && '[&_.name]:font-bold [&_.name]:text-foreground' - )} - onClick={() => - handleSelectConversation({ - type: 'contact', - id: contact.public_key, - name: getContactDisplayName(contact.name, contact.public_key), - }) - } - > - - - {getContactDisplayName(contact.name, contact.public_key)} - - {unreadCount > 0 && ( - - {unreadCount} - - )} -
- ); - } - })} + {renderSectionHeader( + 'Favorites', + favoritesCollapsed, + () => setFavoritesCollapsed((prev) => !prev), + false, + favoritesUnreadCount + )} + {(isSearching || !favoritesCollapsed) && + favoriteRows.map((row) => renderConversationRow(row))} )} {/* Channels */} {nonFavoriteChannels.length > 0 && ( <> -
- Channels - -
- {nonFavoriteChannels.map((channel) => { - const unreadCount = getUnreadCount('channel', channel.key); - const isMention = hasMention('channel', channel.key); - return ( -
0 && '[&_.name]:font-bold [&_.name]:text-foreground' - )} - onClick={() => - handleSelectConversation({ - type: 'channel', - id: channel.key, - name: channel.name, - }) - } - > - {channel.name} - {unreadCount > 0 && ( - - {unreadCount} - - )} -
- ); - })} + {renderSectionHeader( + 'Channels', + channelsCollapsed, + () => setChannelsCollapsed((prev) => !prev), + true, + channelsUnreadCount + )} + {(isSearching || !channelsCollapsed) && + channelRows.map((row) => renderConversationRow(row))} )} {/* Contacts */} {nonFavoriteContacts.length > 0 && ( <> -
- Contacts - {nonFavoriteChannels.length === 0 && ( - - )} -
- {nonFavoriteContacts.map((contact) => { - const unreadCount = getUnreadCount('contact', contact.public_key); - const isMention = hasMention('contact', contact.public_key); - return ( -
0 && '[&_.name]:font-bold [&_.name]:text-foreground' - )} - onClick={() => - handleSelectConversation({ - type: 'contact', - id: contact.public_key, - name: getContactDisplayName(contact.name, contact.public_key), - }) - } - > - - - {getContactDisplayName(contact.name, contact.public_key)} - - {unreadCount > 0 && ( - - {unreadCount} - - )} -
- ); - })} + {renderSectionHeader( + 'Contacts', + contactsCollapsed, + () => setContactsCollapsed((prev) => !prev), + true, + contactsUnreadCount + )} + {(isSearching || !contactsCollapsed) && + contactRows.map((row) => renderConversationRow(row))} + + )} + + {/* Repeaters */} + {nonFavoriteRepeaters.length > 0 && ( + <> + {renderSectionHeader( + 'Repeaters', + repeatersCollapsed, + () => setRepeatersCollapsed((prev) => !prev), + true, + repeatersUnreadCount + )} + {(isSearching || !repeatersCollapsed) && + repeaterRows.map((row) => renderConversationRow(row))} )} {/* Empty state */} {nonFavoriteContacts.length === 0 && nonFavoriteChannels.length === 0 && + nonFavoriteRepeaters.length === 0 && favoriteItems.length === 0 && (
{query ? 'No matches found' : 'No conversations yet'} 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(); + }); + }); +}); +