Allow favorites to be sorted by type. Closes #314.

This commit is contained in:
Jack Kingsman
2026-07-08 14:57:29 -07:00
parent 2fcbefac3b
commit 84e6c34255
3 changed files with 158 additions and 24 deletions
+78 -17
View File
@@ -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({
<div className="ml-auto flex items-center gap-1.5">
{sortSection && sectionSortOrder && (
<button
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[0.625rem] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[0.625rem] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring whitespace-nowrap"
onClick={() => handleSortToggle(sortSection)}
aria-label={
sectionSortOrder === 'alpha'
? `Sort ${title} by recent`
: `Sort ${title} alphabetically`
}
title={
sectionSortOrder === 'alpha'
? `Sort ${title} by recent`
: `Sort ${title} alphabetically`
}
aria-label={`Sort ${title} ${sortOrderDescription(
nextSortOrder(sortSection, sectionSortOrder)
)}`}
title={`Currently sorted ${sortOrderDescription(
sectionSortOrder
)}. Click to sort ${sortOrderDescription(
nextSortOrder(sortSection, sectionSortOrder)
)}.`}
>
{sectionSortOrder === 'alpha' ? 'A-Z' : '⏱'}
{sortOrderLabel(sectionSortOrder)}
</button>
)}
{unreadCount > 0 && (
+59 -1
View File
@@ -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(<Sidebar {...props} />);
// 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();
});
});
+21 -6
View File
@@ -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<string, number>;
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<SidebarSortableSection, SortOrder>;
// 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<SidebarSectionSortOrders>;
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;