mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
5f7f774ea1
commit
36ac5243ff
@@ -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/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:<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 };
|
||||
}
|
||||
@@ -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: "A–Z" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 ?? "A–Z";
|
||||
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"
|
||||
|
||||
@@ -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 A–Z", 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 A–Z)", 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: "A–Z" }),
|
||||
).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" } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user