Fix sidebar ordering for contacts by advert. Closes #69.

This commit is contained in:
Jack Kingsman
2026-03-17 21:04:57 -07:00
parent e33bc553f5
commit 4d5f0087cc
2 changed files with 138 additions and 17 deletions

View File

@@ -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,
]);

View File

@@ -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> = {}
): 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(<Sidebar {...props} />);
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(
<Sidebar
contacts={[noRecency, advertOnly, dmRecent]}
channels={[publicChannel]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{
[getStateKey('contact', dmRecent.public_key)]: 400,
}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
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(
<Sidebar
contacts={[staleMessageRelay, freshAdvertRelay]}
channels={[publicChannel]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{
[getStateKey('contact', staleMessageRelay.public_key)]: 1000,
[getStateKey('contact', freshAdvertRelay.public_key)]: 50,
}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
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', () => {