mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
Allow favorites to be sorted by type. Closes #314.
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user