Allow favorites to be sorted. Closes #91.

This commit is contained in:
Jack Kingsman
2026-03-19 17:05:34 -07:00
parent 5f8ce16855
commit 45ed430580
3 changed files with 88 additions and 23 deletions

View File

@@ -314,6 +314,36 @@ export function Sidebar({
[getContactHeardTime]
);
const getFavoriteItemName = useCallback(
(item: FavoriteItem) =>
item.type === 'channel'
? item.channel.name
: getContactDisplayName(item.contact.name, item.contact.public_key, item.contact.last_advert),
[]
);
const sortFavoriteItemsByOrder = useCallback(
(items: FavoriteItem[], order: SortOrder) =>
[...items].sort((a, b) => {
if (order === 'recent') {
const timeA =
a.type === 'channel'
? getLastMessageTime('channel', a.channel.key)
: getContactRecentTime(a.contact);
const timeB =
b.type === 'channel'
? getLastMessageTime('channel', b.channel.key)
: getContactRecentTime(b.contact);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
}
return getFavoriteItemName(a).localeCompare(getFavoriteItemName(b));
}),
[getContactRecentTime, getFavoriteItemName, getLastMessageTime]
);
// Split non-repeater contacts and repeater contacts into separate sorted lists
const sortedNonRepeaterContacts = useMemo(
() =>
@@ -461,27 +491,10 @@ export function Sidebar({
const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
].sort((a, b) => {
const timeA =
a.type === 'channel'
? getLastMessageTime('channel', a.channel.key)
: getContactRecentTime(a.contact);
const timeB =
b.type === 'channel'
? getLastMessageTime('channel', b.channel.key)
: getContactRecentTime(b.contact);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
const nameA =
a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
const nameB =
b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
return nameA.localeCompare(nameB);
});
];
return {
favoriteItems: items,
favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites),
nonFavoriteChannels: nonFavChannels,
nonFavoriteContacts: nonFavContacts,
nonFavoriteRepeaters: nonFavRepeaters,
@@ -493,6 +506,8 @@ export function Sidebar({
favorites,
getContactRecentTime,
getLastMessageTime,
sectionSortOrders.favorites,
sortFavoriteItemsByOrder,
]);
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
@@ -841,7 +856,7 @@ export function Sidebar({
'Favorites',
favoritesCollapsed,
() => setFavoritesCollapsed((prev) => !prev),
null,
'favorites',
favoritesUnreadCount,
favoritesHasMention
)}

View File

@@ -336,16 +336,17 @@ describe('Sidebar section summaries', () => {
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' }));
fireEvent.click(screen.getByRole('button', { name: 'Sort Contacts alphabetically' }));
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
unmount();
render(<Sidebar {...props} />);
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
});
@@ -466,4 +467,51 @@ describe('Sidebar section summaries', () => {
name: 'Public',
});
});
it('sorts favorites independently and persists the favorites sort preference', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
const props = {
contacts: [zed, amy],
channels: [publicChannel],
activeConversation: null,
onSelectConversation: vi.fn(),
onNewMessage: vi.fn(),
lastMessageTimes: {
[getStateKey('contact', zed.public_key)]: 200,
},
unreadCounts: {},
mentions: {},
showCracker: false,
crackerRunning: false,
onToggleCracker: vi.fn(),
onMarkAllRead: vi.fn(),
favorites: [
{ type: 'contact', id: zed.public_key },
{ type: 'contact', id: amy.public_key },
] satisfies Favorite[],
legacySortOrder: 'recent' as const,
};
const getFavoritesOrder = () =>
screen
.getAllByText(/^(Amy|Zed)$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
const { unmount } = render(<Sidebar {...props} />);
expect(getFavoritesOrder()).toEqual(['Zed', 'Amy']);
fireEvent.click(screen.getByRole('button', { name: 'Sort Favorites alphabetically' }));
expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);
unmount();
render(<Sidebar {...props} />);
expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);
});
});

View File

@@ -15,7 +15,7 @@ const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders'
export type ConversationTimes = Record<string, number>;
export type SortOrder = 'recent' | 'alpha';
export type SidebarSortableSection = 'channels' | 'contacts' | 'repeaters';
export type SidebarSortableSection = 'favorites' | 'channels' | 'contacts' | 'repeaters';
export type SidebarSectionSortOrders = Record<SidebarSortableSection, SortOrder>;
// In-memory cache of last message times (loaded from server on init)
@@ -113,6 +113,7 @@ export function buildSidebarSectionSortOrders(
defaultOrder: SortOrder = 'recent'
): SidebarSectionSortOrders {
return {
favorites: defaultOrder,
channels: defaultOrder,
contacts: defaultOrder,
repeaters: defaultOrder,
@@ -129,6 +130,7 @@ 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',
repeaters: parsed.repeaters === 'alpha' ? 'alpha' : 'recent',