From 68fe04ef58f24f7d1796e55bc092364d9b957832 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:48:24 +0200 Subject: [PATCH] feat(web): add sortDmChannels utility with unread-first sorting --- packages/web/src/utils/dmSorting.test.ts | 84 ++++++++++++++++++++++++ packages/web/src/utils/dmSorting.ts | 35 ++++++++++ 2 files changed, 119 insertions(+) create mode 100644 packages/web/src/utils/dmSorting.test.ts create mode 100644 packages/web/src/utils/dmSorting.ts diff --git a/packages/web/src/utils/dmSorting.test.ts b/packages/web/src/utils/dmSorting.test.ts new file mode 100644 index 00000000..298b7fc7 --- /dev/null +++ b/packages/web/src/utils/dmSorting.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { sortDmChannels } from './dmSorting'; +import type { DmChannel } from '@backspace/shared'; + +function makeDm(id: string, lastMessageCreatedAt: number | null, createdAt = 1000): DmChannel { + return { + id, + createdAt, + members: [], + lastMessage: lastMessageCreatedAt != null + ? { id: `msg-${id}`, dmChannelId: id, userId: 'u1', content: 'test', createdAt: lastMessageCreatedAt } + : null, + }; +} + +describe('sortDmChannels', () => { + it('sorts by recency when no unreads', () => { + const dms = [ + makeDm('old', 100), + makeDm('new', 300), + makeDm('mid', 200), + ]; + const sorted = sortDmChannels(dms, new Set(), null); + expect(sorted.map(d => d.id)).toEqual(['new', 'mid', 'old']); + }); + + it('puts unread channels first', () => { + const dms = [ + makeDm('read-new', 300), + makeDm('unread-old', 100), + makeDm('read-mid', 200), + ]; + const sorted = sortDmChannels(dms, new Set(['unread-old']), null); + expect(sorted.map(d => d.id)).toEqual(['unread-old', 'read-new', 'read-mid']); + }); + + it('sorts within unread group by recency', () => { + const dms = [ + makeDm('unread-old', 100), + makeDm('unread-new', 300), + makeDm('read', 200), + ]; + const sorted = sortDmChannels(dms, new Set(['unread-old', 'unread-new']), null); + expect(sorted.map(d => d.id)).toEqual(['unread-new', 'unread-old', 'read']); + }); + + it('sorts within read group by recency', () => { + const dms = [ + makeDm('read-old', 100), + makeDm('read-new', 300), + makeDm('unread', 200), + ]; + const sorted = sortDmChannels(dms, new Set(['unread']), null); + expect(sorted.map(d => d.id)).toEqual(['unread', 'read-new', 'read-old']); + }); + + it('excludes current channel from unread group', () => { + const dms = [ + makeDm('current-unread', 100), + makeDm('other-unread', 200), + makeDm('read', 300), + ]; + // current-unread is in unreadChannels but is the active channel — treat as read + const sorted = sortDmChannels(dms, new Set(['current-unread', 'other-unread']), 'current-unread'); + expect(sorted.map(d => d.id)).toEqual(['other-unread', 'read', 'current-unread']); + }); + + it('falls back to createdAt when no lastMessage', () => { + const dms = [ + makeDm('no-msg-old', null, 100), + makeDm('no-msg-new', null, 300), + makeDm('has-msg', 200), + ]; + const sorted = sortDmChannels(dms, new Set(), null); + expect(sorted.map(d => d.id)).toEqual(['no-msg-new', 'has-msg', 'no-msg-old']); + }); + + it('does not mutate the input array', () => { + const dms = [makeDm('b', 100), makeDm('a', 200)]; + const original = [...dms]; + sortDmChannels(dms, new Set(), null); + expect(dms).toEqual(original); + }); +}); diff --git a/packages/web/src/utils/dmSorting.ts b/packages/web/src/utils/dmSorting.ts new file mode 100644 index 00000000..590d8cdd --- /dev/null +++ b/packages/web/src/utils/dmSorting.ts @@ -0,0 +1,35 @@ +import type { DmChannel } from '@backspace/shared'; + +/** + * Sort DM channels: unread first (by recency), then read (by recency). + * Does not mutate the input array. + * + * @param dmChannels - The DM channels to sort + * @param unreadChannels - Set of channel IDs that have unread messages + * @param currentChannelId - The currently active channel (excluded from unread group) + */ +export function sortDmChannels( + dmChannels: DmChannel[], + unreadChannels: Set, + currentChannelId: string | null, +): DmChannel[] { + const getTime = (dm: DmChannel) => dm.lastMessage?.createdAt ?? dm.createdAt; + const isUnread = (dm: DmChannel) => unreadChannels.has(dm.id) && dm.id !== currentChannelId; + + const unread: DmChannel[] = []; + const read: DmChannel[] = []; + + for (const dm of dmChannels) { + if (isUnread(dm)) { + unread.push(dm); + } else { + read.push(dm); + } + } + + const byRecency = (a: DmChannel, b: DmChannel) => getTime(b) - getTime(a); + unread.sort(byRecency); + read.sort(byRecency); + + return [...unread, ...read]; +}