diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 44623ea..3d5bb2e 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -216,6 +216,20 @@ export function Sidebar({ [lastMessageTimes] ); + const getContactHeardTime = useCallback((contact: Contact): number => { + return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0); + }, []); + + const getContactRecentTime = useCallback( + (contact: Contact): number => { + if (contact.type === CONTACT_TYPE_REPEATER) { + return getContactHeardTime(contact); + } + return getLastMessageTime('contact', contact.public_key) || getContactHeardTime(contact); + }, + [getContactHeardTime, getLastMessageTime] + ); + // Deduplicate channels by key only. // Channel names are not unique; distinct keys must remain visible. const uniqueChannels = useMemo( @@ -274,15 +288,30 @@ export function Sidebar({ (items: Contact[], order: SortOrder) => [...items].sort((a, b) => { if (order === 'recent') { - const timeA = getLastMessageTime('contact', a.public_key); - const timeB = getLastMessageTime('contact', b.public_key); + const timeA = getContactRecentTime(a); + const timeB = getContactRecentTime(b); if (timeA && timeB) return timeB - timeA; if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; } return (a.name || a.public_key).localeCompare(b.name || b.public_key); }), - [getLastMessageTime] + [getContactRecentTime] + ); + + const sortRepeatersByOrder = useCallback( + (items: Contact[], order: SortOrder) => + [...items].sort((a, b) => { + if (order === 'recent') { + const timeA = getContactHeardTime(a); + const timeB = getContactHeardTime(b); + if (timeA && timeB) return timeB - timeA; + if (timeA && !timeB) return -1; + if (!timeA && timeB) return 1; + } + return (a.name || a.public_key).localeCompare(b.name || b.public_key); + }), + [getContactHeardTime] ); // Split non-repeater contacts and repeater contacts into separate sorted lists @@ -297,11 +326,11 @@ export function Sidebar({ const sortedRepeaters = useMemo( () => - sortContactsByOrder( + sortRepeatersByOrder( uniqueContacts.filter((c) => c.type === CONTACT_TYPE_REPEATER), sectionSortOrders.repeaters ), - [uniqueContacts, sectionSortOrders.repeaters, sortContactsByOrder] + [uniqueContacts, sectionSortOrders.repeaters, sortRepeatersByOrder] ); // Filter by search query @@ -436,11 +465,11 @@ export function Sidebar({ const timeA = a.type === 'channel' ? getLastMessageTime('channel', a.channel.key) - : getLastMessageTime('contact', a.contact.public_key); + : getContactRecentTime(a.contact); const timeB = b.type === 'channel' ? getLastMessageTime('channel', b.channel.key) - : getLastMessageTime('contact', b.contact.public_key); + : getContactRecentTime(b.contact); if (timeA && timeB) return timeB - timeA; if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; @@ -462,6 +491,7 @@ export function Sidebar({ filteredNonRepeaterContacts, filteredRepeaters, favorites, + getContactRecentTime, getLastMessageTime, ]); diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index 42424c0..5a1fa1f 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -16,7 +16,12 @@ function makeChannel(key: string, name: string): Channel { }; } -function makeContact(public_key: string, name: string, type = 1): Contact { +function makeContact( + public_key: string, + name: string, + type = 1, + overrides: Partial = {} +): Contact { return { public_key, name, @@ -33,6 +38,7 @@ function makeContact(public_key: string, name: string, type = 1): Contact { last_contacted: null, last_read_at: null, first_seen: null, + ...overrides, }; } @@ -257,10 +263,14 @@ describe('Sidebar section summaries', () => { const publicChannel = makeChannel('AA'.repeat(16), 'Public'); const zebraChannel = makeChannel('BB'.repeat(16), '#zebra'); const alphaChannel = makeChannel('CC'.repeat(16), '#alpha'); - const zed = makeContact('11'.repeat(32), 'Zed'); + const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 }); const amy = makeContact('22'.repeat(32), 'Amy'); - const relayZulu = makeContact('33'.repeat(32), 'Zulu Relay', CONTACT_TYPE_REPEATER); - const relayAlpha = makeContact('44'.repeat(32), 'Alpha Relay', CONTACT_TYPE_REPEATER); + const relayZulu = makeContact('33'.repeat(32), 'Zulu Relay', CONTACT_TYPE_REPEATER, { + last_seen: 100, + }); + const relayAlpha = makeContact('44'.repeat(32), 'Alpha Relay', CONTACT_TYPE_REPEATER, { + last_seen: 300, + }); const props = { contacts: [zed, amy, relayZulu, relayAlpha], @@ -272,9 +282,6 @@ describe('Sidebar section summaries', () => { [getStateKey('channel', zebraChannel.key)]: 300, [getStateKey('channel', alphaChannel.key)]: 100, [getStateKey('contact', zed.public_key)]: 200, - [getStateKey('contact', amy.public_key)]: 100, - [getStateKey('contact', relayZulu.public_key)]: 300, - [getStateKey('contact', relayAlpha.public_key)]: 100, }, unreadCounts: {}, mentions: {}, @@ -302,20 +309,104 @@ describe('Sidebar section summaries', () => { expect(getChannelsOrder()).toEqual(['#zebra', '#alpha']); expect(getContactsOrder()).toEqual(['Zed', 'Amy']); - expect(getRepeatersOrder()).toEqual(['Zulu Relay', 'Alpha Relay']); + expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']); fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' })); expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']); expect(getContactsOrder()).toEqual(['Zed', 'Amy']); - expect(getRepeatersOrder()).toEqual(['Zulu Relay', 'Alpha Relay']); + expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']); unmount(); render(); expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']); expect(getContactsOrder()).toEqual(['Zed', 'Amy']); - expect(getRepeatersOrder()).toEqual(['Zulu Relay', 'Alpha Relay']); + expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']); + }); + + it('sorts contacts by DM recency first, then advert recency, then no-recency at the bottom', () => { + const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public'); + const dmRecent = makeContact('11'.repeat(32), 'DM Recent', 1, { last_advert: 100 }); + const advertOnly = makeContact('22'.repeat(32), 'Advert Only', 1, { last_seen: 300 }); + const noRecency = makeContact('33'.repeat(32), 'No Recency'); + + render( + + ); + + const contactRows = screen + .getAllByText(/^(DM Recent|Advert Only|No Recency)$/) + .map((node) => node.textContent) + .filter((text): text is string => Boolean(text)); + + expect(contactRows).toEqual(['DM Recent', 'Advert Only', 'No Recency']); + }); + + it('sorts repeaters by heard recency even when message times disagree', () => { + const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public'); + const staleMessageRelay = makeContact( + '44'.repeat(32), + 'Stale Message Relay', + CONTACT_TYPE_REPEATER, + { + last_seen: 100, + } + ); + const freshAdvertRelay = makeContact( + '55'.repeat(32), + 'Fresh Advert Relay', + CONTACT_TYPE_REPEATER, + { + last_advert: 500, + } + ); + + render( + + ); + + const repeaterRows = screen + .getAllByText(/Relay$/) + .map((node) => node.textContent) + .filter((text): text is string => Boolean(text)); + + expect(repeaterRows).toEqual(['Fresh Advert Relay', 'Stale Message Relay']); }); it('pins only the canonical Public channel to the top of channel sorting', () => {