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
+35 -20
View File
@@ -314,6 +314,36 @@ export function Sidebar({
[getContactHeardTime] [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 // Split non-repeater contacts and repeater contacts into separate sorted lists
const sortedNonRepeaterContacts = useMemo( const sortedNonRepeaterContacts = useMemo(
() => () =>
@@ -461,27 +491,10 @@ export function Sidebar({
const items: FavoriteItem[] = [ const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })), ...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
...favContacts.map((contact) => ({ type: 'contact' as const, contact })), ...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 { return {
favoriteItems: items, favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites),
nonFavoriteChannels: nonFavChannels, nonFavoriteChannels: nonFavChannels,
nonFavoriteContacts: nonFavContacts, nonFavoriteContacts: nonFavContacts,
nonFavoriteRepeaters: nonFavRepeaters, nonFavoriteRepeaters: nonFavRepeaters,
@@ -493,6 +506,8 @@ export function Sidebar({
favorites, favorites,
getContactRecentTime, getContactRecentTime,
getLastMessageTime, getLastMessageTime,
sectionSortOrders.favorites,
sortFavoriteItemsByOrder,
]); ]);
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({ const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
@@ -841,7 +856,7 @@ export function Sidebar({
'Favorites', 'Favorites',
favoritesCollapsed, favoritesCollapsed,
() => setFavoritesCollapsed((prev) => !prev), () => setFavoritesCollapsed((prev) => !prev),
null, 'favorites',
favoritesUnreadCount, favoritesUnreadCount,
favoritesHasMention favoritesHasMention
)} )}
+50 -2
View File
@@ -336,16 +336,17 @@ describe('Sidebar section summaries', () => {
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']); expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' })); fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' }));
fireEvent.click(screen.getByRole('button', { name: 'Sort Contacts alphabetically' }));
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']); expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']); expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']); expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
unmount(); unmount();
render(<Sidebar {...props} />); render(<Sidebar {...props} />);
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']); expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']); expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']); expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
}); });
@@ -466,4 +467,51 @@ describe('Sidebar section summaries', () => {
name: 'Public', 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']);
});
}); });
+3 -1
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 ConversationTimes = Record<string, number>;
export type SortOrder = 'recent' | 'alpha'; export type SortOrder = 'recent' | 'alpha';
export type SidebarSortableSection = 'channels' | 'contacts' | 'repeaters'; export type SidebarSortableSection = 'favorites' | 'channels' | 'contacts' | 'repeaters';
export type SidebarSectionSortOrders = Record<SidebarSortableSection, SortOrder>; export type SidebarSectionSortOrders = Record<SidebarSortableSection, SortOrder>;
// In-memory cache of last message times (loaded from server on init) // In-memory cache of last message times (loaded from server on init)
@@ -113,6 +113,7 @@ export function buildSidebarSectionSortOrders(
defaultOrder: SortOrder = 'recent' defaultOrder: SortOrder = 'recent'
): SidebarSectionSortOrders { ): SidebarSectionSortOrders {
return { return {
favorites: defaultOrder,
channels: defaultOrder, channels: defaultOrder,
contacts: defaultOrder, contacts: defaultOrder,
repeaters: defaultOrder, repeaters: defaultOrder,
@@ -129,6 +130,7 @@ export function loadLocalStorageSidebarSectionSortOrders(): SidebarSectionSortOr
const parsed = JSON.parse(stored) as Partial<SidebarSectionSortOrders>; const parsed = JSON.parse(stored) as Partial<SidebarSectionSortOrders>;
return { return {
favorites: parsed.favorites === 'alpha' ? 'alpha' : 'recent',
channels: parsed.channels === 'alpha' ? 'alpha' : 'recent', channels: parsed.channels === 'alpha' ? 'alpha' : 'recent',
contacts: parsed.contacts === 'alpha' ? 'alpha' : 'recent', contacts: parsed.contacts === 'alpha' ? 'alpha' : 'recent',
repeaters: parsed.repeaters === 'alpha' ? 'alpha' : 'recent', repeaters: parsed.repeaters === 'alpha' ? 'alpha' : 'recent',