From 2b51f2c4a587d5af94a3eff1b55ccd472488a984 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Fri, 3 Jul 2026 13:19:48 -0700 Subject: [PATCH 1/3] feat(sidebar): add persistent Recent/A-Z channel sort toggle Adds a user-toggleable sort preference for the left-nav channel lists. One persisted preference (localStorage, scoped by pubkey + relay like channel sections) applied inside every grouping - Starred, each custom section, unassigned Channels, Forums, and DMs - without changing grouping boundaries, section assignments, or drag-and-drop. Recent sorts by lastMessageAt (newest first); channels with no parseable activity sink to the bottom alphabetically. A-Z is the default so existing ordering is preserved until the user opts in. The control is a small sort dropdown in the Channels section header action cluster, mirroring the inbox filter dropdown pattern. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../lib/channelSortPreference.test.mjs | 150 ++++++++++++++++++ .../sidebar/lib/channelSortPreference.ts | 105 ++++++++++++ .../sidebar/lib/dmSidebarSort.test.mjs | 55 ++++++- .../src/features/sidebar/lib/dmSidebarSort.ts | 48 +++++- .../sidebar/lib/useChannelSortPreference.ts | 63 ++++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 56 +++++-- .../sidebar/ui/ChannelSortDropdown.tsx | 67 ++++++++ .../sidebar/ui/CustomChannelSection.tsx | 8 + 8 files changed, 534 insertions(+), 18 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/channelSortPreference.test.mjs create mode 100644 desktop/src/features/sidebar/lib/channelSortPreference.ts create mode 100644 desktop/src/features/sidebar/lib/useChannelSortPreference.ts create mode 100644 desktop/src/features/sidebar/ui/ChannelSortDropdown.tsx diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs new file mode 100644 index 000000000..8c0f4f130 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_STORE, + parseChannelSortPayload, + sortChannelsForSidebar, +} from "./channelSortPreference.ts"; + +function makeChannel(id, name, lastMessageAt = null) { + return { + archivedAt: null, + channelType: "stream", + description: "", + id, + isMember: true, + lastMessageAt, + memberCount: 2, + memberPubkeys: [], + name, + participantPubkeys: [], + participants: [], + purpose: null, + topic: null, + ttlDeadline: null, + ttlSeconds: null, + visibility: "open", + }; +} + +// ── parseChannelSortPayload ────────────────────────────────────────────────── + +test("parseChannelSortPayload: valid alpha payload", () => { + assert.deepEqual(parseChannelSortPayload({ version: 1, mode: "alpha" }), { + version: 1, + mode: "alpha", + }); +}); + +test("parseChannelSortPayload: valid recent payload", () => { + assert.deepEqual(parseChannelSortPayload({ version: 1, mode: "recent" }), { + version: 1, + mode: "recent", + }); +}); + +test("parseChannelSortPayload: unknown mode returns null", () => { + assert.equal(parseChannelSortPayload({ version: 1, mode: "zorp" }), null); +}); + +test("parseChannelSortPayload: wrong version returns null", () => { + assert.equal(parseChannelSortPayload({ version: 2, mode: "alpha" }), null); +}); + +test("parseChannelSortPayload: non-object input returns null", () => { + assert.equal(parseChannelSortPayload(null), null); + assert.equal(parseChannelSortPayload("alpha"), null); + assert.equal(parseChannelSortPayload(42), null); +}); + +test("default store mode is alpha", () => { + assert.equal(DEFAULT_STORE.mode, "alpha"); +}); + +// ── sortChannelsForSidebar ─────────────────────────────────────────────────── + +test("alpha: sorts by name with id tie-breaker", () => { + const sorted = sortChannelsForSidebar( + [ + makeChannel("2", "zeta"), + makeChannel("1", "alpha"), + makeChannel("b", "same"), + makeChannel("a", "same"), + ], + "alpha", + ); + assert.deepEqual( + sorted.map((c) => c.id), + ["1", "a", "b", "2"], + ); +}); + +test("recent: newest last message first", () => { + const sorted = sortChannelsForSidebar( + [ + makeChannel("old", "old", "2026-01-01T00:00:00Z"), + makeChannel("new", "new", "2026-06-01T00:00:00Z"), + makeChannel("mid", "mid", "2026-03-01T00:00:00Z"), + ], + "recent", + ); + assert.deepEqual( + sorted.map((c) => c.id), + ["new", "mid", "old"], + ); +}); + +test("recent: channels without activity sink to bottom alphabetically", () => { + const sorted = sortChannelsForSidebar( + [ + makeChannel("quiet-z", "zzz"), + makeChannel("active", "active", "2026-06-01T00:00:00Z"), + makeChannel("quiet-a", "aaa"), + ], + "recent", + ); + assert.deepEqual( + sorted.map((c) => c.id), + ["active", "quiet-a", "quiet-z"], + ); +}); + +test("recent: equal timestamps fall back to name then id", () => { + const ts = "2026-06-01T00:00:00Z"; + const sorted = sortChannelsForSidebar( + [ + makeChannel("b", "same", ts), + makeChannel("a", "same", ts), + makeChannel("c", "aardvark", ts), + ], + "recent", + ); + assert.deepEqual( + sorted.map((c) => c.id), + ["c", "a", "b"], + ); +}); + +test("recent: unparseable timestamps are treated as no activity", () => { + const sorted = sortChannelsForSidebar( + [ + makeChannel("bad", "bad", "not-a-date"), + makeChannel("good", "good", "2026-06-01T00:00:00Z"), + ], + "recent", + ); + assert.deepEqual( + sorted.map((c) => c.id), + ["good", "bad"], + ); +}); + +test("does not mutate the input array", () => { + const input = [makeChannel("b", "bbb"), makeChannel("a", "aaa")]; + sortChannelsForSidebar(input, "alpha"); + assert.deepEqual( + input.map((c) => c.id), + ["b", "a"], + ); +}); diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts new file mode 100644 index 000000000..02887d177 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -0,0 +1,105 @@ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import type { Channel } from "@/shared/api/types"; + +const STORAGE_KEY_PREFIX = "buzz-channel-sort.v1"; + +export type ChannelSortMode = "alpha" | "recent"; + +export type ChannelSortStore = { + version: 1; + mode: ChannelSortMode; +}; + +export const DEFAULT_STORE: ChannelSortStore = Object.freeze({ + version: 1, + mode: "alpha", +}); + +/** + * Returns the localStorage key for the sidebar channel sort preference. + * + * When `relayUrl` is provided the key is scoped to that relay (normalized via + * the same `normalizeRelayUrl` used by all relay-scoped local stores) so the + * preference doesn't bleed across workspaces/relays. + */ +export function storageKey(pubkey: string, relayUrl?: string): string { + if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`; + const normalized = normalizeRelayUrl(relayUrl); + // Encode the normalized relay so it can't contain the `:` delimiter. + return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalized)}`; +} + +export function parseChannelSortPayload( + json: unknown, +): ChannelSortStore | null { + if (typeof json !== "object" || json === null) return null; + const obj = json as Record; + if (obj.version !== 1) return null; + if (obj.mode !== "alpha" && obj.mode !== "recent") return null; + return { version: 1, mode: obj.mode }; +} + +export function readChannelSortStore( + pubkey: string, + relayUrl?: string, +): ChannelSortStore { + try { + const raw = window.localStorage.getItem(storageKey(pubkey, relayUrl)); + if (!raw) return DEFAULT_STORE; + return parseChannelSortPayload(JSON.parse(raw)) ?? DEFAULT_STORE; + } catch { + return DEFAULT_STORE; + } +} + +export function writeChannelSortStore( + pubkey: string, + store: ChannelSortStore, + relayUrl?: string, +): boolean { + try { + window.localStorage.setItem( + storageKey(pubkey, relayUrl), + JSON.stringify(store), + ); + return true; + } catch { + return false; + } +} + +function channelRecencyMs(channel: Channel): number | null { + if (!channel.lastMessageAt) return null; + const ms = Date.parse(channel.lastMessageAt); + return Number.isFinite(ms) ? ms : null; +} + +export function compareChannelsByName(left: Channel, right: Channel): number { + return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); +} + +/** + * Sorts a single sidebar grouping's channels by the selected mode. + * + * `alpha` orders by name (id tie-breaker). `recent` orders by last message + * time, newest first; channels without any message activity sink to the + * bottom in alphabetical order so quiet channels stay stable and findable. + */ +export function sortChannelsForSidebar( + channels: Channel[], + mode: ChannelSortMode, +): Channel[] { + if (mode === "alpha") { + return [...channels].sort(compareChannelsByName); + } + return [...channels].sort((left, right) => { + const leftMs = channelRecencyMs(left); + const rightMs = channelRecencyMs(right); + if (leftMs !== null && rightMs !== null && leftMs !== rightMs) { + return rightMs - leftMs; + } + if (leftMs !== null && rightMs === null) return -1; + if (leftMs === null && rightMs !== null) return 1; + return compareChannelsByName(left, right); + }); +} diff --git a/desktop/src/features/sidebar/lib/dmSidebarSort.test.mjs b/desktop/src/features/sidebar/lib/dmSidebarSort.test.mjs index b37180454..f16e80f01 100644 --- a/desktop/src/features/sidebar/lib/dmSidebarSort.test.mjs +++ b/desktop/src/features/sidebar/lib/dmSidebarSort.test.mjs @@ -1,16 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { sortDmChannelsByLabel } from "./dmSidebarSort.ts"; +import { + sortDmChannelsByLabel, + sortDmChannelsForSidebar, +} from "./dmSidebarSort.ts"; -function makeDm(id, name) { +function makeDm(id, name, lastMessageAt = null) { return { archivedAt: null, channelType: "dm", description: "", id, isMember: true, - lastMessageAt: null, + lastMessageAt, memberCount: 2, memberPubkeys: [], name, @@ -70,3 +73,49 @@ test("uses channel id as a deterministic tie breaker", () => { ["a", "b"], ); }); + +test("sortDmChannelsForSidebar: alpha mode matches label sort", () => { + const sorted = sortDmChannelsForSidebar( + [makeDm("b", "Zed"), makeDm("a", "Amy")], + {}, + "alpha", + ); + + assert.deepEqual( + sorted.map((channel) => channel.id), + ["a", "b"], + ); +}); + +test("sortDmChannelsForSidebar: recent mode puts newest message first", () => { + const sorted = sortDmChannelsForSidebar( + [ + makeDm("old", "Amy", "2026-01-01T00:00:00Z"), + makeDm("new", "Zed", "2026-06-01T00:00:00Z"), + ], + {}, + "recent", + ); + + assert.deepEqual( + sorted.map((channel) => channel.id), + ["new", "old"], + ); +}); + +test("sortDmChannelsForSidebar: recent mode sinks quiet DMs in label order", () => { + const sorted = sortDmChannelsForSidebar( + [ + makeDm("quiet-z", "Zed"), + makeDm("active", "Amy", "2026-06-01T00:00:00Z"), + makeDm("quiet-a", "Bea"), + ], + {}, + "recent", + ); + + assert.deepEqual( + sorted.map((channel) => channel.id), + ["active", "quiet-a", "quiet-z"], + ); +}); diff --git a/desktop/src/features/sidebar/lib/dmSidebarSort.ts b/desktop/src/features/sidebar/lib/dmSidebarSort.ts index d372d9e54..34ef1b93f 100644 --- a/desktop/src/features/sidebar/lib/dmSidebarSort.ts +++ b/desktop/src/features/sidebar/lib/dmSidebarSort.ts @@ -1,14 +1,52 @@ import type { Channel } from "@/shared/api/types"; +import type { ChannelSortMode } from "@/features/sidebar/lib/channelSortPreference"; + +function compareDmChannelsByLabel( + left: Channel, + right: Channel, + channelLabels: Record, +): number { + const leftLabel = channelLabels[left.id] ?? left.name; + const rightLabel = channelLabels[right.id] ?? right.name; + return leftLabel.localeCompare(rightLabel) || left.id.localeCompare(right.id); +} export function sortDmChannelsByLabel( channels: Channel[], channelLabels: Record, ) { + return [...channels].sort((left, right) => + compareDmChannelsByLabel(left, right, channelLabels), + ); +} + +function dmRecencyMs(channel: Channel): number | null { + if (!channel.lastMessageAt) return null; + const ms = Date.parse(channel.lastMessageAt); + return Number.isFinite(ms) ? ms : null; +} + +/** + * Sorts sidebar DMs by the selected mode. `alpha` keeps the existing + * resolved-label ordering; `recent` orders by last message time, newest + * first, with quiet DMs sinking to the bottom in label order. + */ +export function sortDmChannelsForSidebar( + channels: Channel[], + channelLabels: Record, + mode: ChannelSortMode, +) { + if (mode === "alpha") { + return sortDmChannelsByLabel(channels, channelLabels); + } return [...channels].sort((left, right) => { - const leftLabel = channelLabels[left.id] ?? left.name; - const rightLabel = channelLabels[right.id] ?? right.name; - return ( - leftLabel.localeCompare(rightLabel) || left.id.localeCompare(right.id) - ); + const leftMs = dmRecencyMs(left); + const rightMs = dmRecencyMs(right); + if (leftMs !== null && rightMs !== null && leftMs !== rightMs) { + return rightMs - leftMs; + } + if (leftMs !== null && rightMs === null) return -1; + if (leftMs === null && rightMs !== null) return 1; + return compareDmChannelsByLabel(left, right, channelLabels); }); } diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts new file mode 100644 index 000000000..a6c43d4ad --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -0,0 +1,63 @@ +import * as React from "react"; + +import { + DEFAULT_STORE, + readChannelSortStore, + storageKey, + writeChannelSortStore, + type ChannelSortMode, + type ChannelSortStore, +} from "./channelSortPreference"; + +/** + * Persistent sidebar channel sort preference, scoped by pubkey + relay so it + * doesn't bleed across identities or workspaces (same scoping as channel + * sections). Mirrors changes made in other windows via the storage event. + */ +export function useChannelSortPreference( + pubkey: string | undefined, + relayUrl?: string, +): { + sortMode: ChannelSortMode; + setSortMode: (mode: ChannelSortMode) => void; +} { + const [store, setStore] = React.useState(() => { + if (!pubkey) return DEFAULT_STORE; + return readChannelSortStore(pubkey, relayUrl); + }); + + React.useEffect(() => { + if (!pubkey) { + setStore(DEFAULT_STORE); + return; + } + setStore(readChannelSortStore(pubkey, relayUrl)); + }, [pubkey, relayUrl]); + + React.useEffect(() => { + if (!pubkey) return; + const key = storageKey(pubkey, relayUrl); + const handler = (e: StorageEvent) => { + if (e.key !== key) return; + setStore(readChannelSortStore(pubkey, relayUrl)); + }; + window.addEventListener("storage", handler); + return () => { + window.removeEventListener("storage", handler); + }; + }, [pubkey, relayUrl]); + + const setSortMode = React.useCallback( + (mode: ChannelSortMode) => { + if (!pubkey) return; + setStore((prev) => { + const next: ChannelSortStore = { ...prev, mode }; + if (!writeChannelSortStore(pubkey, next, relayUrl)) return prev; + return next; + }); + }, + [pubkey, relayUrl], + ); + + return { sortMode: store.mode, setSortMode }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 98bbe2644..96eb96f15 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -15,7 +15,9 @@ import { } from "@/features/sidebar/lib/useChannelSections"; import { useActiveWorkingChannelsById } from "@/features/sidebar/lib/useActiveWorkingChannelsById"; import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata"; -import { sortDmChannelsByLabel } from "@/features/sidebar/lib/dmSidebarSort"; +import { sortDmChannelsForSidebar } from "@/features/sidebar/lib/dmSidebarSort"; +import { sortChannelsForSidebar } from "@/features/sidebar/lib/channelSortPreference"; +import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference"; import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; import { @@ -32,6 +34,7 @@ import { ChannelGroupSection, CustomChannelSection, } from "@/features/sidebar/ui/CustomChannelSection"; +import { ChannelSortDropdown } from "@/features/sidebar/ui/ChannelSortDropdown"; import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog"; import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard"; @@ -349,6 +352,11 @@ export function AppSidebar({ unassignChannel, } = useChannelSections(currentPubkey, activeWorkspace?.relayUrl); + const { sortMode, setSortMode } = useChannelSortPreference( + currentPubkey, + activeWorkspace?.relayUrl, + ); + const [createSectionState, setCreateSectionState] = React.useState<{ open: boolean; pendingChannelId: string | null; @@ -387,15 +395,33 @@ export function AppSidebar({ unassigned.push(channel); } } - return { bySection, unassigned }; - }, [streamChannels, channelSections, channelAssignments, starredChannelIds]); + // Apply the sort preference inside each grouping; section membership + // itself is untouched. + for (const sectionId of Object.keys(bySection)) { + bySection[sectionId] = sortChannelsForSidebar( + bySection[sectionId], + sortMode, + ); + } + return { + bySection, + unassigned: sortChannelsForSidebar(unassigned, sortMode), + }; + }, [ + streamChannels, + channelSections, + channelAssignments, + starredChannelIds, + sortMode, + ]); const starredChannels = React.useMemo(() => { if (!starredChannelIds || starredChannelIds.size === 0) return []; - return streamChannels.filter((channel) => - starredChannelIds.has(channel.id), + return sortChannelsForSidebar( + streamChannels.filter((channel) => starredChannelIds.has(channel.id)), + sortMode, ); - }, [streamChannels, starredChannelIds]); + }, [streamChannels, starredChannelIds, sortMode]); const handleCreateSectionForChannel = React.useCallback( (channelId: string) => { @@ -419,8 +445,12 @@ export function AppSidebar({ ); const forumChannels = React.useMemo( - () => channels.filter((channel) => channel.channelType === "forum"), - [channels], + () => + sortChannelsForSidebar( + channels.filter((channel) => channel.channelType === "forum"), + sortMode, + ), + [channels, sortMode], ); const directMessages = React.useMemo( () => channels.filter((channel) => channel.channelType === "dm"), @@ -442,8 +472,8 @@ export function AppSidebar({ profileDisplayName: profile?.displayName, }); const sortedDirectMessages = React.useMemo( - () => sortDmChannelsByLabel(directMessages, dmChannelLabels), - [directMessages, dmChannelLabels], + () => sortDmChannelsForSidebar(directMessages, dmChannelLabels, sortMode), + [directMessages, dmChannelLabels, sortMode], ); const sidebarLoadingShape = useSidebarLoadingShape({ activeWorkspaceId: activeWorkspace?.id, @@ -650,6 +680,12 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={sectionBuckets.unassigned} + leadingHeaderAction={ + + } listTestId="stream-list" onBrowseClick={onBrowseChannels} onCreateClick={() => openCreateDialog("stream")} diff --git a/desktop/src/features/sidebar/ui/ChannelSortDropdown.tsx b/desktop/src/features/sidebar/ui/ChannelSortDropdown.tsx new file mode 100644 index 000000000..5de04bfe1 --- /dev/null +++ b/desktop/src/features/sidebar/ui/ChannelSortDropdown.tsx @@ -0,0 +1,67 @@ +import { ArrowUpDown } from "lucide-react"; + +import type { ChannelSortMode } from "@/features/sidebar/lib/channelSortPreference"; +import { + SECTION_ACTION_VISIBILITY_CLASS, + SECTION_ICON_BUTTON_CLASS, +} from "@/features/sidebar/ui/sidebarSectionStyles"; +import { cn } from "@/shared/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +const SORT_OPTIONS: { value: ChannelSortMode; label: string }[] = [ + { value: "recent", label: "Recent" }, + { value: "alpha", label: "A–Z" }, +]; + +/** + * Section-header dropdown for the sidebar-wide channel sort preference. + * One preference, applied inside every grouping (Starred, custom sections, + * Channels, Forums, DMs) without changing grouping boundaries. + */ +export function ChannelSortDropdown({ + sortMode, + onSortModeChange, +}: { + sortMode: ChannelSortMode; + onSortModeChange: (mode: ChannelSortMode) => void; +}) { + const activeLabel = + SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? "A–Z"; + + return ( + + + + + + onSortModeChange(value as ChannelSortMode)} + value={sortMode} + > + {SORT_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + ); +} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 77c1a363c..6f88eb1dd 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -19,6 +19,8 @@ import { Trash2, } from "lucide-react"; +import type * as React from "react"; + import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { ContextMenu, @@ -254,6 +256,7 @@ function SectionHeaderActions({ browseAriaLabel, createAriaLabel, hasUnread, + leadingAction, onBrowseClick, onCreateClick, onMarkAllRead, @@ -261,12 +264,14 @@ function SectionHeaderActions({ browseAriaLabel?: string; createAriaLabel: string; hasUnread?: boolean; + leadingAction?: React.ReactNode; onBrowseClick?: () => void; onCreateClick?: () => void; onMarkAllRead?: () => void; }) { return (
+ {leadingAction} {hasUnread && onMarkAllRead ? (