diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index f5d2309..4de31d7 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 )} diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index dde9e63..e889c6e 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -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(); 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(); + + expect(getFavoritesOrder()).toEqual(['Zed', 'Amy']); + + fireEvent.click(screen.getByRole('button', { name: 'Sort Favorites alphabetically' })); + + expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']); + + unmount(); + render(); + + expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']); + }); }); diff --git a/frontend/src/utils/conversationState.ts b/frontend/src/utils/conversationState.ts index 6a42156..df6d8f0 100644 --- a/frontend/src/utils/conversationState.ts +++ b/frontend/src/utils/conversationState.ts @@ -15,7 +15,7 @@ const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders' export type ConversationTimes = Record; export type SortOrder = 'recent' | 'alpha'; -export type SidebarSortableSection = 'channels' | 'contacts' | 'repeaters'; +export type SidebarSortableSection = 'favorites' | 'channels' | 'contacts' | 'repeaters'; export type SidebarSectionSortOrders = Record; // 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; 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',