From 84e6c3425505aea92cf5cf42724fda2527f2cf5b Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 8 Jul 2026 14:57:29 -0700 Subject: [PATCH] Allow favorites to be sorted by type. Closes #314. --- frontend/src/components/Sidebar.tsx | 95 ++++++++++++++++++++----- frontend/src/test/sidebar.test.tsx | 60 +++++++++++++++- frontend/src/utils/conversationState.ts | 27 +++++-- 3 files changed, 158 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a5f6d9d..bbb7694 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -23,6 +23,7 @@ import { } from '../types'; import { buildSidebarSectionSortOrders, + FAVORITES_SORT_CYCLE, getStateKey, loadLegacyLocalStorageSortOrder, loadLocalStorageSidebarSectionSortOrders, @@ -42,6 +43,60 @@ import { cn } from '@/lib/utils'; type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact }; +// Grouping order for the Favorites "by type" sorts. Mirrors the standalone sidebar +// section order: Channels, Contacts (clients/sensors/unknown), Rooms, Repeaters. +function favoriteTypeRank(item: FavoriteItem): number { + if (item.type === 'channel') return 0; + switch (item.contact.type) { + case CONTACT_TYPE_ROOM: + return 2; + case CONTACT_TYPE_REPEATER: + return 3; + default: + return 1; + } +} + +// The next order when the section's sort toggle is clicked. Favorites cycles +// through all four orders; every other (single-type) section flips recent<->alpha. +function nextSortOrder(section: SidebarSortableSection, current: SortOrder): SortOrder { + if (section === 'favorites') { + const idx = FAVORITES_SORT_CYCLE.indexOf(current); + return FAVORITES_SORT_CYCLE[(idx + 1) % FAVORITES_SORT_CYCLE.length]; + } + return current === 'alpha' ? 'recent' : 'alpha'; +} + +// Compact glyph/text shown on the toggle for the current order. +function sortOrderLabel(order: SortOrder): string { + switch (order) { + case 'alpha': + return 'A-Z'; + case 'type-recent': + return 'Type ⏱'; + case 'type-alpha': + return 'Type A-Z'; + case 'recent': + default: + return '⏱'; + } +} + +// Human phrase for aria/title, describing what the order sorts by. +function sortOrderDescription(order: SortOrder): string { + switch (order) { + case 'alpha': + return 'alphabetically'; + case 'type-recent': + return 'by type, then recent'; + case 'type-alpha': + return 'by type, then alphabetically'; + case 'recent': + default: + return 'by recent'; + } +} + type ConversationRow = { key: string; type: 'channel' | 'contact'; @@ -159,7 +214,7 @@ export function Sidebar({ const handleSortToggle = (section: SidebarSortableSection) => { setSectionSortOrders((prev) => { - const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha'; + const nextOrder = nextSortOrder(section, prev[section]); const updated = { ...prev, [section]: nextOrder }; saveLocalStorageSidebarSectionSortOrders(updated); return updated; @@ -323,9 +378,16 @@ export function Sidebar({ ); const sortFavoriteItemsByOrder = useCallback( - (items: FavoriteItem[], order: SortOrder) => - [...items].sort((a, b) => { - if (order === 'recent') { + (items: FavoriteItem[], order: SortOrder) => { + const typeGrouped = order === 'type-recent' || order === 'type-alpha'; + const byRecent = order === 'recent' || order === 'type-recent'; + return [...items].sort((a, b) => { + if (typeGrouped) { + const rankDiff = favoriteTypeRank(a) - favoriteTypeRank(b); + if (rankDiff !== 0) return rankDiff; + } + + if (byRecent) { const timeA = a.type === 'channel' ? getLastMessageTime('channel', a.channel.key) @@ -340,7 +402,8 @@ export function Sidebar({ } return getFavoriteItemName(a).localeCompare(getFavoriteItemName(b)); - }), + }); + }, [getContactRecentTime, getFavoriteItemName, getLastMessageTime] ); @@ -802,20 +865,18 @@ export function Sidebar({
{sortSection && sectionSortOrder && ( )} {unreadCount > 0 && ( diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index 4b65795..b2d01c2 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -714,6 +714,60 @@ describe('Sidebar section summaries', () => { expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']); }); + it('cycles favorites through the four sort orders, grouping by type', () => { + // Mixed-type favorites: a channel (rank 0), two clients (rank 1), a repeater + // (rank 3). Names are chosen so plain-alpha and type-grouped orders differ. + const chan = makeChannel('cd'.repeat(16), 'Zulu'); + const alpha = makeContact('11'.repeat(32), 'Alpha', 1, { favorite: true }); + const bravo = makeContact('22'.repeat(32), 'Bravo', 1, { favorite: true }); + const yankee = makeContact('33'.repeat(32), 'Yankee', 2, { favorite: true }); // repeater + const favChannel = { ...chan, favorite: true }; + + const props = { + contacts: [alpha, bravo, yankee], + channels: [favChannel], + activeConversation: null, + onSelectConversation: vi.fn(), + onNewMessage: vi.fn(), + lastMessageTimes: { + [getStateKey('contact', alpha.public_key)]: 100, + [getStateKey('contact', bravo.public_key)]: 300, // Bravo more recent than Alpha + }, + unreadCounts: {}, + mentions: {}, + showCracker: false, + crackerRunning: false, + onToggleCracker: vi.fn(), + onMarkAllRead: vi.fn(), + }; + + const getFavoritesOrder = () => + screen + .getAllByText(/^(Alpha|Bravo|Yankee|Zulu)$/) + .map((node) => node.textContent) + .filter((text): text is string => Boolean(text)); + + render(); + + // recent -> alpha: pure name order regardless of type. + fireEvent.click(screen.getByRole('button', { name: 'Sort Favorites alphabetically' })); + expect(getFavoritesOrder()).toEqual(['Alpha', 'Bravo', 'Yankee', 'Zulu']); + + // alpha -> type-recent: group by type (channel, clients, repeater); within the + // client group, more-recent Bravo precedes Alpha. + fireEvent.click(screen.getByRole('button', { name: 'Sort Favorites by type, then recent' })); + expect(getFavoritesOrder()).toEqual(['Zulu', 'Bravo', 'Alpha', 'Yankee']); + + // type-recent -> type-alpha: same grouping, clients now A-Z (Alpha before Bravo). + fireEvent.click( + screen.getByRole('button', { name: 'Sort Favorites by type, then alphabetically' }) + ); + expect(getFavoritesOrder()).toEqual(['Zulu', 'Alpha', 'Bravo', 'Yankee']); + + // type-alpha -> recent: cycle wraps back to the recency sort. + expect(screen.getByRole('button', { name: 'Sort Favorites by recent' })).toBeInTheDocument(); + }); + it('seeds favorites sort from the legacy global sort order when section prefs are missing', () => { localStorage.setItem('remoteterm-sortOrder', 'alpha'); @@ -746,6 +800,10 @@ describe('Sidebar section summaries', () => { .filter((text): text is string => Boolean(text)); expect(favoriteRows).toEqual(['Amy', 'Zed']); - expect(screen.getByRole('button', { name: 'Sort Favorites by recent' })).toBeInTheDocument(); + // Favorites now cycles recent -> alpha -> type-recent -> type-alpha, so the + // next order after the seeded 'alpha' is the type-grouped recency sort. + expect( + screen.getByRole('button', { name: 'Sort Favorites by type, then recent' }) + ).toBeInTheDocument(); }); }); diff --git a/frontend/src/utils/conversationState.ts b/frontend/src/utils/conversationState.ts index badda43..f1f9048 100644 --- a/frontend/src/utils/conversationState.ts +++ b/frontend/src/utils/conversationState.ts @@ -13,10 +13,24 @@ const SORT_ORDER_KEY = 'remoteterm-sortOrder'; const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders'; export type ConversationTimes = Record; -export type SortOrder = 'recent' | 'alpha'; +// 'type-*' orders group by contact/channel type first, then apply the sub-order. +// They are only used by the Favorites section (every other section is single-type, +// so type grouping is meaningless there and its toggle stays recent<->alpha). +export type SortOrder = 'recent' | 'alpha' | 'type-recent' | 'type-alpha'; export type SidebarSortableSection = 'favorites' | 'channels' | 'contacts' | 'rooms' | 'repeaters'; export type SidebarSectionSortOrders = Record; +// Full cycle for the Favorites sort toggle, in click order. +export const FAVORITES_SORT_CYCLE: SortOrder[] = ['recent', 'alpha', 'type-recent', 'type-alpha']; + +function coerceFavoritesSortOrder(value: unknown): SortOrder { + return (FAVORITES_SORT_CYCLE as unknown[]).includes(value) ? (value as SortOrder) : 'recent'; +} + +function coerceBasicSortOrder(value: unknown): SortOrder { + return value === 'alpha' ? 'alpha' : 'recent'; +} + // In-memory cache of last message times (loaded from server on init) let lastMessageTimesCache: ConversationTimes = {}; @@ -106,11 +120,12 @@ export function loadLocalStorageSidebarSectionSortOrders(): SidebarSectionSortOr const parsed = JSON.parse(stored) as Partial; return { - favorites: parsed.favorites === 'alpha' ? 'alpha' : 'recent', - channels: parsed.channels === 'alpha' ? 'alpha' : 'recent', - contacts: parsed.contacts === 'alpha' ? 'alpha' : 'recent', - rooms: parsed.rooms === 'alpha' ? 'alpha' : 'recent', - repeaters: parsed.repeaters === 'alpha' ? 'alpha' : 'recent', + // Only Favorites may persist the type-grouped orders. + favorites: coerceFavoritesSortOrder(parsed.favorites), + channels: coerceBasicSortOrder(parsed.channels), + contacts: coerceBasicSortOrder(parsed.contacts), + rooms: coerceBasicSortOrder(parsed.rooms), + repeaters: coerceBasicSortOrder(parsed.repeaters), }; } catch { return null;