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;