diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 75de302e2..b1133386a 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -72,6 +72,7 @@ export default defineConfig({ "**/send-channel-binding.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", + "**/channel-sort.spec.ts", ], use: { ...devices["Desktop Chrome"], 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..ac558075f --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_SORT_MODE, + DEFAULT_STORE, + parseChannelSortPayload, + sectionSortGroupKey, + sortChannelsForSidebar, + sortModeForGroup, + stripOrphanedSectionModes, +} 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 per-group payload", () => { + assert.deepEqual( + parseChannelSortPayload({ + version: 1, + groups: { channels: "recent", dms: "alpha" }, + }), + { version: 1, groups: { channels: "recent", dms: "alpha" } }, + ); +}); + +test("parseChannelSortPayload: empty groups is valid", () => { + assert.deepEqual(parseChannelSortPayload({ version: 1, groups: {} }), { + version: 1, + groups: {}, + }); +}); + +test("parseChannelSortPayload: unknown modes are filtered out", () => { + assert.deepEqual( + parseChannelSortPayload({ + version: 1, + groups: { channels: "zorp", forums: "recent", dms: 42 }, + }), + { version: 1, groups: { forums: "recent" } }, + ); +}); + +test("parseChannelSortPayload: missing/invalid groups falls back to empty", () => { + assert.deepEqual(parseChannelSortPayload({ version: 1 }), { + version: 1, + groups: {}, + }); + assert.deepEqual(parseChannelSortPayload({ version: 1, groups: ["x"] }), { + version: 1, + groups: {}, + }); +}); + +test("parseChannelSortPayload: wrong version returns null", () => { + assert.equal( + parseChannelSortPayload({ version: 2, groups: { channels: "alpha" } }), + null, + ); +}); + +test("parseChannelSortPayload: non-object input returns null", () => { + assert.equal(parseChannelSortPayload(null), null); + assert.equal(parseChannelSortPayload("alpha"), null); + assert.equal(parseChannelSortPayload(42), null); +}); + +// ── sortModeForGroup / defaults ────────────────────────────────────────────── + +test("default sort mode is alpha and default store has no overrides", () => { + assert.equal(DEFAULT_SORT_MODE, "alpha"); + assert.deepEqual(DEFAULT_STORE.groups, {}); +}); + +test("sortModeForGroup: unset group falls back to alpha", () => { + assert.equal(sortModeForGroup(DEFAULT_STORE, "channels"), "alpha"); + assert.equal(sortModeForGroup(DEFAULT_STORE, "dms"), "alpha"); +}); + +test("sortModeForGroup: set groups are independent", () => { + const store = { + version: 1, + groups: { channels: "recent", [sectionSortGroupKey("abc")]: "recent" }, + }; + assert.equal(sortModeForGroup(store, "channels"), "recent"); + assert.equal(sortModeForGroup(store, sectionSortGroupKey("abc")), "recent"); + assert.equal(sortModeForGroup(store, "forums"), "alpha"); + assert.equal(sortModeForGroup(store, sectionSortGroupKey("xyz")), "alpha"); +}); + +test("sectionSortGroupKey: namespaced by section id", () => { + assert.equal(sectionSortGroupKey("abc"), "section:abc"); +}); + +// ── stripOrphanedSectionModes ──────────────────────────────────────────────── + +test("stripOrphanedSectionModes: drops modes for deleted sections", () => { + const store = { + version: 1, + groups: { + channels: "recent", + [sectionSortGroupKey("live")]: "recent", + [sectionSortGroupKey("deleted")]: "alpha", + }, + }; + assert.deepEqual(stripOrphanedSectionModes(store, ["live"]), { + version: 1, + groups: { channels: "recent", [sectionSortGroupKey("live")]: "recent" }, + }); +}); + +test("stripOrphanedSectionModes: fixed groups survive with no live sections", () => { + const store = { + version: 1, + groups: { + starred: "recent", + channels: "alpha", + forums: "recent", + dms: "recent", + [sectionSortGroupKey("gone")]: "recent", + }, + }; + assert.deepEqual(stripOrphanedSectionModes(store, []), { + version: 1, + groups: { + starred: "recent", + channels: "alpha", + forums: "recent", + dms: "recent", + }, + }); +}); + +test("stripOrphanedSectionModes: returns same reference when nothing is stale", () => { + const store = { + version: 1, + groups: { channels: "recent", [sectionSortGroupKey("live")]: "recent" }, + }; + assert.equal(stripOrphanedSectionModes(store, ["live", "other"]), store); +}); + +test("stripOrphanedSectionModes: does not mutate the input store", () => { + const store = { + version: 1, + groups: { [sectionSortGroupKey("gone")]: "recent" }, + }; + stripOrphanedSectionModes(store, []); + assert.deepEqual(store.groups, { [sectionSortGroupKey("gone")]: "recent" }); +}); + +// ── 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..ff8b9677e --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -0,0 +1,159 @@ +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"; + +/** + * Key identifying a sidebar grouping that carries its own sort preference. + * Fixed groups use their name; custom sections use `section:`. + */ +export type ChannelSortGroupKey = + | "starred" + | "channels" + | "forums" + | "dms" + | `section:${string}`; + +export type ChannelSortStore = { + version: 1; + groups: Record; +}; + +export const DEFAULT_SORT_MODE: ChannelSortMode = "alpha"; + +export const DEFAULT_STORE: ChannelSortStore = Object.freeze({ + version: 1, + groups: {}, +}); + +export function sectionSortGroupKey(sectionId: string): ChannelSortGroupKey { + return `section:${sectionId}`; +} + +/** + * Returns the localStorage key for the sidebar channel sort preferences. + * + * When `relayUrl` is provided the key is scoped to that relay (normalized via + * the same `normalizeRelayUrl` used by all relay-scoped local stores) so + * preferences don'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)}`; +} + +/** + * Drops per-section sort modes whose custom section no longer exists so + * deleted sections don't leave stale `section:` keys in localStorage + * forever. Fixed group keys (starred/channels/forums/dms) are always kept. + * Returns the same store reference when nothing needs stripping. + */ +export function stripOrphanedSectionModes( + store: ChannelSortStore, + liveSectionIds: Iterable, +): ChannelSortStore { + const liveKeys = new Set( + [...liveSectionIds].map((id) => sectionSortGroupKey(id)), + ); + const kept = Object.entries(store.groups).filter( + ([key]) => !key.startsWith("section:") || liveKeys.has(key), + ); + if (kept.length === Object.keys(store.groups).length) return store; + return { ...store, groups: Object.fromEntries(kept) }; +} + +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; + const groups: Record = + typeof obj.groups === "object" && + obj.groups !== null && + !Array.isArray(obj.groups) + ? Object.fromEntries( + Object.entries(obj.groups as Record).filter( + (entry): entry is [string, ChannelSortMode] => + entry[1] === "alpha" || entry[1] === "recent", + ), + ) + : {}; + return { version: 1, groups }; +} + +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; + } +} + +export function sortModeForGroup( + store: ChannelSortStore, + group: ChannelSortGroupKey, +): ChannelSortMode { + return store.groups[group] ?? DEFAULT_SORT_MODE; +} + +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..6b23c1b3b --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { + DEFAULT_STORE, + readChannelSortStore, + sortModeForGroup, + storageKey, + stripOrphanedSectionModes, + writeChannelSortStore, + type ChannelSortGroupKey, + type ChannelSortMode, + type ChannelSortStore, +} from "./channelSortPreference"; + +/** + * Persistent per-group sidebar sort preferences, scoped by pubkey + relay so + * they don't bleed across identities or workspaces (same scoping as channel + * sections). Each sidebar grouping (starred, channels, forums, dms, and each + * custom section) carries its own saved Recent/A–Z mode; unset groups default + * to A–Z. Mirrors changes made in other windows via the storage event. + * + * When `liveSectionIds` is provided, writes also prune `section:` entries + * whose custom section no longer exists, so deleted sections don't leave + * stale keys in localStorage. + */ +export function useChannelSortPreference( + pubkey: string | undefined, + relayUrl?: string, + liveSectionIds?: string[], +): { + sortModeFor: (group: ChannelSortGroupKey) => ChannelSortMode; + setSortModeFor: (group: ChannelSortGroupKey, 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 sortModeFor = React.useCallback( + (group: ChannelSortGroupKey) => sortModeForGroup(store, group), + [store], + ); + + const setSortModeFor = React.useCallback( + (group: ChannelSortGroupKey, mode: ChannelSortMode) => { + if (!pubkey) return; + setStore((prev) => { + const withUpdate: ChannelSortStore = { + ...prev, + groups: { ...prev.groups, [group]: mode }, + }; + // Prune sort modes left behind by deleted custom sections on write so + // the stored map can't grow unboundedly with stale `section:` keys. + const next = liveSectionIds + ? stripOrphanedSectionModes(withUpdate, liveSectionIds) + : withUpdate; + if (!writeChannelSortStore(pubkey, next, relayUrl)) return prev; + return next; + }); + }, + [pubkey, relayUrl, liveSectionIds], + ); + + return { sortModeFor, setSortModeFor }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 98bbe2644..a4f3b4d6a 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -15,7 +15,12 @@ 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 { + sectionSortGroupKey, + 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 +37,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 +355,17 @@ export function AppSidebar({ unassignChannel, } = useChannelSections(currentPubkey, activeWorkspace?.relayUrl); + const sectionIds = React.useMemo( + () => channelSections.map((s) => s.id), + [channelSections], + ); + + const { sortModeFor, setSortModeFor } = useChannelSortPreference( + currentPubkey, + activeWorkspace?.relayUrl, + sectionIds, + ); + const [createSectionState, setCreateSectionState] = React.useState<{ open: boolean; pendingChannelId: string | null; @@ -360,11 +377,6 @@ export function AppSidebar({ const { requestLeaveChannel, dialog: leaveChannelDialog } = useLeaveChannelDialog(); - const sectionIds = React.useMemo( - () => channelSections.map((s) => s.id), - [channelSections], - ); - const streamChannels = React.useMemo( () => channels.filter((channel) => channel.channelType === "stream"), [channels], @@ -387,15 +399,33 @@ export function AppSidebar({ unassigned.push(channel); } } - return { bySection, unassigned }; - }, [streamChannels, channelSections, channelAssignments, starredChannelIds]); + // Apply each grouping's own sort preference; section membership itself + // is untouched. + for (const sectionId of Object.keys(bySection)) { + bySection[sectionId] = sortChannelsForSidebar( + bySection[sectionId], + sortModeFor(sectionSortGroupKey(sectionId)), + ); + } + return { + bySection, + unassigned: sortChannelsForSidebar(unassigned, sortModeFor("channels")), + }; + }, [ + streamChannels, + channelSections, + channelAssignments, + starredChannelIds, + sortModeFor, + ]); 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)), + sortModeFor("starred"), ); - }, [streamChannels, starredChannelIds]); + }, [streamChannels, starredChannelIds, sortModeFor]); const handleCreateSectionForChannel = React.useCallback( (channelId: string) => { @@ -419,8 +449,12 @@ export function AppSidebar({ ); const forumChannels = React.useMemo( - () => channels.filter((channel) => channel.channelType === "forum"), - [channels], + () => + sortChannelsForSidebar( + channels.filter((channel) => channel.channelType === "forum"), + sortModeFor("forums"), + ), + [channels, sortModeFor], ); const directMessages = React.useMemo( () => channels.filter((channel) => channel.channelType === "dm"), @@ -442,8 +476,13 @@ export function AppSidebar({ profileDisplayName: profile?.displayName, }); const sortedDirectMessages = React.useMemo( - () => sortDmChannelsByLabel(directMessages, dmChannelLabels), - [directMessages, dmChannelLabels], + () => + sortDmChannelsForSidebar( + directMessages, + dmChannelLabels, + sortModeFor("dms"), + ), + [directMessages, dmChannelLabels, sortModeFor], ); const sidebarLoadingShape = useSidebarLoadingShape({ activeWorkspaceId: activeWorkspace?.id, @@ -561,6 +600,16 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={starredChannels} + leadingHeaderAction={ + + setSortModeFor("starred", mode) + } + sortMode={sortModeFor("starred")} + testId="channel-sort-trigger-starred" + /> + } listTestId="starred-list" onMarkAllRead={() => { for (const channel of starredChannels) { @@ -612,6 +661,22 @@ export function AppSidebar({ assignments={channelAssignments} isFirst={idx === 0} isLast={idx === channelSections.length - 1} + leadingHeaderAction={ + + setSortModeFor( + sectionSortGroupKey(section.id), + mode, + ) + } + sortMode={sortModeFor( + sectionSortGroupKey(section.id), + )} + testId={`channel-sort-trigger-section-${section.id}`} + visibilityClassName="" + /> + } onToggleCollapsed={() => toggleCollapsedSection(section.id) } @@ -650,6 +715,16 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={sectionBuckets.unassigned} + leadingHeaderAction={ + + setSortModeFor("channels", mode) + } + sortMode={sortModeFor("channels")} + testId="channel-sort-trigger-channels" + /> + } listTestId="stream-list" onBrowseClick={onBrowseChannels} onCreateClick={() => openCreateDialog("stream")} @@ -684,6 +759,16 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={forumChannels} + leadingHeaderAction={ + + setSortModeFor("forums", mode) + } + sortMode={sortModeFor("forums")} + testId="channel-sort-trigger-forums" + /> + } listTestId="forum-list" onCreateClick={() => openCreateDialog("forum")} onMarkAllRead={onMarkAllChannelsRead} @@ -703,6 +788,12 @@ export function AppSidebar({ + setSortModeFor("dms", mode)} + sortMode={sortModeFor("dms")} + testId="channel-sort-trigger-dms" + /> + + + 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..d0e487405 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 ? (