feat(channel list): add persistent channel sort toggle (#1505)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-07-06 16:31:22 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 5f7f774ea1
commit 36ac5243ff
10 changed files with 957 additions and 23 deletions
+1
View File
@@ -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"],
@@ -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"],
);
});
@@ -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:<sectionId>`.
*/
export type ChannelSortGroupKey =
| "starred"
| "channels"
| "forums"
| "dms"
| `section:${string}`;
export type ChannelSortStore = {
version: 1;
groups: Record<string, ChannelSortMode>;
};
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:<id>` 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<string>,
): ChannelSortStore {
const liveKeys = new Set<string>(
[...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<string, unknown>;
if (obj.version !== 1) return null;
const groups: Record<string, ChannelSortMode> =
typeof obj.groups === "object" &&
obj.groups !== null &&
!Array.isArray(obj.groups)
? Object.fromEntries(
Object.entries(obj.groups as Record<string, unknown>).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);
});
}
@@ -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"],
);
});
@@ -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<string, string>,
): 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<string, string>,
) {
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<string, string>,
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);
});
}
@@ -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/AZ mode; unset groups default
* to AZ. Mirrors changes made in other windows via the storage event.
*
* When `liveSectionIds` is provided, writes also prune `section:<id>` 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<ChannelSortStore>(() => {
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 };
}
+106 -15
View File
@@ -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={
<ChannelSortDropdown
groupLabel="starred channels"
onSortModeChange={(mode) =>
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={
<ChannelSortDropdown
groupLabel={section.name}
onSortModeChange={(mode) =>
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={
<ChannelSortDropdown
groupLabel="channels"
onSortModeChange={(mode) =>
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={
<ChannelSortDropdown
groupLabel="forums"
onSortModeChange={(mode) =>
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({
<SidebarSection
action={
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
<ChannelSortDropdown
groupLabel="direct messages"
onSortModeChange={(mode) => setSortModeFor("dms", mode)}
sortMode={sortModeFor("dms")}
testId="channel-sort-trigger-dms"
/>
<button
aria-expanded={isNewDmOpen}
aria-label="Compose new message"
@@ -0,0 +1,72 @@
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: "AZ" },
];
/**
* Section-header dropdown for a single sidebar grouping's sort preference.
* Every grouping (Starred, each custom section, Channels, Forums, DMs)
* carries its own control and saved mode; grouping boundaries are untouched.
*/
export function ChannelSortDropdown({
groupLabel,
sortMode,
onSortModeChange,
testId,
visibilityClassName = SECTION_ACTION_VISIBILITY_CLASS,
}: {
groupLabel: string;
sortMode: ChannelSortMode;
onSortModeChange: (mode: ChannelSortMode) => void;
testId?: string;
visibilityClassName?: string;
}) {
const activeLabel =
SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? "AZ";
const ariaLabel = `Sort ${groupLabel}: ${activeLabel}`;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={ariaLabel}
className={cn(SECTION_ICON_BUTTON_CLASS, visibilityClassName)}
data-testid={testId ?? "channel-sort-trigger"}
onClick={(e) => e.stopPropagation()}
title={ariaLabel}
type="button"
>
<ArrowUpDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuRadioGroup
onValueChange={(value) => onSortModeChange(value as ChannelSortMode)}
value={sortMode}
>
{SORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -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 (
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
{leadingAction}
{hasUnread && onMarkAllRead ? (
<button
aria-label="Mark all as read"
@@ -322,6 +327,7 @@ export function ChannelGroupSection({
isActiveChannel,
activeWorkingByChannelId,
items,
leadingHeaderAction,
listTestId,
onBrowseClick,
onCreateClick,
@@ -355,6 +361,7 @@ export function ChannelGroupSection({
isActiveChannel: boolean;
activeWorkingByChannelId?: ReadonlyMap<string, ActiveChannelTurnSummary>;
items: Channel[];
leadingHeaderAction?: React.ReactNode;
listTestId: string;
onBrowseClick?: () => void;
onCreateClick?: () => void;
@@ -479,6 +486,7 @@ export function ChannelGroupSection({
browseAriaLabel={browseAriaLabel}
createAriaLabel={createAriaLabel}
hasUnread={hasUnread}
leadingAction={leadingHeaderAction}
onBrowseClick={onBrowseClick}
onCreateClick={onCreateClick}
onMarkAllRead={onMarkAllRead}
@@ -511,6 +519,7 @@ export function CustomChannelSection({
assignments,
isFirst,
isLast,
leadingHeaderAction,
onToggleCollapsed,
onSelectChannel,
onMarkChannelRead,
@@ -544,6 +553,7 @@ export function CustomChannelSection({
assignments: Record<string, string>;
isFirst: boolean;
isLast: boolean;
leadingHeaderAction?: React.ReactNode;
onToggleCollapsed: () => void;
onSelectChannel: (channelId: string) => void;
onMarkChannelRead: (
@@ -633,6 +643,7 @@ export function CustomChannelSection({
SECTION_ACTION_VISIBILITY_CLASS,
)}
>
{leadingHeaderAction}
{hasUnread ? (
<button
aria-label="Mark all as read"
+171
View File
@@ -0,0 +1,171 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/channel-sort";
// Mock-mode current-user pubkey and relay (see e2eBridge DEFAULT_MOCK_PUBKEY /
// DEFAULT_RELAY_WS_URL). Sort preferences persist under the relay-scoped key
// buzz-channel-sort.v1:<pubkey>:<encoded-relay>.
const MOCK_PUBKEY = "deadbeef".repeat(8);
const MOCK_RELAY_ENCODED = encodeURIComponent("ws://localhost:3000");
const SORT_STORAGE_KEY = `buzz-channel-sort.v1:${MOCK_PUBKEY}:${MOCK_RELAY_ENCODED}`;
function seedSortState(page: Page, groups: Record<string, string>) {
return page.addInitScript(
({ key, groups }) => {
window.localStorage.setItem(key, JSON.stringify({ version: 1, groups }));
},
{ key: SORT_STORAGE_KEY, groups },
);
}
async function openApp(page: Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
function streamNames(page: Page) {
return page
.getByTestId("stream-list")
.locator("[data-testid^='channel-']")
.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute("data-testid") ?? "")
.filter(
(id) =>
!id.startsWith("channel-unread") &&
!id.startsWith("channel-working") &&
!id.startsWith("channel-dm-count"),
)
.map((id) => id.replace(/^channel-/, "")),
);
}
test.describe("per-group channel sort", () => {
test("01 — Channels group defaults to AZ", async ({ page }) => {
await installMockBridge(page);
await openApp(page);
const names = await streamNames(page);
const sorted = [...names].sort((a, b) => a.localeCompare(b));
expect(names).toEqual(sorted);
});
test("02 — sort trigger switches Channels to Recent and persists", async ({
page,
}) => {
await installMockBridge(page);
await openApp(page);
// Hover the Channels header to reveal the action cluster, then open the
// sort dropdown and choose Recent.
const streamList = page.getByTestId("stream-list");
await expect(streamList).toBeVisible();
await page.getByText("Channels", { exact: true }).hover();
const trigger = page.getByTestId("channel-sort-trigger-channels");
await expect(trigger).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/01-channels-sort-ingress.png` });
await trigger.click();
await expect(
page.getByRole("menuitemradio", { name: "Recent" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-channels-sort-open.png` });
await page.getByRole("menuitemradio", { name: "Recent" }).click();
// Mock recency: all-replies (far future) > deep-history (1m) > general
// (5m) > agents (15m) > sales (30m) > engineering (42m) > design (120m),
// then no-activity channels alphabetically. The list is virtualized, so
// only assert on the rendered prefix.
await expect
.poll(async () => (await streamNames(page)).slice(0, 3))
.toEqual(["all-replies", "deep-history", "general"]);
const names = await streamNames(page);
const recencyOrder = [
"all-replies",
"deep-history",
"general",
"agents",
"sales",
"engineering",
"design",
];
const rendered = recencyOrder.filter((n) => names.includes(n));
expect(names.slice(0, rendered.length)).toEqual(rendered);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-channels-recent.png` });
// Persisted for this identity.
const stored = await page.evaluate((key) => {
return JSON.parse(window.localStorage.getItem(key) ?? "null");
}, SORT_STORAGE_KEY);
expect(stored).toEqual({ version: 1, groups: { channels: "recent" } });
// Survives reload.
await page.reload();
await expect(page.getByTestId("stream-list")).toBeVisible();
await expect
.poll(async () => (await streamNames(page)).slice(0, 2))
.toEqual(["all-replies", "deep-history"]);
});
test("03 — group preferences are independent (seeded Channels=recent leaves Forums AZ)", async ({
page,
}) => {
await seedSortState(page, { channels: "recent" });
await installMockBridge(page);
await openApp(page);
// Channels reflects the seeded Recent order…
await expect
.poll(async () => (await streamNames(page)).slice(0, 2))
.toEqual(["all-replies", "deep-history"]);
// …while Forums (unset) stays alphabetical.
const forumNames = await page
.getByTestId("forum-list")
.locator("[data-testid^='channel-']")
.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute("data-testid") ?? "")
.filter(
(id) =>
!id.startsWith("channel-unread") &&
!id.startsWith("channel-working"),
)
.map((id) => id.replace(/^channel-/, "")),
);
const sortedForums = [...forumNames].sort((a, b) => a.localeCompare(b));
expect(forumNames).toEqual(sortedForums);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-independent-groups.png` });
});
test("04 — DM group has its own sort trigger", async ({ page }) => {
await installMockBridge(page);
await openApp(page);
const dmList = page.getByTestId("dm-list");
await expect(dmList).toBeVisible();
await page.getByText("Direct messages", { exact: true }).hover();
const trigger = page.getByTestId("channel-sort-trigger-dms");
await expect(trigger).toBeVisible();
await trigger.click();
await expect(
page.getByRole("menuitemradio", { name: "AZ" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/05-dm-sort-open.png` });
await page.getByRole("menuitemradio", { name: "Recent" }).click();
const stored = await page.evaluate((key) => {
return JSON.parse(window.localStorage.getItem(key) ?? "null");
}, SORT_STORAGE_KEY);
expect(stored).toEqual({ version: 1, groups: { dms: "recent" } });
});
});