diff --git a/browser-host/sidebar-sort.mjs b/browser-host/sidebar-sort.mjs index 46590918da..9bf6288b03 100644 --- a/browser-host/sidebar-sort.mjs +++ b/browser-host/sidebar-sort.mjs @@ -114,7 +114,7 @@ function sectionChoice(coordinate, validIntent, edit, field) { intent.group, intent.mode, now, - intent.activeWindowSeconds, + field === "filters" ? intent.activeWindowSeconds : undefined, ); const projected = project(blob, intent.sectionIds); if (Buffer.byteLength(JSON.stringify(blob)) > 128 * 1024) @@ -163,8 +163,22 @@ function sectionChoice(coordinate, validIntent, edit, field) { await publish(draft.event); // This checks only the requested group now, not whether the whole-blob write // lost another device's intervening change to an unrelated group. - const confirmation = prepare(await readHead(), intent, secret); - if (confirmation.event) + const heads = await readHead(); + const confirmation = + field === "sort" + ? { + groups: + project( + parseHead(heads, secret, intent.sectionIds).blob, + intent.sectionIds, + ).sort ?? {}, + } + : prepare(heads, intent, secret); + if ( + field === "sort" + ? (confirmation.groups[intent.group] ?? "alpha") !== intent.mode + : confirmation.event + ) throw new Error( `Sidebar ${label} changed on another device; reload and try again`, ); diff --git a/browser-host/sidebar-sort.test.mjs b/browser-host/sidebar-sort.test.mjs index aa85e372f5..aaff385196 100644 --- a/browser-host/sidebar-sort.test.mjs +++ b/browser-host/sidebar-sort.test.mjs @@ -13,6 +13,7 @@ import { mutateSidebarFilter, } from "./sidebar-sort.mjs"; import { projectSidebarPreferences } from "../src/features/relay/sidebar-preferences.ts"; +import { editSidebarRecord } from "../src/features/relay/sidebar-registers.ts"; function harness() { const secret = generateSecretKey(), viewer = getPublicKey(secret); @@ -38,6 +39,96 @@ function harness() { }; } const intent = { group: "section:work", mode: "recent", sectionIds: ["work"] }; +it("encrypts plugin choices without putting them in legacy sort fields and confirms reloads", async () => { + const h = harness(); + const mode = "plugin:example.plugin/rows/reverse"; + let head = h.encrypt({ version: 1, groups: { dms: "recent" } }); + const publish = vi.fn(async (event) => { + head = event; + }); + const choose = (selected) => + mutateSidebarSort( + { group: "channels", mode: selected, sectionIds: [] }, + h.secret, + async () => [head], + publish, + ); + expect(await choose(mode)).toEqual({ channels: mode, dms: "recent" }); + expect(h.decode(head).groups).toEqual({ channels: "alpha", dms: "recent" }); + expect(h.decode(head).meta.g.channels[2]).toBe("alpha"); + expect(await choose(mode)).toEqual({ channels: mode, dms: "recent" }); + expect(publish).toHaveBeenCalledOnce(); + expect(await choose("alpha")).toEqual({ dms: "recent" }); + expect(publish).toHaveBeenCalledTimes(2); +}); +it.each(["dms", "channels"])( + "confirms only the requested plugin sort when an older client changes %s", + async (changedGroup) => { + const h = harness(); + const mode = "plugin:example.plugin/rows/reverse"; + let head = prepareSidebarSort( + [], + { group: "dms", mode, sectionIds: [] }, + h.secret, + 100_000, + ).event; + const publish = vi.fn(async (event) => { + const updated = editSidebarRecord( + "channel-sort", + h.decode(event), + event.created_at, + [[["g", changedGroup], "recent"]], + event.created_at * 1000 + 1, + ); + head = h.encrypt(updated, { created_at: event.created_at + 1 }); + }); + const saved = mutateSidebarSort( + { group: "channels", mode, sectionIds: [] }, + h.secret, + async () => [head], + publish, + ); + if (changedGroup === "dms") + await expect(saved).resolves.toEqual({ channels: mode, dms: "recent" }); + else await expect(saved).rejects.toThrow("changed on another device"); + expect(publish).toHaveBeenCalledOnce(); + expect( + projectSidebarPreferences(undefined, undefined, undefined, h.decode(head)) + .sort, + ).toEqual( + changedGroup === "dms" + ? { channels: mode, dms: "recent" } + : { channels: "recent", dms: mode }, + ); + }, +); +it("preserves another device's plugin choice when the writer has a stale section list", async () => { + const h = harness(); + const mode = "plugin:example.plugin/rows/reverse"; + let head = prepareSidebarSort( + [], + { group: "section:new", mode, sectionIds: ["new"] }, + h.secret, + 100_000, + ).event; + const saved = h.decode(head).pluginSort; + expect( + await mutateSidebarSort( + { group: "channels", mode: "recent", sectionIds: [] }, + h.secret, + async () => [head], + async (event) => { + head = event; + }, + ), + ).toEqual({ channels: "recent" }); + const current = h.decode(head); + expect(current.pluginSort).toEqual(saved); + expect( + projectSidebarPreferences(undefined, undefined, undefined, current, ["new"]) + .sort, + ).toEqual({ channels: "recent", "section:new": mode }); +}); it("mutates only the selected register, imports recognized legacy modes, and encodes alpha as a tombstone", () => { const h = harness(); const blob = { diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index d275165ddc..bc18289616 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -265,6 +265,89 @@ ctx.reflect.get("sidebarSections")?.register({ }); ``` +### Decorating host sidebar rows and adding sort modes + +`ctx.reflect.get("sidebarRows")` is an optional version-1 capability for host +Team channel and direct-message rows, including rows moved into custom groups. +Solo conversations are not supported yet. Use the reflective lookup rather than +required `inject` so the plugin still activates on older hosts. + +```tsx +ctx.reflect.get("sidebarRows")?.register({ + version: 1, + id: "row-details", + render(row, Default) { + return row.id === "chosen-channel-id" + ?
{Default}
+ : Default; + }, +}); +``` + +`row` contains `id`, `kind` (`team` or `dm`), `label`, `sectionKey`, the current +`channel` summary (including available labels, visibility and activity data), and +its current `unread` snapshot. Unknown unread counts remain `null`; activity +fields retain their existing units and unknown values. This is the host's +currently authorized roster, not a channel discovery API. + +`Default` is a React element, not a component constructor: return it, wrap it, +or return a replacement element. Returning `null` intentionally hides the row; +the host skips hidden rows when choosing a neighbor after leaving or deleting +the current conversation. +Render callbacks must be pure; return a component element if it needs hooks. +The live host element keeps selection, unread badges, keyboard focus, menus and +channel dragging without copying their implementation. Replacements and wrappers +that discard or interfere with that element own the interactions they remove. +Keep the wrapper shape stable across live row updates; moving `Default` to a +different tree position remounts it and can discard focus or open menus. + +Active contributions compose in lexical plugin-id/contribution-id order, with +later callbacks receiving the preceding result (possibly `null`). Callback +throws, invalid return values, and descendant render failures restore the +original host element for that row only, not a partially decorated result. +Repeated callback and invalid-result fallbacks retain the host row across live +updates. Switching between a decoration and fallback, or recovering from a +descendant error, can remount it and reset an open menu. If the host button had +focus, the host restores it to the replacement button in the same section only +when the update leaves focus on the document body; it never overrides focus +moved elsewhere. +Disabling, replacing or disposing a plugin removes its contributions through its +Cordis scope. With no active render contribution, the existing host row renders +without an extra DOM wrapper. + +```ts +ctx.reflect.get("sidebarRows")?.register({ + version: 1, + id: "activity-order", + sortModes: [{ + id: "oldest", + label: "Oldest activity first", + compare: (a, b) => + (a.channel.lastActivityAt ?? 0) - + (b.channel.lastActivityAt ?? 0) || a.id.localeCompare(b.id), + }], +}); +``` + +Modes appear beside A–Z and Recent in each Team section's existing Sort menu. +Their persisted values are `plugin://`; +ids use lowercase ASCII letters, digits, dots, underscores and hyphens, starting +with a letter or digit, with a combined value limit of 512 characters. A registration +with invalid fields or incompatible sort ids is rejected as a whole, including +its renderer; register render-only contributions separately if needed. Sorting +only rearranges existing rows. Comparators must be pure and return finite +numbers; ties preserve host order. A throw or non-finite result restores the +section's host order. Live unread and channel updates recompute custom sorting. +Selecting a plugin mode loads the same channel activity history as Recent, +sharing its request lifetime and cancellation. Activity fields remain unknown +until that read completes; comparators must handle unknown values. +If a saved mode is unavailable, the section uses A–Z while retaining the saved +choice for re-enabling the plugin. Plugin choices use a separate extension in +the existing sort record, leaving built-in fields readable by older hosts. +A later built-in sort edit to that section supersedes its plugin choice; +edits to another section leave it intact. There is no automatic reorder-without-menu +hook and no insertion hook in this version; use `sidebarSections` for new rows. + A plugin exports `inject` and `apply(ctx)`. Pages register with `ctx.pages.register({ id, title, layout?, companion?, primary?, placement?, icon?, component, sidebar? })`. Panels register with `ctx.panels.register({ id, title, matches, order?, launcher?, toolbar?, component })`. An optional `toolbar` supplies live controls beside shell search in place of the static launcher. Its lifetime stays with its plugin, and shell render failures are isolated. Huddles is the first caller. IDs are local to the diff --git a/src-tauri/src/identity.rs b/src-tauri/src/identity.rs index efada5f308..46a76c97e8 100644 --- a/src-tauri/src/identity.rs +++ b/src-tauri/src/identity.rs @@ -549,7 +549,7 @@ fn validate_sidebar_payload(coordinate: &str, value: &serde_json::Value) -> Resu coordinate, "channel-sections" | "channel-sort" | "channel-filter" ) { - validate_sidebar_meta(coordinate, meta)?; + validate_sidebar_meta(coordinate, meta, false)?; let projection = project_sidebar_meta(coordinate, meta); let projected = projection.as_object().ok_or_else(invalid)?; if projected @@ -560,12 +560,28 @@ fn validate_sidebar_payload(coordinate: &str, value: &serde_json::Value) -> Resu } } } + if coordinate == "channel-sort" { + if let Some(choices) = data.get("pluginSort") { + if !choices + .get("g") + .and_then(|v| v.as_object()) + .is_some_and(|g| g.len() <= 104) + { + return Err(invalid()); + } + validate_sidebar_meta(coordinate, choices, true)?; + } + } Ok(()) } // The signer admits only the bounded Desktop register schema, not arbitrary // renderer-controlled trees. Retained tombstones share the plaintext byte budget. -fn validate_sidebar_meta(coordinate: &str, value: &serde_json::Value) -> Result<()> { +fn validate_sidebar_meta( + coordinate: &str, + value: &serde_json::Value, + plugin_sort: bool, +) -> Result<()> { use serde_json::Value; const MAX_SAFE: u64 = 9_007_199_254_740_991; fn text(value: &Value, max: usize) -> bool { @@ -612,12 +628,16 @@ fn validate_sidebar_meta(coordinate: &str, value: &serde_json::Value) -> Result< let valid = if matches!(coordinate, "channel-sort" | "channel-filter") { map(meta.get("g"), 264, |v| { reg(v, |v| { - v.is_null() - || if coordinate == "channel-filter" { - matches!(v.as_str(), Some("unreads" | "active")) - } else { - matches!(v.as_str(), Some("alpha" | "recent")) - } + if plugin_sort { + v.as_str().is_some_and(valid_plugin_sidebar_sort_mode) + } else { + v.is_null() + || if coordinate == "channel-filter" { + matches!(v.as_str(), Some("unreads" | "active")) + } else { + matches!(v.as_str(), Some("alpha" | "recent")) + } + } }) }) } else { @@ -643,6 +663,22 @@ fn validate_sidebar_meta(coordinate: &str, value: &serde_json::Value) -> Result< Ok(()) } +fn valid_plugin_sidebar_sort_mode(value: &str) -> bool { + let Some(value) = value.strip_prefix("plugin:").filter(|_| value.len() <= 512) else { + return false; + }; + let parts: Vec<_> = value.split('/').collect(); + parts.len() == 3 + && parts.iter().all(|part| { + part.as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && part + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b)) + }) +} + // Called only after shape validation. Requiring these exact fields also binds // metadata's live counts to the existing projection limits above. fn project_sidebar_meta(coordinate: &str, meta: &serde_json::Value) -> serde_json::Value { diff --git a/src-tauri/src/identity/tests.rs b/src-tauri/src/identity/tests.rs index 2318eca334..ba8eb625b6 100644 --- a/src-tauri/src/identity/tests.rs +++ b/src-tauri/src/identity/tests.rs @@ -761,3 +761,37 @@ async fn main_agent_signer_admits_only_one_agent() { .is_err()); } } + +#[test] +fn sidebar_plugin_sort_registers_are_bounded_and_namespaced() { + use serde_json::json; + for mode in [ + "plugin:example.plugin/rows/reverse", + &format!("plugin:a/b/{}", "x".repeat(501)), + ] { + let record = json!({"version":1,"groups":{"dms":"recent"},"meta":{"v":1,"g":{"channels":[100,"1234567890abcdef",null],"dms":[99,"1234567890abcdef","recent"]}},"pluginSort":{"v":1,"g":{"channels":[100,"1234567890abcdef",mode]}}}); + assert!(super::validate_sidebar_payload("channel-sort", &record).is_ok()); + } + for mode in [ + "plugin:x", + "alpha", + "recent", + "plugin:a/b/", + "plugin:a/b/c/d", + "plugin:A/b/c", + &format!("plugin:a/b/{}", "x".repeat(502)), + ] { + let record = json!({"version":1,"groups":{},"meta":{"v":1,"g":{"channels":[100,"1234567890abcdef",null]}},"pluginSort":{"v":1,"g":{"channels":[100,"1234567890abcdef",mode]}}}); + assert!(super::validate_sidebar_payload("channel-sort", &record).is_err()); + } + for choices in [ + json!({"v":2,"g":{}}), + json!({"v":1}), + json!({"v":1,"g":{"channels":[100,"bad-device","plugin:a/b/c"]}}), + json!({"v":1,"g":{"channels":[-1,"1234567890abcdef","plugin:a/b/c"]}}), + json!({"v":1,"g":{"channels":[100,"1234567890abcdef",null]}}), + ] { + let record = json!({"version":1,"groups":{},"pluginSort":choices}); + assert!(super::validate_sidebar_payload("channel-sort", &record).is_err()); + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx index 9d98db9a67..a3c589388b 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -422,6 +422,7 @@ function ConnectedApp({ services }: { services: AppServices }) { chatIcons={services.conversation.chatIcons} chatMenus={services.conversation.chatMenus} sidebarSections={services.sidebarSections} + sidebarRows={services.sidebarRows} target={workspace.target} > {pageNavigation} diff --git a/src/app/services.ts b/src/app/services.ts index cb6e73aaec..235300609f 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -37,6 +37,7 @@ import { Context } from "@deepseek-ai/cordis"; import { BrowserService } from "../features/browser/service"; import { PagesService } from "../features/pages/service"; import { SoloNavigationService } from "../features/channel-navigation/solo-navigation"; +import { SidebarRowsService } from "../features/channel-navigation/sidebar-rows-service"; import { SidebarSectionsService } from "../features/channel-navigation/sidebar-sections-service"; import { bundledPlugins } from "../bundled"; import { conversationFeatures } from "./conversation-features"; @@ -62,6 +63,7 @@ export function createServices() { const decisions = new DecisionsService(ctx); const pages = new PagesService(ctx); const sidebarSections = new SidebarSectionsService(ctx); + const sidebarRows = new SidebarRowsService(ctx); const panels = new PanelsService(ctx); const accountActions = new AccountActionsService(ctx); const conversation = new ConversationService(ctx); @@ -175,6 +177,7 @@ export function createServices() { adminAccess, pages, sidebarSections, + sidebarRows, panels, plugins, relay, diff --git a/src/app/shell/PrimarySidebar.tsx b/src/app/shell/PrimarySidebar.tsx index 80da8d27e0..eb7698fe0d 100644 --- a/src/app/shell/PrimarySidebar.tsx +++ b/src/app/shell/PrimarySidebar.tsx @@ -31,6 +31,7 @@ import { readView, writeView } from "../../shared/view-state"; import type { ChannelList } from "../../features/relay/contracts"; import type { ContributionReader } from "../../features/conversation/contracts"; import type { ChatIcon, ChatMenu } from "../../features/conversation/service"; +import type { SidebarRows } from "../../features/channel-navigation/sidebar-rows-service"; import type { SidebarSections } from "../../features/channel-navigation/sidebar-sections-service"; const noSubscription = () => () => {}; @@ -49,6 +50,7 @@ type Props = { chatIcons?: ContributionReader | undefined; chatMenus?: ContributionReader | undefined; sidebarSections?: SidebarSections | undefined; + sidebarRows?: SidebarRows | undefined; target: OpenTarget; children: ReactNode; }; @@ -210,6 +212,7 @@ function ScopedSidebar({ scope={scope} viewer={connection.viewer} sidebarSections={props.sidebarSections} + sidebarRows={props.sidebarRows} cached={!!connection.cached} layoutReady={filter === "team" || soloReady} connectionError={connection.error} diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index e635a5a423..b7ac4a02c8 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -7,6 +7,7 @@ import { useRef, useSyncExternalStore, type ComponentType, + type ReactElement, type ReactNode, } from "react"; import { Avatar } from "../../shared/design-system/ui/Avatar"; @@ -28,9 +29,16 @@ import { UnreadBadge, ordinaryUnread } from "./UnreadBadge"; import { useWorkingAgents } from "./useWorkingAgents"; import { WorkingAgentsBadge } from "./WorkingAgentsBadge"; import styles from "./Channels.module.css"; +import { SidebarRowRenderer } from "../../features/channel-navigation/SidebarRowRenderer"; +import { + sidebarRow, + type RegisteredSidebarRows, +} from "../../features/channel-navigation/sidebar-rows-service"; import { avatarMedia } from "../../shared/avatar-source"; type ItemProps = { + rowContributions?: readonly RegisteredSidebarRows[] | undefined; + onHiddenChange?: ((id: string, hidden: boolean) => void) | undefined; channel: ChannelSummary; profile?: Profile | undefined; session: RelaySession; @@ -117,6 +125,8 @@ function useOrdinaryUnread( } function ChannelSidebarItemCore({ + rowContributions, + onHiddenChange, channel, profile, session, @@ -284,7 +294,21 @@ function ChannelSidebarItemCore({ {...(onHideDm ? { onHideDm } : {})} /> ); - if (!menuEnabled) return row; + const rendered = + sectionKey && rowContributions ? ( + + {row} + + ) : ( + row + ); + if (!menuEnabled) return rendered; return ( - {row} + {rendered} void) | undefined; +}) { + const target = useMemo( + () => ({ kind: "channel" as const, channelId: channel.id }), + [channel.id], + ); + const subscribe = useCallback( + (listener: () => void) => session.unread.subscribe(target, listener), + [session, target], + ); + const snapshot = useCallback( + () => session.unread.snapshot(target), + [session, target], + ); + const unread = useSyncExternalStore(subscribe, snapshot, snapshot); + const row = useMemo( + () => sidebarRow(channel, sectionKey, unread), + [channel, sectionKey, unread], + ); + return ( + + {children} + + ); +} + /** Quiet lifetime metadata; the working badge takes the trailing slot while agents work. */ function TemporaryMarker({ ttlSeconds }: { ttlSeconds: number }) { const label = `Temporary channel. Cleans up after ${channelDurationLabel(ttlSeconds)} without activity.`; diff --git a/src/bundled/channels/ChannelSidebarRow.tsx b/src/bundled/channels/ChannelSidebarRow.tsx index dbfd9e9cfa..9df0246ae0 100644 --- a/src/bundled/channels/ChannelSidebarRow.tsx +++ b/src/bundled/channels/ChannelSidebarRow.tsx @@ -2,7 +2,13 @@ import { FadingLabel } from "./FadingLabel"; import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { XIcon } from "../../shared/design-system/icons/index"; -import { useId, type ReactElement, type ReactNode } from "react"; +import { + useId, + useLayoutEffect, + useRef, + type ReactElement, + type ReactNode, +} from "react"; import type { ChannelSummary } from "../../features/relay/contracts"; import styles from "./ChannelSidebarRow.module.css"; @@ -37,9 +43,30 @@ export function ChannelSidebarRow({ onPrepare: (id: string) => void; onHideDm?: (id: string) => void; }) { + const selectRef = useRef(null); + useLayoutEffect(() => { + const button = selectRef.current; + if (!button) return; + const document = button.ownerDocument; + const section = button.closest("[data-sidebar-section]"); + return () => { + if (document.activeElement !== button) return; + // A plugin wrapper can remount the host row. Wait for its replacement, + // without overriding focus that moved elsewhere during the update. + queueMicrotask(() => { + if (document.activeElement !== document.body) return; + section + ?.querySelector( + `button[data-channel-id="${CSS.escape(channel.id)}"]`, + ) + ?.focus(); + }); + }; + }, [channel.id]); const presenceId = useId(); const selectButton = ( void; + value: import("../../features/relay/sidebar-sort").SidebarSortMode; + change: ( + value: import("../../features/relay/sidebar-sort").SidebarSortMode, + ) => void; }; } = {}) { const [open, setOpen] = useState(true); diff --git a/src/bundled/channels/SidebarSection.tsx b/src/bundled/channels/SidebarSection.tsx index 77ce8e30f9..5a2e0820e2 100644 --- a/src/bundled/channels/SidebarSection.tsx +++ b/src/bundled/channels/SidebarSection.tsx @@ -45,6 +45,7 @@ import { Button } from "../../shared/design-system/ui/Button"; import { Dialog } from "../../shared/design-system/ui/Dialog"; import type { RelaySession } from "../../features/relay/session"; import type { ChannelSummary } from "../../features/relay/contracts"; +import type { SidebarSortMode } from "../../features/relay/sidebar-sort"; import type { SidebarFilter } from "./useSidebarFilters"; import { SidebarGroupIcon } from "./SidebarGroupIcon"; import { SidebarStarIcon } from "./SidebarStarIcon"; @@ -103,8 +104,9 @@ export function SidebarSection({ | undefined; sort?: | { - value: "alpha" | "recent"; - change: (mode: "alpha" | "recent") => void; + value: SidebarSortMode; + modes?: readonly { id: SidebarSortMode; label: string }[]; + change: (mode: SidebarSortMode) => void; } | undefined; filter?: @@ -362,7 +364,7 @@ export function SidebarSection({ { - sort.change(mode as "alpha" | "recent"); + sort.change(mode as SidebarSortMode); setMenuOpen(false); }} > @@ -378,6 +380,15 @@ export function SidebarSection({ A–Z + {sort.modes?.map((mode) => ( + + {mode.label} + + ))} diff --git a/src/bundled/channels/useSidebarStartup.test.tsx b/src/bundled/channels/useSidebarStartup.test.tsx index 3a07fee1db..b062044821 100644 --- a/src/bundled/channels/useSidebarStartup.test.tsx +++ b/src/bundled/channels/useSidebarStartup.test.tsx @@ -87,44 +87,55 @@ function View({ ); } -it("reveals once after names, saved preferences, unread and Recent activity settle in any order", async () => { - const h = fixture(); - const view = render( - - - , - ); - expect(screen.queryByRole("button")).toBeNull(); - await act(async () => { - h.resolve(); - }); - act(() => h.names()); - view.rerender( - - - , - ); - expect(screen.queryByRole("button")).toBeNull(); - view.rerender( - - - , - ); - expect(screen.getByRole("button", { name: "One" })).toBeVisible(); - expect(screen.getByTestId("updating")).toHaveTextContent("false"); - // A later refresh never blanks navigation, nor retains revoked rows. - view.rerender( - - - , - ); - expect(screen.queryByText("Loading", { exact: true })).toBeNull(); - expect(screen.queryByRole("button")).toBeNull(); -}); +it.each(["recent", "plugin:example.plugin/rows/oldest"] as const)( + "reveals once after names, saved preferences, unread and %s activity settle in any order", + async (mode) => { + const selected = { + ...preferences, + data: { ...preferences.data, sort: { channels: mode } }, + }; + const h = fixture(); + const view = render( + + + , + ); + expect(screen.queryByRole("button")).toBeNull(); + await act(async () => { + h.resolve(); + }); + act(() => h.names()); + view.rerender( + + + , + ); + expect(screen.queryByRole("button")).toBeNull(); + view.rerender( + + + , + ); + expect(screen.getByRole("button", { name: "One" })).toBeVisible(); + expect(screen.getByTestId("updating")).toHaveTextContent("false"); + // A later refresh never blanks navigation, nor retains revoked rows. + view.rerender( + + + , + ); + expect(screen.queryByText("Loading", { exact: true })).toBeNull(); + expect(screen.queryByRole("button")).toBeNull(); + }, +); it("caps cold presentation waiting after authorization without needing optional reads to finish", async () => { vi.useFakeTimers(); diff --git a/src/bundled/channels/useSidebarStartup.ts b/src/bundled/channels/useSidebarStartup.ts index 6b1f8d95fe..c33841c1a5 100644 --- a/src/bundled/channels/useSidebarStartup.ts +++ b/src/bundled/channels/useSidebarStartup.ts @@ -1,6 +1,7 @@ import { useEffect, useState, useSyncExternalStore } from "react"; import type { RelaySession } from "../../features/relay/session"; import type { ChannelList } from "../../features/relay/contracts"; +import { sidebarSortNeedsActivity } from "../../features/relay/sidebar-sort"; // Presentation latch only, never roster/access data. Warm sidebar remounts reveal // immediately, but new relay/viewer sessions start independently. @@ -66,9 +67,11 @@ export function useSidebarStartup( preferences.status === "unsupported"; const namesReady = live.roster.state !== "pending" && live.roster.state !== "idle"; - const recent = Object.values(preferences.data?.sort ?? {}).includes("recent"); + const needsActivity = Object.values(preferences.data?.sort ?? {}).some( + sidebarSortNeedsActivity, + ); const activityReady = - !recent || + !needsActivity || list.activityStatus === "ready" || list.activityStatus === "error" || list.activityStatus === "unavailable"; diff --git a/src/features/channel-navigation/ChannelInventory.test.tsx b/src/features/channel-navigation/ChannelInventory.test.tsx index d0bdcb095b..4816b4309f 100644 --- a/src/features/channel-navigation/ChannelInventory.test.tsx +++ b/src/features/channel-navigation/ChannelInventory.test.tsx @@ -22,6 +22,7 @@ import type { SidebarGroups, SidebarPreferences, SidebarFilterMode, + SidebarSortMode, } from "../relay/sidebar-preferences"; import type { RelayData, RelaySnapshot } from "../relay/service"; import type { ChannelList } from "../relay/contracts"; @@ -31,7 +32,13 @@ import type { SidebarSectionRow, SidebarSections, } from "./sidebar-sections-service"; -import { createRef, type ReactNode } from "react"; +import type { + RegisteredSidebarRows, + SidebarRows, + SidebarRowContribution, + SidebarRow, +} from "./sidebar-rows-service"; +import { createRef, type ReactNode, type ReactElement } from "react"; import userEvent from "@testing-library/user-event"; import { ChannelHeaderMenu } from "../../bundled/channels/ChannelHeaderMenu"; import { PrimarySidebar } from "../../app/shell/PrimarySidebar"; @@ -109,11 +116,13 @@ function fixture( { id: "gamma", name: "gamma", channelType: "stream" }, ], sidebarSections?: SidebarSections, + sidebarRows?: SidebarRows, ) { const owner = createRelaySession(null); owners.push(owner); let list: ChannelList = { status: "ready", + activityStatus: "unavailable", channels: initialChannels.map((channel) => ({ space: "collaborative", ...channel, @@ -217,6 +226,7 @@ function fixture( }, }} sidebarSections={sidebarSections} + sidebarRows={sidebarRows} > {pages} @@ -1436,8 +1446,25 @@ it.each(["archive", "unarchive", "delete"] as const)( function pendingLifecycle( action: "archive" | "delete" | "leave" | "hide", preferences?: Parameters[0], + sidebarRows?: SidebarRows, + unreadChannelId?: string, ) { - const h = fixture(preferences); + const h = fixture(preferences, "ready", undefined, undefined, sidebarRows); + if (unreadChannelId) { + const unread = h.session.unread; + const target = { kind: "channel" as const, channelId: unreadChannelId }; + const snapshot = { + ...unread.snapshot(target), + manual: "local-only" as const, + }; + h.session.unread = { + ...unread, + snapshot: (target) => + target.kind === "channel" && target.channelId === unreadChannelId + ? snapshot + : unread.snapshot(target), + }; + } const channel = h.list.channels[1]; assert(channel); let finish!: () => void; @@ -1562,14 +1589,199 @@ it("a failed lifecycle action releases its gate while keeping recovery visible", // Opening the sidebar's first row would scroll a long sidebar away from where // the channel was; its neighbor is already in view. -it("deleting the open channel opens its neighbor after the roster drops it", async () => { - const h = pendingLifecycle("delete"); - await h.start(); - h.drop("beta"); - await act(async () => h.finish()); +it.each([ + { + name: "host neighbor", + before: [], + after: [], + reveal: false, + replacement: false, + next: "gamma", + }, + { + name: "hidden neighbor", + before: ["gamma"], + after: [], + reveal: false, + replacement: false, + next: "alpha", + }, + { + name: "neighbor hidden while pending", + before: [], + after: ["gamma"], + reveal: false, + replacement: false, + next: "alpha", + }, + { + name: "newly visible neighbor", + before: ["gamma"], + after: ["alpha"], + reveal: true, + replacement: false, + next: "gamma", + }, + { + name: "no visible neighbor", + before: ["gamma"], + after: ["alpha"], + reveal: false, + replacement: false, + next: undefined, + }, + { + name: "visible replacement", + before: [], + after: [], + reveal: false, + replacement: true, + next: "gamma", + }, +])( + "deleting the open channel uses its $name after the roster drops it", + async ({ before, after, reveal, replacement, next }) => { + const registry = rowRegistry({ + version: 1, + id: "visibility", + render: (row, Default) => + row.label.startsWith("hidden ") ? null : replacement && + row.id === "gamma" ? ( + + ) : ( + Default + ), + }); + const h = pendingLifecycle("delete", undefined, registry); + try { + for (const id of before) h.publish(id, { name: `hidden ${id}` }); + await h.start(); + h.drop("beta"); + for (const id of after) h.publish(id, { name: `hidden ${id}` }); + if (reveal) h.publish("gamma", { name: "gamma" }); + for (const id of [...before, ...after]) { + expect( + screen.queryByRole("button", { name: `hidden ${id}` }), + ).toBeNull(); + } + } finally { + await act(async () => h.finish()); + } + await waitFor(() => + expect(h.navigator.open).toHaveBeenCalledWith( + expect.objectContaining( + next + ? { kind: "conversation", channelId: next } + : { + kind: "page", + pageId: "channels", + route: { version: 1, params: "empty" }, + }, + ), + ), + ); + expect(h.navigator.open).toHaveBeenCalledOnce(); + }, +); + +it.each( + (["delete", "leave"] as const).flatMap((action) => + [false, true].map((hideUnread) => ({ action, hideUnread })), + ), +)( + "pending $action recovers within the Unreads roster (hide remaining unread: $hideUnread)", + async ({ action, hideUnread }) => { + const registry = rowRegistry({ + version: 1, + id: "visibility", + render: (row, Default) => + row.id === "gamma" || row.label.startsWith("hidden ") ? null : Default, + }); + const preferences = createSidebarPreferencesStore({ + available: true, + read: async () => ({ + sections: [], + assignments: {}, + starred: [], + muted: [], + filters: { channels: "unreads" }, + }), + }); + await preferences.queries.ensure(); + const h = pendingLifecycle(action, preferences.queries, registry, "alpha"); + try { + const alpha = await screen.findByRole("button", { name: /^alpha/ }); + expect(alpha).toBeVisible(); + expect( + within(alpha).getByRole("img", { + name: "Marked unread on this device only", + }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "beta" })).toBeVisible(); + expect(screen.queryByRole("button", { name: "gamma" })).toBeNull(); + await h.start(); + h.drop("beta"); + if (hideUnread) h.publish("alpha", { name: "hidden alpha" }); + } finally { + await act(async () => h.finish()); + preferences.dispose(); + } + await waitFor(() => + expect(h.navigator.open).toHaveBeenCalledWith( + expect.objectContaining( + hideUnread + ? { + kind: "page", + pageId: "channels", + route: { version: 1, params: "empty" }, + } + : { kind: "conversation", channelId: "alpha" }, + ), + ), + ); + expect(h.navigator.open).toHaveBeenCalledOnce(); + }, +); + +it("deleting a custom-sorted channel opens its displayed neighbor after the roster drops it", async () => { + const registry = rowRegistry({ + version: 1, + id: "order", + sortModes: [ + { + id: "reverse", + label: "Reverse name", + compare: (a, b) => b.label.localeCompare(a.label), + }, + ], + }); + const preferences = createSidebarPreferencesStore({ + available: true, + read: async () => ({ + sections: [], + assignments: {}, + starred: [], + muted: [], + sort: { channels: "plugin:fixture0/order/reverse" }, + }), + }); + await preferences.queries.ensure(); + const h = pendingLifecycle("delete", preferences.queries, registry); + try { + await h.start(); + expect( + [...document.querySelectorAll("[data-channel-id]")].map((row) => + row.getAttribute("data-channel-id"), + ), + ).toEqual(["gamma", "beta", "alpha"]); + h.drop("beta"); + } finally { + await act(async () => h.finish()); + preferences.dispose(); + } await waitFor(() => expect(h.navigator.open).toHaveBeenCalledWith( - expect.objectContaining({ kind: "conversation", channelId: "gamma" }), + expect.objectContaining({ kind: "conversation", channelId: "alpha" }), ), ); expect(h.navigator.open).toHaveBeenCalledOnce(); @@ -1806,3 +2018,506 @@ it("opening a shared chat lists it once, with the viewer's chats", async () => { expect(rows).toHaveLength(1); expect(rows[0]?.closest("nav")).toHaveAccessibleName("Solo conversations"); }); + +function rowRegistry(...values: SidebarRowContribution[]) { + const registered: readonly RegisteredSidebarRows[] = values.map( + (value, index) => ({ + ...value, + key: `fixture${index}/${value.id}`, + pluginId: `fixture${index}`, + revision: "one", + }), + ); + let entries = registered; + const listeners = new Set<() => void>(); + return { + version: 1 as const, + snapshot: () => entries, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + register: () => {}, + disable() { + entries = []; + for (const listener of listeners) listener(); + }, + enable() { + entries = registered; + for (const listener of listeners) listener(); + }, + }; +} + +it("decorates one live host row, preserving selection and keyboard menus", async () => { + const registry = rowRegistry({ + version: 1, + id: "decorate", + render: (row, Default) => + row.id === "beta" ? ( +
+ {Default} + Plugin detail +
+ ) : ( + Default + ), + }); + const h = fixture(undefined, "ready", undefined, undefined, registry); + render(h.view("beta")); + const beta = await screen.findByRole("button", { name: "beta" }); + expect(beta).toHaveAttribute("aria-current", "page"); + expect(beta.closest("[data-decoration]")).toHaveTextContent("Plugin detail"); + expect( + screen.getByRole("button", { name: "alpha" }).closest("[data-decoration]"), + ).toBeNull(); + beta.focus(); + expect(beta).toHaveFocus(); + fireEvent.click(beta); + expect(h.navigator.open).toHaveBeenCalled(); + fireEvent.keyDown(beta, { key: "ContextMenu" }); + expect( + await screen.findByRole("menuitem", { name: "Copy link" }), + ).toBeVisible(); +}); + +it.each(["enable", "disable"] as const)( + "preserves host row identity and focus on renderer %s", + async (transition) => { + const registry = rowRegistry({ + version: 1, + id: "unchanged", + render: (_, Default) => Default, + }); + if (transition === "enable") registry.disable(); + const h = fixture(undefined, "ready", undefined, undefined, registry); + render(h.view("alpha")); + const alpha = await screen.findByRole("button", { name: "alpha" }); + alpha.focus(); + expect(alpha).toHaveFocus(); + + act(() => registry[transition]()); + + expect(screen.getByRole("button", { name: "alpha" })).toBe(alpha); + expect(alpha).toHaveFocus(); + }, +); + +it("can replace or hide a chosen row without changing its siblings", async () => { + const registry = rowRegistry({ + version: 1, + id: "replace", + render: (row, Default) => + row.id === "beta" ? ( + Replacement animation + ) : row.id === "gamma" ? null : ( + Default + ), + }); + const h = fixture(undefined, "ready", undefined, undefined, registry); + render(h.view("alpha")); + expect(await screen.findByText("Replacement animation")).toBeVisible(); + expect(screen.queryByRole("button", { name: "beta" })).toBeNull(); + expect(screen.queryByRole("button", { name: "gamma" })).toBeNull(); + expect(screen.getByRole("button", { name: "alpha" })).toBeVisible(); + act(() => registry.disable()); + expect(await screen.findByRole("button", { name: "beta" })).toBeVisible(); + expect(screen.getByRole("button", { name: "gamma" })).toBeVisible(); + expect(screen.queryByText("Replacement animation")).toBeNull(); +}); + +function BrokenRow(): never { + throw new Error("broken child"); +} + +it.each(["callback", "child", "invalid"] as const)( + "falls back to the live host row for a %s failure only", + async (failure) => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const registry = rowRegistry({ + version: 1, + id: "broken", + render: (row, Default) => { + if (row.id !== "beta") + return
{Default}
; + if (row.channel.lastActivityAt === 1) + return
{Default}
; + if (failure === "callback") throw new Error("broken callback"); + return failure === "child" ? : ({} as never); + }, + }); + try { + const h = fixture(undefined, "ready", undefined, undefined, registry); + render(h.view("beta")); + const beta = await screen.findByRole("button", { name: "beta" }); + expect(beta).toHaveAttribute("aria-current", "page"); + expect(beta.closest("[data-decoration]")).toBeNull(); + expect( + screen + .getByRole("button", { name: "alpha" }) + .closest("[data-decoration]"), + ).not.toBeNull(); + expect(screen.getByRole("button", { name: "gamma" })).toBeVisible(); + beta.focus(); + h.publish("beta", { lastActivityAt: 2 }); + if (failure !== "child") { + expect(screen.getByRole("button", { name: "beta" })).toBe(beta); + expect(beta).toHaveFocus(); + } + h.publish("beta", { lastActivityAt: 1 }); + expect( + screen + .getByRole("button", { name: "beta" }) + .closest("[data-decoration]"), + ).toHaveAttribute("data-decoration", "recovered"); + + screen.getByRole("button", { name: "beta" }).focus(); + await act(async () => h.publish("beta", { lastActivityAt: 3 })); + const fallback = screen.getByRole("button", { name: "beta" }); + expect(fallback.closest("[data-decoration]")).toBeNull(); + expect(fallback).toHaveFocus(); + + await act(async () => h.publish("beta", { lastActivityAt: 1 })); + screen.getByRole("button", { name: "beta" }).focus(); + const alpha = screen.getByRole("button", { name: "alpha" }); + await act(async () => { + h.publish("beta", { lastActivityAt: 4 }); + alpha.focus(); + }); + expect(alpha).toHaveFocus(); + } finally { + error.mockRestore(); + } + }, +); + +it("composes two plugins around the preceding result", async () => { + const registry = rowRegistry( + { + version: 1, + id: "first", + render: (_, Default) =>
{Default}
, + }, + { + version: 1, + id: "second", + render: (_, Default) =>
{Default}
, + }, + ); + const h = fixture(undefined, "ready", undefined, undefined, registry); + render(h.view("alpha")); + const alpha = await screen.findByRole("button", { name: "alpha" }); + expect(alpha.closest('[data-layer="inner"]')?.parentElement).toHaveAttribute( + "data-layer", + "outer", + ); +}); + +it.each([ + { sectionKey: "channels", preferenceKey: "channels", label: "Channels" }, + { sectionKey: "group:work", preferenceKey: "section:work", label: "Work" }, +])( + "persists a plugin sort for $sectionKey across disable and re-enable", + async ({ sectionKey, preferenceKey, label }) => { + const registry = rowRegistry({ + version: 1, + id: "order", + sortModes: [ + { + id: "reverse", + label: "Reverse name", + compare: (a, b) => b.label.localeCompare(a.label), + }, + ], + }); + let sort: Record = {}; + const writeSort = vi.fn(async (key: string, mode: SidebarSortMode) => { + sort = { ...sort, [key]: mode }; + return sort; + }); + const prefs = createSidebarPreferencesStore({ + available: true, + read: async () => ({ + sections: + sectionKey === "channels" + ? [] + : [{ id: "work", name: "Work", order: 0 }], + assignments: + sectionKey === "channels" + ? {} + : { alpha: "work", beta: "work", gamma: "work" }, + starred: [], + muted: [], + sort, + }), + writeSort, + }); + const h = fixture(prefs.queries, "ready", undefined, undefined, registry); + const user = userEvent.setup(); + try { + const mounted = render(h.view("alpha")); + await screen.findByRole("button", { name: "gamma" }); + const order = () => + [ + ...document.querySelectorAll( + `[data-sidebar-section="${sectionKey}"] [data-channel-id]`, + ), + ].map((el) => el.getAttribute("data-channel-id")); + expect(order()).toEqual(["alpha", "beta", "gamma"]); + await user.click( + screen.getByRole("button", { name: `More actions for ${label}` }), + ); + await user.click(await screen.findByRole("menuitem", { name: "Sort" })); + fireEvent.click( + await screen.findByRole("menuitemradio", { name: "Reverse name" }), + ); + await waitFor(() => expect(order()).toEqual(["gamma", "beta", "alpha"])); + expect(writeSort).toHaveBeenCalledWith( + preferenceKey, + "plugin:fixture0/order/reverse", + sectionKey === "channels" ? [] : ["work"], + expect.any(AbortSignal), + ); + mounted.unmount(); + render(h.view("alpha")); + await waitFor(() => expect(order()).toEqual(["gamma", "beta", "alpha"])); + act(() => registry.disable()); + await waitFor(() => expect(order()).toEqual(["alpha", "beta", "gamma"])); + expect(sort[preferenceKey]).toBe("plugin:fixture0/order/reverse"); + await user.click( + screen.getByRole("button", { name: `More actions for ${label}` }), + ); + await user.click(await screen.findByRole("menuitem", { name: "Sort" })); + expect( + screen.queryByRole("menuitemradio", { name: "Reverse name" }), + ).toBeNull(); + act(() => registry.enable()); + await waitFor(() => expect(order()).toEqual(["gamma", "beta", "alpha"])); + expect(writeSort).toHaveBeenCalledOnce(); + } finally { + prefs.dispose(); + } + }, +); + +it.each(["throw", "non-finite"] as const)( + "retains the section roster and host order when a comparator returns %s", + async (failure) => { + const registry = rowRegistry({ + version: 1, + id: "order", + sortModes: [ + { + id: "broken", + label: "Broken sort", + compare: (a, b) => { + if (failure === "throw") throw new Error("broken sort"); + return a.id === "gamma" || b.id === "gamma" + ? NaN + : b.label.localeCompare(a.label); + }, + }, + ], + }); + const prefs = createSidebarPreferencesStore({ + available: true, + read: async () => ({ + sections: [], + assignments: {}, + starred: [], + muted: [], + sort: { channels: "plugin:fixture0/order/broken" }, + }), + writeSort: async () => ({ channels: "plugin:fixture0/order/broken" }), + }); + const h = fixture(prefs.queries, "ready", undefined, undefined, registry); + try { + render(h.view("alpha")); + await screen.findByRole("button", { name: "gamma" }); + expect( + [ + ...document.querySelectorAll( + '[data-sidebar-section="channels"] [data-channel-id]', + ), + ].map((el) => el.getAttribute("data-channel-id")), + ).toEqual(["alpha", "beta", "gamma"]); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "More actions for Channels" }), + ); + await user.click(await screen.findByRole("menuitem", { name: "Sort" })); + expect( + await screen.findByRole("menuitemradio", { name: "Broken sort" }), + ).toHaveAttribute("aria-checked", "true"); + } finally { + prefs.dispose(); + } + }, +); + +it("supplies DM identity and live channel metadata without replacing its host row", async () => { + const registry = rowRegistry({ + version: 1, + id: "details", + render: (row, Default) => + row.kind === "dm" ? ( +
+ {Default} + Activity {row.channel.lastActivityAt ?? "unknown"} +
+ ) : ( + Default + ), + }); + const h = fixture( + undefined, + "ready", + [ + { id: "alpha", name: "alpha", channelType: "stream" }, + { + id: "dm", + name: "Alice", + channelType: "dm", + visibility: "private", + lastActivityAt: 12, + }, + ], + undefined, + registry, + ); + render(h.view("dm")); + const dm = await screen.findByRole("button", { name: "Alice" }); + expect(dm).toHaveAttribute("aria-current", "page"); + expect(dm.closest("[data-dm-decoration]")).toHaveAttribute( + "data-dm-decoration", + "dm", + ); + expect(screen.getByText("Activity 12")).toBeVisible(); + h.publish("dm", { lastActivityAt: 24 }); + expect(await screen.findByText("Activity 24")).toBeVisible(); + expect( + screen + .getByRole("button", { name: "alpha" }) + .closest("[data-dm-decoration]"), + ).toBeNull(); +}); + +it("updates decorated unread data and a selected unread sort without rewriting preferences", async () => { + const renderRow = vi.fn((row: SidebarRow, Default: ReactElement | null) => ( +
+ {Default} +
+ )); + const registry = rowRegistry({ + version: 1, + id: "unread", + render: renderRow, + sortModes: [ + { + id: "unread", + label: "Unread first", + compare: (a, b) => + (b.unread.observedCount ?? 0) - (a.unread.observedCount ?? 0), + }, + ], + }); + const writeSort = vi.fn(async (key: string, mode: SidebarSortMode) => ({ + [key]: mode, + })); + const prefs = createSidebarPreferencesStore({ + available: true, + read: async () => ({ + sections: [], + assignments: {}, + starred: [], + muted: [], + sort: {}, + }), + writeSort, + }); + const h = fixture(prefs.queries, "ready", undefined, undefined, registry); + const original = h.session.unread; + const snapshots = new Map( + ["alpha", "beta", "gamma"].map((id) => [ + id, + original.snapshot({ kind: "channel", channelId: id }), + ]), + ); + const listeners = new Map void>>(); + h.session.unread = { + ...original, + snapshot: (target) => + target.kind === "channel" + ? (snapshots.get(target.channelId) ?? original.snapshot(target)) + : original.snapshot(target), + subscribe: (target, listener) => { + if (target.kind !== "channel") + return original.subscribe(target, listener); + const set = listeners.get(target.channelId) ?? new Set(); + listeners.set(target.channelId, set); + set.add(listener); + return () => { + set.delete(listener); + }; + }, + }; + const publish = (count: number | null) => + act(() => { + const previous = snapshots.get("beta"); + assert(previous); + snapshots.set("beta", { + ...previous, + observedCount: count, + attentionCount: count, + coverage: count === null ? "unknown" : "observed", + freshness: count === null ? "unknown" : "observed", + }); + for (const listener of listeners.get("beta") ?? []) listener(); + }); + const order = () => + [ + ...document.querySelectorAll( + '[data-sidebar-section="channels"] [data-channel-id]', + ), + ].map((el) => el.getAttribute("data-channel-id")); + try { + render(h.view("alpha")); + await screen.findByRole("button", { name: "gamma" }); + expect(document.querySelector('[data-unread-row="beta"]')).toHaveAttribute( + "data-observed-count", + "unknown", + ); + renderRow.mockClear(); + publish(2); + expect(document.querySelector('[data-unread-row="beta"]')).toHaveAttribute( + "data-observed-count", + "2", + ); + expect(renderRow.mock.calls.map(([row]) => row.id)).toEqual(["beta"]); + expect(order()).toEqual(["alpha", "beta", "gamma"]); + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "More actions for Channels" }), + ); + await user.click(await screen.findByRole("menuitem", { name: "Sort" })); + fireEvent.click( + await screen.findByRole("menuitemradio", { name: "Unread first" }), + ); + await waitFor(() => expect(order()).toEqual(["beta", "alpha", "gamma"])); + expect(writeSort).toHaveBeenCalledOnce(); + publish(null); + expect(order()).toEqual(["alpha", "beta", "gamma"]); + publish(3); + expect(order()).toEqual(["beta", "alpha", "gamma"]); + expect(document.querySelector('[data-unread-row="beta"]')).toHaveAttribute( + "data-observed-count", + "3", + ); + expect(writeSort).toHaveBeenCalledOnce(); + } finally { + prefs.dispose(); + } +}); diff --git a/src/features/channel-navigation/ChannelInventory.tsx b/src/features/channel-navigation/ChannelInventory.tsx index 3dbed1d359..2985b57f9a 100644 --- a/src/features/channel-navigation/ChannelInventory.tsx +++ b/src/features/channel-navigation/ChannelInventory.tsx @@ -24,6 +24,15 @@ import type { RegisteredSidebarSection, SidebarSections, } from "./sidebar-sections-service"; +import { + sidebarRow, + sidebarRowSortModes, + sortSidebarRows, + type SidebarRows, + type RegisteredSidebarRows, +} from "./sidebar-rows-service"; +import { useSidebarRowUnread } from "./useSidebarRowUnread"; +import type { SidebarSortMode } from "../relay/sidebar-sort"; import type { SidebarFrame } from "./SidebarFrame"; import { Button } from "../../shared/design-system/ui/Button"; import { @@ -108,6 +117,8 @@ type Inventory = { const noSubscription = () => () => {}; const emptyContributions: readonly RegisteredSidebarSection[] = []; const noSidebarSections = () => emptyContributions; +const emptyRowContributions: readonly RegisteredSidebarRows[] = []; +const noSidebarRows = () => emptyRowContributions; const emptySidebarSections: SidebarSections = { version: 1, snapshot: noSidebarSections, @@ -128,6 +139,7 @@ export function ChannelInventory({ scope, viewer, sidebarSections: contributedSections, + sidebarRows: contributedRows, render, }: { relay: RelayData; @@ -142,6 +154,7 @@ export function ChannelInventory({ scope: string; viewer?: string | undefined; sidebarSections?: SidebarSections | undefined; + sidebarRows?: SidebarRows | undefined; render(inventory: Inventory): ReactNode; }) { const contributions = useSyncExternalStore( @@ -149,6 +162,15 @@ export function ChannelInventory({ contributedSections?.snapshot ?? noSidebarSections, contributedSections?.snapshot ?? noSidebarSections, ); + const rowContributions = useSyncExternalStore( + contributedRows?.subscribe ?? noSubscription, + contributedRows?.snapshot ?? noSidebarRows, + contributedRows?.snapshot ?? noSidebarRows, + ); + const rowSortModes = useMemo( + () => sidebarRowSortModes(rowContributions), + [rowContributions], + ); const list = useChannelList(queries.channels); const recoveryShown = useRecoveryShown(queries.recovery); const preferences = useSidebarPreferences(queries.sidebarPreferences); @@ -162,6 +184,11 @@ export function ChannelInventory({ const readAction = useChannelReadAction(); const readWrite = readAction.state; const [rowFocus, setRowFocus] = useState(); + const pluginHiddenRows = useRef(new Set()); + const setRowHidden = useCallback((id: string, hidden: boolean) => { + if (hidden) pluginHiddenRows.current.add(id); + else pluginHiddenRows.current.delete(id); + }, []); const kitState = useSyncExternalStore( queries.channelKit.subscribe, queries.channelKit.snapshot, @@ -242,16 +269,22 @@ export function ChannelInventory({ target.pluginId === "buzz.channels" && target.route?.params === "new-message"; const preparingDm = composingMessage ? handoff?.preparingDm : undefined; - const sidebarChannels = channels.filter( - (channel) => - !channel.huddle && - !channel.metadataPending && - inTeamInventory(channel) && - (!preparingDm || - preparingDm.existing.has(channel.id) || - channel.channelType !== "dm" || - channel.members?.length !== preparingDm.members.size || - !channel.members.every((member) => preparingDm.members.has(member))), + const sidebarChannels = useMemo( + () => + channels.filter( + (channel) => + !channel.huddle && + !channel.metadataPending && + inTeamInventory(channel) && + (!preparingDm || + preparingDm.existing.has(channel.id) || + channel.channelType !== "dm" || + channel.members?.length !== preparingDm.members.size || + !channel.members.every((member) => + preparingDm.members.has(member), + )), + ), + [channels, preparingDm], ); const current = channels.find( (channel) => @@ -467,15 +500,80 @@ export function ChannelInventory({ sidebar.toggle(key, true), ); }; - const sections = startup.ready - ? sidebarSections( - sidebarChannels, - preferences.data, - new Set([...hiddenDms.hiddenIds, ...dmVisibility.hidden]), - ) - : []; + const sections = useMemo( + () => + startup.ready + ? sidebarSections( + sidebarChannels, + preferences.data, + new Set([...hiddenDms.hiddenIds, ...dmVisibility.hidden]), + ) + : [], + [ + startup.ready, + sidebarChannels, + preferences.data, + hiddenDms.hiddenIds, + dmVisibility.hidden, + ], + ); + const sectionSort = useCallback( + (key: string): SidebarSortMode => { + const saved = + preferences.data?.sort?.[ + key.startsWith("group:") ? `section:${key.slice(6)}` : key + ]; + return saved && + (saved === "recent" || rowSortModes.some((mode) => mode.id === saved)) + ? saved + : "alpha"; + }, + [preferences.data, rowSortModes], + ); + const rowUnread = useSidebarRowUnread( + queries.unread, + JSON.stringify( + sections.flatMap((section) => + rowSortModes.some((mode) => mode.id === sectionSort(section.key)) + ? section.rows.map((channel) => channel.id) + : [], + ), + ), + ); + const sortedSections = useMemo( + () => + rowSortModes.length + ? sections.map((section) => { + const mode = rowSortModes.find( + (mode) => mode.id === sectionSort(section.key), + ); + if (!mode) return section; + const candidates = section.rows.map((channel) => ({ + channel, + data: sidebarRow( + channel, + section.key, + rowUnread.get(channel.id) ?? + queries.unread.snapshot({ + kind: "channel", + channelId: channel.id, + }), + ), + })); + const sorted = sortSidebarRows( + candidates, + mode, + ({ data }) => data, + ); + return sorted === candidates + ? section + : { ...section, rows: sorted.map(({ channel }) => channel) }; + }) + : sections, + [sections, rowSortModes, sectionSort, rowUnread, queries.unread], + ); const filteredSections = useSidebarFilters( - sections, + sortedSections, Object.fromEntries( Object.entries(preferences.data?.filters ?? {}).map(([key, mode]) => [ key.startsWith("section:") ? `group:${key.slice(8)}` : key, @@ -615,7 +713,7 @@ export function ChannelInventory({ ids.splice(to, 0, sectionId); void preferences.reorderSections(sectionId, ids).catch(() => {}); }; - const setSectionSort = (key: string, mode: "alpha" | "recent") => { + const setSectionSort = (key: string, mode: SidebarSortMode) => { void preferences .setSort( key.startsWith("group:") ? `section:${key.slice(6)}` : key, @@ -989,15 +1087,20 @@ export function ChannelInventory({ if (!lifecycleCallbacks) return; const dialogId = lifecycleDialog?.channel.id; if (!dialogId) lifecycleNeighbors.current = undefined; - const at = sections.findIndex((section) => + const at = filteredSections.findIndex((section) => section.rows.some((row) => row.id === dialogId), ); - const placement = sections[at]; + const placement = filteredSections[at]; if (dialogId && placement) { const shown = (section: typeof placement) => sidebar.collapsed.includes(section.key) ? [] - : section.rows.map((row) => row.id); + : section.rows + .filter( + (row) => + row.id === dialogId || !pluginHiddenRows.current.has(row.id), + ) + .map((row) => row.id); const own = shown(placement); const index = Math.max(own.indexOf(dialogId), 0); lifecycleNeighbors.current = { @@ -1005,8 +1108,8 @@ export function ChannelInventory({ ids: [ ...own.slice(index + 1), ...own.slice(0, index).reverse(), - ...sections.slice(at + 1).flatMap(shown), - ...sections.slice(0, at).flatMap(shown).reverse(), + ...filteredSections.slice(at + 1).flatMap(shown), + ...filteredSections.slice(0, at).flatMap(shown).reverse(), ], }; } @@ -1032,7 +1135,9 @@ export function ChannelInventory({ return; } // Its neighbor takes its place, so the sidebar stays where it is. - const listed = sections.flatMap((section) => section.rows); + const listed = filteredSections + .flatMap((section) => section.rows) + .filter((row) => !pluginHiddenRows.current.has(row.id)); const next = (lifecycleNeighbors.current?.id === id ? lifecycleNeighbors.current.ids @@ -1349,12 +1454,8 @@ export function ChannelInventory({ sort={ preferences.sortWritable ? { - value: - preferences.data?.sort?.[ - section.key.startsWith("group:") - ? `section:${section.key.slice(6)}` - : section.key - ] ?? "alpha", + value: sectionSort(section.key), + modes: rowSortModes, change: (mode) => setSectionSort(section.key, mode), } : undefined @@ -1378,6 +1479,8 @@ export function ChannelInventory({ } key={channel.id} channel={channel} + rowContributions={rowContributions} + onHiddenChange={setRowHidden} session={queries} working={workingChannels.has(channel.id)} selected={composingMessage ? undefined : selected} diff --git a/src/features/channel-navigation/SidebarRowRenderer.test.tsx b/src/features/channel-navigation/SidebarRowRenderer.test.tsx new file mode 100644 index 0000000000..e8b1e41722 --- /dev/null +++ b/src/features/channel-navigation/SidebarRowRenderer.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { cleanup, render } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { SidebarRowRenderer } from "./SidebarRowRenderer"; +import type { SidebarRow } from "./sidebar-rows-service"; + +afterEach(cleanup); + +it("leaves host output identical with no plugin", () => { + const Default = ( + + ); + const plain = render(Default); + const expected = plain.container.innerHTML; + plain.unmount(); + const row: SidebarRow = { + id: "row", + kind: "team", + label: "Host row", + sectionKey: "channels", + channel: { id: "row", name: "Host row" }, + unread: { + target: { kind: "channel", channelId: "row" }, + observedCount: null, + attentionCount: null, + coverage: "unknown", + freshness: "unknown", + manual: "none", + }, + }; + const mounted = render( + + {Default} + , + ); + expect(mounted.container.innerHTML).toBe(expected); +}); diff --git a/src/features/channel-navigation/SidebarRowRenderer.tsx b/src/features/channel-navigation/SidebarRowRenderer.tsx new file mode 100644 index 0000000000..f7a7554131 --- /dev/null +++ b/src/features/channel-navigation/SidebarRowRenderer.tsx @@ -0,0 +1,43 @@ +import { + isValidElement, + useLayoutEffect, + useMemo, + type ReactElement, +} from "react"; +import { ContributionBoundary } from "../conversation/ContributionBoundary"; +import type { RegisteredSidebarRows, SidebarRow } from "./sidebar-rows-service"; + +export function SidebarRowRenderer({ + row, + entries, + children, + onHiddenChange, +}: { + row: SidebarRow; + entries: readonly RegisteredSidebarRows[]; + children: ReactElement; + onHiddenChange?: ((id: string, hidden: boolean) => void) | undefined; +}) { + const resetIdentity = useMemo(() => [entries, row], [entries, row]); + let result: ReactElement | null = children; + try { + for (const entry of entries) { + if (!entry.render) continue; + result = entry.render(row, result); + if (result !== null && !isValidElement(result)) + throw new Error("Invalid sidebar row render result"); + } + } catch { + result = children; + } + const hidden = result === null; + useLayoutEffect(() => { + onHiddenChange?.(row.id, hidden); + return () => onHiddenChange?.(row.id, false); + }, [onHiddenChange, row.id, hidden]); + return ( + + {result} + + ); +} diff --git a/src/features/channel-navigation/sidebar-rows-service.test.ts b/src/features/channel-navigation/sidebar-rows-service.test.ts new file mode 100644 index 0000000000..ffc0eb3650 --- /dev/null +++ b/src/features/channel-navigation/sidebar-rows-service.test.ts @@ -0,0 +1,103 @@ +import { Context } from "@deepseek-ai/cordis"; +import { expect, it } from "vitest"; +import type { SidebarRowContribution } from "./sidebar-rows-service"; +import { SidebarRowsService } from "./sidebar-rows-service"; + +it("orders active scopes by contribution key and removes registrations on disable and teardown", async () => { + const root = new Context(); + const active = new Set(["z.plugin", "a.plugin"]); + const listeners = new Set<() => void>(); + root.provide("pluginStatus", { + isActive: (id: string) => active.has(id), + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }); + const registry = new SidebarRowsService(root); + const value: SidebarRowContribution = { + version: 1, + id: "rows", + render: (_, Default) => Default, + }; + const fibers = ["z.plugin", "a.plugin"].map((id) => + root + .extend({ pluginOwner: { id, revision: "one" } }) + .plugin((ctx) => ctx.reflect.get("sidebarRows")?.register(value)), + ); + try { + await Promise.all(fibers.map((fiber) => fiber.await())); + expect(registry.snapshot().map((entry) => entry.key)).toEqual([ + "a.plugin/rows", + "z.plugin/rows", + ]); + expect(registry.snapshot()).toBe(registry.snapshot()); + active.delete("a.plugin"); + for (const listener of listeners) listener(); + expect(registry.snapshot().map((entry) => entry.key)).toEqual([ + "z.plugin/rows", + ]); + await fibers[0]?.dispose(); + expect(registry.snapshot()).toEqual([]); + } finally { + await root.fiber.dispose(); + } +}); + +it.each([ + [ + "duplicate mode ids", + "example.plugin", + { + sortModes: [ + { id: "one", label: "One", compare: (): number => 0 }, + { id: "one", label: "Two", compare: (): number => 0 }, + ], + }, + ], + [ + "blank label", + "example.plugin", + { sortModes: [{ id: "one", label: " ", compare: (): number => 0 }] }, + ], + [ + "non-function comparator", + "example.plugin", + { sortModes: [{ id: "one", label: "One", compare: null }] }, + ], + ["non-function renderer", "example.plugin", { render: false }], + [ + "incompatible owner id", + "MixedCase", + { sortModes: [{ id: "one", label: "One", compare: (): number => 0 }] }, + ], +] as const)( + "rejects %s without publishing a partial contribution", + async (_, owner, fields) => { + const root = new Context(); + root.provide("pluginStatus", { + isActive: () => true, + subscribe: () => () => {}, + }); + const registry = new SidebarRowsService(root); + let register = () => {}; + const fiber = root + .extend({ pluginOwner: { id: owner, revision: "one" } }) + .plugin((ctx) => { + register = () => + ctx.reflect.get("sidebarRows")?.register({ + version: 1, + id: "rows", + render: (_, Default) => Default, + ...fields, + } as SidebarRowContribution); + }); + try { + await fiber.await(); + expect(register).toThrow(); + expect(registry.snapshot()).toEqual([]); + } finally { + await root.fiber.dispose(); + } + }, +); diff --git a/src/features/channel-navigation/sidebar-rows-service.ts b/src/features/channel-navigation/sidebar-rows-service.ts new file mode 100644 index 0000000000..32ce9d12c5 --- /dev/null +++ b/src/features/channel-navigation/sidebar-rows-service.ts @@ -0,0 +1,151 @@ +import { Service, type Context } from "@deepseek-ai/cordis"; +import type { ReactElement } from "react"; +import type { ChannelSummary } from "../relay/contracts"; +import type { UnreadSnapshot } from "../relay/unread"; +import { + createContributions, + type Contribution, +} from "../../plugins/contributions"; +import { isSidebarSortMode, type SidebarSortMode } from "../relay/sidebar-sort"; + +export type SidebarRow = Readonly<{ + id: string; + kind: "team" | "dm"; + label: string; + sectionKey: string; + channel: ChannelSummary; + unread: UnreadSnapshot; +}>; +export type SidebarRowSortMode = Readonly<{ + id: string; + label: string; + compare: (a: SidebarRow, b: SidebarRow) => number; +}>; +export type SidebarRowContribution = Readonly<{ + version: 1; + id: string; + render?: ( + row: SidebarRow, + Default: ReactElement | null, + ) => ReactElement | null; + sortModes?: readonly SidebarRowSortMode[]; +}>; +export type RegisteredSidebarRows = Contribution; +export type SidebarRows = Readonly<{ + version: 1; + snapshot: () => readonly RegisteredSidebarRows[]; + subscribe: (listener: () => void) => () => void; + register: (contribution: SidebarRowContribution) => void; +}>; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Read through reflect for compatibility with hosts without row contributions. */ + sidebarRows?: SidebarRows; + } +} + +const idPattern = /^[a-z0-9][a-z0-9._-]*$/; + +export class SidebarRowsService extends Service implements SidebarRows { + readonly version = 1 as const; + private readonly contributions; + private source: readonly RegisteredSidebarRows[] = []; + private ordered: readonly RegisteredSidebarRows[] = []; + + constructor(ctx: Context) { + super(ctx, "sidebarRows"); + this.contributions = createContributions(ctx); + } + + snapshot = () => { + const source = this.contributions.snapshot(); + if (source !== this.source) { + this.source = source; + this.ordered = Object.freeze( + [...source].sort((a, b) => + a.key < b.key ? -1 : a.key > b.key ? 1 : 0, + ), + ); + } + return this.ordered; + }; + subscribe = (listener: () => void) => this.contributions.subscribe(listener); + + register(value: SidebarRowContribution) { + if (value?.version !== 1) { + console.warn("Ignoring unsupported sidebar row contribution version"); + return; + } + if ( + typeof value.id !== "string" || + !idPattern.test(value.id) || + (value.render !== undefined && typeof value.render !== "function") || + (value.sortModes !== undefined && + (!Array.isArray(value.sortModes) || + new Set(value.sortModes.map((mode) => mode?.id)).size !== + value.sortModes.length || + value.sortModes.some( + (mode) => + !mode || + typeof mode.id !== "string" || + !idPattern.test(mode.id) || + typeof mode.label !== "string" || + !mode.label.trim() || + typeof mode.compare !== "function", + ))) + ) + throw new Error("Invalid sidebar row contribution"); + for (const mode of value.sortModes ?? []) { + if ( + !isSidebarSortMode( + `plugin:${this.ctx.pluginOwner?.id}/${value.id}/${mode.id}`, + ) + ) + throw new Error("Invalid sidebar row sort id"); + } + this.contributions.register(this.ctx, value); + } +} + +export function sidebarRow( + channel: ChannelSummary, + sectionKey: string, + unread: UnreadSnapshot, +): SidebarRow { + return { + id: channel.id, + kind: channel.channelType === "dm" ? "dm" : "team", + label: channel.name, + sectionKey, + channel, + unread, + }; +} + +export function sidebarRowSortModes(entries: readonly RegisteredSidebarRows[]) { + return entries.flatMap((entry) => + (entry.sortModes ?? []).map((mode) => ({ + ...mode, + id: `plugin:${entry.key}/${mode.id}` as SidebarSortMode, + })), + ); +} + +export function sortSidebarRows( + rows: readonly T[], + mode: SidebarRowSortMode | undefined, + data: (row: T) => SidebarRow, +): readonly T[] { + if (!mode) return rows; + try { + return [...rows].sort((a, b) => { + const order = mode.compare(data(a), data(b)); + if (!Number.isFinite(order)) + throw new Error("Invalid sidebar row comparison"); + return order; + }); + } catch { + return rows; + } +} diff --git a/src/features/channel-navigation/useSidebarRowUnread.ts b/src/features/channel-navigation/useSidebarRowUnread.ts new file mode 100644 index 0000000000..8fdcef043c --- /dev/null +++ b/src/features/channel-navigation/useSidebarRowUnread.ts @@ -0,0 +1,37 @@ +import { useCallback, useMemo, useSyncExternalStore } from "react"; +import type { UnreadCapability, UnreadSnapshot } from "../relay/unread"; + +export function useSidebarRowUnread(unread: UnreadCapability, ids: string) { + const targets = useMemo( + () => + (JSON.parse(ids) as string[]).map((channelId) => ({ + kind: "channel" as const, + channelId, + })), + [ids], + ); + const subscribe = useCallback( + (listener: () => void) => { + const stops = targets.map((target) => unread.subscribe(target, listener)); + return () => { + for (const stop of stops) stop(); + }; + }, + [unread, targets], + ); + const snapshot = useMemo(() => { + let previous = new Map(); + return () => { + const next = new Map( + targets.map((target) => [target.channelId, unread.snapshot(target)]), + ); + if ( + next.size !== previous.size || + [...next].some(([id, value]) => previous.get(id) !== value) + ) + previous = next; + return previous; + }; + }, [unread, targets]); + return useSyncExternalStore(subscribe, snapshot, snapshot); +} diff --git a/src/features/relay/channel-activity-session.test.ts b/src/features/relay/channel-activity-session.test.ts index e4a237da99..2f36ffd0a7 100644 --- a/src/features/relay/channel-activity-session.test.ts +++ b/src/features/relay/channel-activity-session.test.ts @@ -13,6 +13,7 @@ import { } from "./testing"; import type { LiveCallbacks } from "./live"; import type { RelayEvent } from "./events"; +import type { SidebarSortMode } from "./sidebar-sort"; function setup() { const viewer = keypair(), relay = keypair(), @@ -32,7 +33,7 @@ function setup() { ), ); const publish = vi.fn(async (_event: RelayEvent) => {}); - const sort: Record = {}; + const sort: Record = {}; const filters: Record = {}; const owner = createRelaySession( { @@ -50,8 +51,8 @@ function setup() { sort: {}, }), writeSidebarSort: async (group, mode) => { - if (mode === "recent") sort[group] = mode; - else delete sort[group]; + if (mode === "alpha") delete sort[group]; + else sort[group] = mode; return { ...sort }; }, writeSidebarFilter: async (group, mode) => { @@ -129,45 +130,49 @@ it("Active fetches activity independently of sort and shares Recent's demand lif h.owner.dispose(); } }); -it("reads no roster activity for A–Z, deduplicates Recent demand, and projects verified live activity monotonically", async () => { - const h = setup(); - try { - await h.initial(); - expect(h.activity).not.toHaveBeenCalled(); - await h.preferences.setSort("channels", "recent", []); - expect(h.activity).toHaveBeenCalledOnce(); - expect(h.channels.list().activityStatus).toBe("loading"); - expect(h.pending[0]?.ids).toEqual(["alpha", "beta"]); - take(h.pending).resolve([message(h.peer, "alpha", "history", 50)]); - await flush(); - const first = h.channels.list(); - expect(first.activityStatus).toBe("ready"); - expect(first.channels.find((c) => c.id === "alpha")?.lastActivityAt).toBe( - 50, - ); - expect(h.channels.list()).toBe(first); - await h.preferences.setSort("dms", "recent", []); - expect(h.activity).toHaveBeenCalledOnce(); - h.live().receive([message(h.peer, "alpha", "new", 90)]); - h.live().receive([ - message(h.peer, "alpha", "older", 70), - signed(h.peer, { - kind: 39000, - created_at: 200, - tags: [["d", "beta"]], - content: "metadata is not activity", - }), - ]); - expect( - h.channels.list().channels.find((c) => c.id === "alpha")?.lastActivityAt, - ).toBe(90); - expect( - h.channels.list().channels.find((c) => c.id === "beta")?.lastActivityAt, - ).toBeUndefined(); - } finally { - h.owner.dispose(); - } -}); +it.each(["recent", "plugin:example.plugin/rows/oldest"] as const)( + "%s loads activity on demand, deduplicates reads, and projects verified live activity monotonically", + async (mode) => { + const h = setup(); + try { + await h.initial(); + expect(h.activity).not.toHaveBeenCalled(); + await h.preferences.setSort("channels", mode, []); + expect(h.activity).toHaveBeenCalledOnce(); + expect(h.channels.list().activityStatus).toBe("loading"); + expect(h.pending[0]?.ids).toEqual(["alpha", "beta"]); + take(h.pending).resolve([message(h.peer, "alpha", "history", 50)]); + await flush(); + const first = h.channels.list(); + expect(first.activityStatus).toBe("ready"); + expect(first.channels.find((c) => c.id === "alpha")?.lastActivityAt).toBe( + 50, + ); + expect(h.channels.list()).toBe(first); + await h.preferences.setSort("dms", mode, []); + expect(h.activity).toHaveBeenCalledOnce(); + h.live().receive([message(h.peer, "alpha", "new", 90)]); + h.live().receive([ + message(h.peer, "alpha", "older", 70), + signed(h.peer, { + kind: 39000, + created_at: 200, + tags: [["d", "beta"]], + content: "metadata is not activity", + }), + ]); + expect( + h.channels.list().channels.find((c) => c.id === "alpha") + ?.lastActivityAt, + ).toBe(90); + expect( + h.channels.list().channels.find((c) => c.id === "beta")?.lastActivityAt, + ).toBeUndefined(); + } finally { + h.owner.dispose(); + } + }, +); // Own sends updated the row preview at once but moved it in Recent and Active // only when the live socket echoed them. it.each(["connected", "retrying"] as const)( @@ -375,50 +380,56 @@ it("retires failed activity only when the last Recent demand ends and recovers o h.owner.dispose(); } }); -it("cancels only after the last Recent section clears and refreshes unchanged rosters on re-entry", async () => { - const h = setup(); - try { - await h.initial(); - await h.preferences.setSort("channels", "recent", []); - const retired = take(h.pending); - await h.preferences.setSort("dms", "recent", []); - await h.preferences.setSort("channels", "alpha", []); - expect(retired.signal.aborted).toBe(false); - expect(h.activity).toHaveBeenCalledOnce(); - await h.preferences.setSort("dms", "alpha", []); - expect(retired.signal.aborted).toBe(true); - expect(h.channels.list().activityStatus).toBe("idle"); - // Ignoring transport cancellation must not let late history win. Live - // activity remains valid even while every section is A–Z. - retired.resolve([message(h.peer, "alpha", "retired history", 200)]); - h.live().receive([message(h.peer, "alpha", "live", 90)]); - await flush(); - expect( - h.channels.list().channels.find((c) => c.id === "alpha")?.lastActivityAt, - ).toBe(90); - await h.preferences.setSort("channels", "recent", []); - expect(h.activity).toHaveBeenCalledTimes(2); - const resumed = take(h.pending); - expect(resumed.ids).toEqual(["alpha", "beta"]); - resumed.resolve([message(h.peer, "alpha", "fresh history", 120)]); - await flush(); - expect(h.channels.list().activityStatus).toBe("ready"); - expect( - h.channels.list().channels.find((c) => c.id === "alpha")?.lastActivityAt, - ).toBe(120); - // Completed data stays useful, but a new demand lifetime still refreshes it. - await h.preferences.setSort("channels", "alpha", []); - expect( - h.channels.list().channels.find((c) => c.id === "alpha")?.lastActivityAt, - ).toBe(120); - await h.preferences.setSort("channels", "recent", []); - expect(h.activity).toHaveBeenCalledTimes(3); - take(h.pending).resolve([]); - await flush(); - } finally { - h.owner.dispose(); - } -}); +it.each(["recent", "plugin:example.plugin/rows/oldest"] as const)( + "%s cancels only after the last section clears and refreshes unchanged rosters on re-entry", + async (mode) => { + const h = setup(); + try { + await h.initial(); + await h.preferences.setSort("channels", mode, []); + const retired = take(h.pending); + await h.preferences.setSort("dms", mode, []); + await h.preferences.setSort("channels", "alpha", []); + expect(retired.signal.aborted).toBe(false); + expect(h.activity).toHaveBeenCalledOnce(); + await h.preferences.setSort("dms", "alpha", []); + expect(retired.signal.aborted).toBe(true); + expect(h.channels.list().activityStatus).toBe("idle"); + // Ignoring transport cancellation must not let late history win. Live + // activity remains valid even while every section is A–Z. + retired.resolve([message(h.peer, "alpha", "retired history", 200)]); + h.live().receive([message(h.peer, "alpha", "live", 90)]); + await flush(); + expect( + h.channels.list().channels.find((c) => c.id === "alpha") + ?.lastActivityAt, + ).toBe(90); + await h.preferences.setSort("channels", mode, []); + expect(h.activity).toHaveBeenCalledTimes(2); + const resumed = take(h.pending); + expect(resumed.ids).toEqual(["alpha", "beta"]); + resumed.resolve([message(h.peer, "alpha", "fresh history", 120)]); + await flush(); + expect(h.channels.list().activityStatus).toBe("ready"); + expect( + h.channels.list().channels.find((c) => c.id === "alpha") + ?.lastActivityAt, + ).toBe(120); + // Completed data stays useful, but a new demand lifetime still refreshes it. + await h.preferences.setSort("channels", "alpha", []); + expect( + h.channels.list().channels.find((c) => c.id === "alpha") + ?.lastActivityAt, + ).toBe(120); + await h.preferences.setSort("channels", mode, []); + expect(h.activity).toHaveBeenCalledTimes(3); + take(h.pending).resolve([]); + await flush(); + } finally { + h.owner.dispose(); + } + }, +); // Clearing every value on one channel's access loss flashed Recent sections to // A–Z until the next read finished. it("losing one channel keeps the others' activity while the roster rereads", async () => { diff --git a/src/features/relay/native-sidebar.test.ts b/src/features/relay/native-sidebar.test.ts index cb44084c3a..57c2d9efbe 100644 --- a/src/features/relay/native-sidebar.test.ts +++ b/src/features/relay/native-sidebar.test.ts @@ -881,6 +881,28 @@ it.each([ }, ); +it.each(["channels", "section:work"])( + "confirms native plugin sort choices for %s through encrypted readback", + async (group) => { + const transport = await connectNativeTransport(community); + await transport.writeSidebarSort?.("forums", "recent", [], signal); + for (const mode of [ + "plugin:example.plugin/rows/reverse", + "plugin:example.plugin/rows/other", + "recent", + "alpha", + ] as const) { + await expect( + transport.writeSidebarSort?.(group, mode, ["work"], signal), + ).resolves.toEqual( + mode === "alpha" + ? { forums: "recent" } + : { forums: "recent", [group]: mode }, + ); + } + }, +); + it("uses the native filter signer and confirmed writer without changing sort", async () => { const transport = await connectNativeTransport(community); await transport.writeSidebarSort?.("channels", "recent", [], signal); diff --git a/src/features/relay/native-sidebar.ts b/src/features/relay/native-sidebar.ts index 9d07550de2..badf2f76b4 100644 --- a/src/features/relay/native-sidebar.ts +++ b/src/features/relay/native-sidebar.ts @@ -330,7 +330,13 @@ export function nativeSidebar(transport: ReadTransport) { if (!validSidebarSort(group, mode, sectionIds)) throw new Error("Invalid sidebar sort intent"); const prepare = (current: Record, createdAt: number) => { - const next = editSidebarSort(current, createdAt, group, mode); + const next = editSidebarSort( + current, + createdAt, + group, + mode, + Date.now(), + ); return { next, result: project("channel-sort", next, sectionIds).sort ?? {}, @@ -341,7 +347,8 @@ export function nativeSidebar(transport: ReadTransport) { signal, prepare, (value) => - ((value.groups as Record)[group] ?? "alpha") === mode, + (project("channel-sort", value, sectionIds).sort?.[group] ?? + "alpha") === mode, "Sidebar sort changed on another device; reload and try again", ); }, diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 492b795d69..70a049a1db 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -72,6 +72,7 @@ import { agentAudience } from "./audience"; import type { ChannelList, ChannelSummary } from "./contracts"; import { createChannelActivity } from "./channel-activity"; import { readSidebarPreferences } from "./sidebar-preferences"; +import { sidebarSortNeedsActivity } from "./sidebar-sort"; import { createSidebarPreferencesStore } from "./sidebar-preferences-store"; import { createUserStatuses } from "./user-status"; import { createEmojiDirectory } from "./emoji-directory"; @@ -3200,7 +3201,7 @@ export function createRelaySession( if (closed || !transport?.channelActivity) return; const preferences = sidebarPreferences.queries.snapshot().data; if ( - !Object.values(preferences?.sort ?? {}).includes("recent") && + !Object.values(preferences?.sort ?? {}).some(sidebarSortNeedsActivity) && !Object.values(preferences?.filters ?? {}).includes("active") ) { channelActivity.cancel(); diff --git a/src/features/relay/sidebar-edits.ts b/src/features/relay/sidebar-edits.ts index fc7fa8309d..6ccca459bb 100644 --- a/src/features/relay/sidebar-edits.ts +++ b/src/features/relay/sidebar-edits.ts @@ -1,3 +1,4 @@ +import { isSidebarSortMode } from "./sidebar-sort.ts"; import { projectSidebarPreferences, validActiveWindow, @@ -9,6 +10,7 @@ import { } from "./sidebar-preferences.ts"; import { editSidebarRecord, + editSidebarSortRecord, nextSidebarSectionOrder, projectSidebarRecord, } from "./sidebar-registers.ts"; @@ -127,7 +129,7 @@ export function validSidebarSort( ): boolean { return ( group.length <= 264 && - ["alpha", "recent"].includes(mode) && + isSidebarSortMode(mode) && sectionIds.length <= 100 && sectionIds.every(validSidebarChannelId) && (["starred", "channels", "forums", "dms"].includes(group) || @@ -200,13 +202,7 @@ export function editSidebarSort( mode: SidebarSortMode, now = Date.now(), ): Record { - return editSidebarRecord( - "channel-sort", - current, - createdAt, - [[["g", group], mode === "alpha" ? null : mode]], - now, - ); + return editSidebarSortRecord(current, createdAt, group, mode, now); } export function editSidebarFilter( diff --git a/src/features/relay/sidebar-preferences.ts b/src/features/relay/sidebar-preferences.ts index 96aedaeb2b..c9c0f2a21b 100644 --- a/src/features/relay/sidebar-preferences.ts +++ b/src/features/relay/sidebar-preferences.ts @@ -1,4 +1,9 @@ -import { projectSidebarRecord } from "./sidebar-registers.ts"; +import { isSidebarSortMode, type SidebarSortMode } from "./sidebar-sort.ts"; +export type { SidebarSortMode } from "./sidebar-sort.ts"; +import { + projectSidebarRecord, + projectSidebarSort, +} from "./sidebar-registers.ts"; import type { ReadFilter } from "./events.ts"; import type { RelayEvent } from "./events.ts"; import type { RelayReader } from "./reader.ts"; @@ -74,7 +79,6 @@ export type SidebarFilterMutator = ( signal: AbortSignal, activeWindowSeconds?: number | null, ) => Promise; -export type SidebarSortMode = "alpha" | "recent"; export type SidebarSortMutator = ( group: string, mode: SidebarSortMode, @@ -217,19 +221,17 @@ export function projectSidebarPreferences( } } if (sort !== undefined) { - const data = projectSidebarRecord("channel-sort", object(sort)); + const data = object(sort); if (data.version !== 1) throw new Error("Unsupported sidebar sort"); - const entries = Object.entries(object(data.groups)); - if (entries.length > 104) throw new Error("Sidebar sort budget exceeded"); + const entries = Object.entries(projectSidebarSort(data)); const fixed = new Set(["starred", "channels", "forums", "dms"]); const liveSections = new Set( (sortSectionIds ?? []).map((id) => `section:${id}`), ); result.sort = Object.fromEntries( entries - .filter( - (entry): entry is [string, SidebarSortMode] => - entry[1] === "alpha" || entry[1] === "recent", + .filter((entry): entry is [string, SidebarSortMode] => + isSidebarSortMode(entry[1]), ) .map(([key, mode]) => [text(key, 264), mode] as const) .filter( diff --git a/src/features/relay/sidebar-registers.test.ts b/src/features/relay/sidebar-registers.test.ts index 57608a259c..64e668f11a 100644 --- a/src/features/relay/sidebar-registers.test.ts +++ b/src/features/relay/sidebar-registers.test.ts @@ -1,8 +1,10 @@ import { expect, it } from "vitest"; import { editSidebarRecord, + editSidebarSortRecord, nextSidebarSectionOrder, } from "./sidebar-registers"; +import { editSidebarSort } from "./sidebar-edits"; import { projectSidebarPreferences } from "./sidebar-preferences"; const dev = "1234567890abcdef"; const reg = (value: unknown, v = 100) => [v, dev, value]; @@ -243,3 +245,173 @@ it("keeps missing-assignment removal a no-op without suppressing other null regi g: { channels: [expect.any(Number), expect.any(String), null] }, }); }); + +it("keeps plugin choices outside builtin metadata and invalidates only a changed group's binding", () => { + const mode = "plugin:example.plugin/rows/reverse"; + const longest = `plugin:a/b/${"x".repeat(501)}` as const; + const bounded = editSidebarSortRecord( + { version: 1, groups: { dms: "recent" } }, + 1, + "channels", + longest, + 2000, + ); + expect( + projectSidebarPreferences(undefined, undefined, undefined, bounded).sort, + ).toEqual({ channels: longest, dms: "recent" }); + expect(bounded.groups).toEqual({ channels: "alpha", dms: "recent" }); + expect(bounded.meta).toMatchObject({ + g: { channels: [2000, expect.any(String), "alpha"] }, + }); + const current = editSidebarSortRecord(bounded, 2, "channels", mode, 3000); + expect(editSidebarSortRecord(current, 3, "channels", mode, 4000)).toBe( + current, + ); + const next = editSidebarRecord( + "channel-sort", + current, + 1, + [[["g", "dms"], null]], + 4000, + ); + expect( + projectSidebarPreferences(undefined, undefined, undefined, next).sort, + ).toEqual({ channels: mode }); + expect( + projectSidebarPreferences(undefined, undefined, undefined, current).sort, + ).toEqual({ channels: mode, dms: "recent" }); + for (const mode of ["alpha", "recent"] as const) { + const changed = editSidebarRecord( + "channel-sort", + next, + 4, + [[["g", "channels"], mode === "alpha" ? null : mode]], + 5000, + ); + expect(changed).not.toBe(next); + expect(changed.pluginSort).toEqual(next.pluginSort); + expect( + projectSidebarPreferences(undefined, undefined, undefined, changed).sort, + ).toEqual(mode === "alpha" ? {} : { channels: mode }); + } + const alpha = editSidebarSortRecord(current, 3, "channels", "alpha", 4000); + expect( + projectSidebarPreferences(undefined, undefined, undefined, alpha).sort, + ).toEqual({ dms: "recent" }); + for (const invalid of [ + "plugin:x", + "plugin:a/b/", + "plugin:a/b/c/d", + "plugin:A/b/c", + `plugin:a/b/${"x".repeat(502)}`, + ]) { + expect(() => + projectSidebarPreferences(undefined, undefined, undefined, { + ...next, + pluginSort: { v: 1, g: { channels: [2000, dev, invalid] } }, + }), + ).toThrow(); + } +}); + +it("bounds builtin and plugin sort sources separately before filtering deleted groups", () => { + const builtin = Object.fromEntries( + Array.from({ length: 60 }, (_, i) => [`section:builtin${i}`, "recent"]), + ); + const plugin = Object.fromEntries( + Array.from({ length: 50 }, (_, i) => [ + `section:plugin${i}`, + reg("plugin:example.plugin/rows/reverse"), + ]), + ); + const record = { + version: 1, + groups: builtin, + meta: { + v: 1, + g: { + ...Object.fromEntries( + Object.keys(builtin).map((key) => [key, reg("recent")]), + ), + ...Object.fromEntries( + Object.keys(plugin).map((key) => [key, reg(null)]), + ), + }, + }, + pluginSort: { v: 1, g: plugin }, + }; + expect( + projectSidebarPreferences(undefined, undefined, undefined, record, [ + "builtin0", + "plugin0", + ]).sort, + ).toEqual({ + "section:builtin0": "recent", + "section:plugin0": "plugin:example.plugin/rows/reverse", + }); + expect(() => + projectSidebarPreferences( + undefined, + undefined, + undefined, + { + version: 1, + groups: Object.fromEntries( + Array.from({ length: 105 }, (_, i) => [`section:${i}`, "recent"]), + ), + }, + [], + ), + ).toThrow("Sidebar sort budget exceeded"); +}); + +it("reclaims superseded plugin bindings without losing unavailable choices", () => { + const mode = "plugin:unavailable.plugin/rows/reverse"; + const choices = Object.fromEntries( + Array.from({ length: 104 }, (_, i) => [`section:group${i}`, reg(mode)]), + ); + const base = Object.fromEntries( + Object.keys(choices).map((key) => [key, reg(null)]), + ); + base["section:group1"] = reg("recent", 101); + base["section:group2"] = reg(null, 102); + const current = { + version: 1, + groups: { "section:group1": "recent" }, + meta: { v: 1, g: base }, + pluginSort: { v: 1, g: choices }, + }; + const before = structuredClone(current); + const next = editSidebarSort(current, 1, "section:new", mode, 2000); + expect( + projectSidebarPreferences(undefined, undefined, undefined, next).sort, + ).toMatchObject({ + "section:group0": mode, + "section:group1": "recent", + "section:group103": mode, + "section:new": mode, + }); + const retained = (next.pluginSort as { g: object }).g; + expect(Object.keys(retained)).toHaveLength(103); + expect(retained).not.toHaveProperty("section:group1"); + expect(retained).not.toHaveProperty("section:group2"); + expect(current).toEqual(before); + expect(() => + editSidebarSortRecord( + { + ...current, + groups: {}, + meta: { + v: 1, + g: Object.fromEntries( + Object.keys(choices).map((key) => [key, reg(null)]), + ), + }, + }, + 1, + "channels", + mode, + 2000, + ), + ).toThrow("Plugin sidebar sort budget exceeded"); +}); diff --git a/src/features/relay/sidebar-registers.ts b/src/features/relay/sidebar-registers.ts index 29e4559839..49afc32385 100644 --- a/src/features/relay/sidebar-registers.ts +++ b/src/features/relay/sidebar-registers.ts @@ -1,3 +1,5 @@ +import { isSidebarSortMode, type SidebarSortMode } from "./sidebar-sort.ts"; + /** Section and section-choice registers. No persistence or publication owner. */ export type SidebarRegister = [number, string, unknown]; type Tree = { [key: string]: Tree | SidebarRegister }; @@ -285,6 +287,124 @@ export function editSidebarRecord( return { ...current, ...projected, meta }; } +function pluginSortRegisters(value: unknown): Tree { + if (value === undefined) return {}; + const data = object(value); + if ( + data.v !== 1 || + Object.keys(data).some((key) => !["v", "g"].includes(key)) + ) + throw new Error("Unsupported plugin sidebar sort"); + const groups = map(data.g, 264, (value) => + register( + value, + (mode) => + typeof mode === "string" && + mode.startsWith("plugin:") && + isSidebarSortMode(mode), + ), + ); + if (Object.keys(groups).length > 104) + throw new Error("Plugin sidebar sort budget exceeded"); + return groups; +} + +/** Plugin choices stay outside the strict metadata understood by older hosts. */ +export function projectSidebarSort( + value: Record, +): Record { + const projected = projectSidebarRecord("channel-sort", value); + const groups = { ...object(projected.groups) }; + if (Object.keys(groups).length > 104) + throw new Error("Sidebar sort budget exceeded"); + const base = + value.meta === undefined + ? {} + : (metadata("channel-sort", value.meta, true).g as Tree); + for (const [key, choice] of Object.entries( + pluginSortRegisters(value.pluginSort), + )) { + const bound = own(base, key); + if ( + Array.isArray(choice) && + Array.isArray(bound) && + (bound[2] === "alpha" || bound[2] === null) && + choice[0] === bound[0] && + choice[1] === bound[1] + ) + Object.defineProperty(groups, key, { + value: choice[2], + enumerable: true, + configurable: true, + writable: true, + }); + } + return groups; +} + +export function editSidebarSortRecord( + current: Record, + createdAt: number, + group: string, + mode: SidebarSortMode, + now: number, +): Record { + const choices = pluginSortRegisters(current.pluginSort); + const previousCount = Object.keys(choices).length; + const plugin = mode.startsWith("plugin:"); + // Older A–Z writers tombstone with null and skip equal values. A distinct + // alpha anchor lets their explicit choice supersede the plugin binding. + const next = editSidebarRecord( + "channel-sort", + current, + createdAt, + [[["g", group], plugin ? "alpha" : mode === "recent" ? "recent" : null]], + now, + ); + const base = + next.meta === undefined + ? {} + : (metadata("channel-sort", next.meta, true).g as Tree); + // Section presence is a separate record; a caller snapshot cannot prove deletion. + for (const [key, choice] of Object.entries(choices)) { + const bound = own(base, key); + if ( + !Array.isArray(choice) || + !Array.isArray(bound) || + (bound[2] !== "alpha" && bound[2] !== null) || + choice[0] !== bound[0] || + choice[1] !== bound[1] + ) + delete choices[key]; + } + const pruned = Object.keys(choices).length !== previousCount; + if (plugin) { + const bound = own(base, group) as SidebarRegister; + const previous = own(choices, group); + if ( + next === current && + !pruned && + Array.isArray(previous) && + previous[0] === bound[0] && + previous[1] === bound[1] && + previous[2] === mode + ) + return current; + Object.defineProperty(choices, group, { + value: [bound[0], bound[1], mode], + enumerable: true, + configurable: true, + writable: true, + }); + } else { + if (!Object.hasOwn(choices, group) && !pruned) return next; + delete choices[group]; + } + const pluginSort = { v: 1, g: choices }; + pluginSortRegisters(pluginSort); + return { ...next, pluginSort }; +} + /** Insertion uses canonical orders, not the densely numbered legacy projection. */ export function nextSidebarSectionOrder( current: Record, diff --git a/src/features/relay/sidebar-sort.ts b/src/features/relay/sidebar-sort.ts new file mode 100644 index 0000000000..c9f1de5903 --- /dev/null +++ b/src/features/relay/sidebar-sort.ts @@ -0,0 +1,17 @@ +export type SidebarSortMode = "alpha" | "recent" | `plugin:${string}`; + +export function isSidebarSortMode(value: unknown): value is SidebarSortMode { + return ( + value === "alpha" || + value === "recent" || + (typeof value === "string" && + value.length <= 512 && + /^plugin:[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test( + value, + )) + ); +} + +export function sidebarSortNeedsActivity(mode: SidebarSortMode): boolean { + return mode === "recent" || mode.startsWith("plugin:"); +} diff --git a/src/plugins/author.test.mjs b/src/plugins/author.test.mjs index 4e86b7d4d9..60b293b720 100644 --- a/src/plugins/author.test.mjs +++ b/src/plugins/author.test.mjs @@ -33,7 +33,7 @@ test("generated author package in a path with spaces exposes shared message fact join(dir, "consumer.ts"), ` import { createElement } from "react"; -import type { Context, DecisionResult, Host, PluginManifest, NamingPolicy, PageSidebarProps, SidebarSectionContribution, SidebarSections, UnreadCapability } from "@buzz/author"; +import type { Context, DecisionResult, Host, PluginManifest, NamingPolicy, PageSidebarProps, SidebarSectionContribution, SidebarSections, SidebarRowContribution, SidebarRows, UnreadCapability } from "@buzz/author"; export const manifest: PluginManifest = { id: "example.plugin", name: "Example", apiVersion: 1, host: { @@ -112,6 +112,17 @@ export function apply(ctx: Context) { // @ts-expect-error Row counts are numeric, not display text. const badCount: SidebarSectionContribution = { ...section, rows: { snapshot: () => [{ id: "bad", label: "Bad", count: "two" }], subscribe: () => () => {} } }; void badCount; + const hostRows: SidebarRows | undefined = ctx.reflect.get("sidebarRows"); + const decoration: SidebarRowContribution = { + version: 1, id: "details", + render: (row, Default) => row.kind === "dm" ? createElement("div", { title: row.label }, Default) : Default, + sortModes: [{ id: "reverse", label: "Reverse", compare: (a, b) => b.label.localeCompare(a.label) }], + }; + hostRows?.register(decoration); + // @ts-expect-error Host row contribution versions are explicit. + hostRows?.register({ ...decoration, version: 2 }); + // @ts-expect-error Render results must be elements or an intentional null. + hostRows?.register({ version: 1, id: "invalid", render: () => "text" }); ctx.pages.register({ id: "other", title: "Other", diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 5da93a7163..e00fe708b5 100644 --- a/src/plugins/author.ts +++ b/src/plugins/author.ts @@ -11,6 +11,12 @@ export type { export type { NamingIdentity } from "../features/identity-names/policy"; export type { Browser, BrowserViewProps } from "../features/browser/api"; export type { Page, PageSidebarProps, Pages } from "../features/pages/service"; +export type { + SidebarRow, + SidebarRowSortMode, + SidebarRowContribution, + SidebarRows, +} from "../features/channel-navigation/sidebar-rows-service"; export type { SidebarSectionAction, SidebarSectionContribution, diff --git a/tests/browser/plugin-fixtures.tsx b/tests/browser/plugin-fixtures.tsx index 120a389957..5e86bbecc6 100644 --- a/tests/browser/plugin-fixtures.tsx +++ b/tests/browser/plugin-fixtures.tsx @@ -162,6 +162,37 @@ export const fixturePlugins: readonly BundledPlugin[] = [ }, }, }, + { + manifest: { + id: "fixture.sidebar-rows", + name: "Host row fixture", + apiVersion: 1, + }, + enabledByDefault: false, + module: { + apply(ctx) { + ctx.reflect.get("sidebarRows")?.register({ + version: 1, + id: "rows", + render: (row, Default) => + row.id === "beta" ? ( +
+ {Default} +
+ ) : ( + Default + ), + sortModes: [ + { + id: "reverse", + label: "Reverse name", + compare: (a, b) => b.label.localeCompare(a.label), + }, + ], + }); + }, + }, + }, { manifest: { id: "fixture.sidebar-sections", diff --git a/tests/browser/sidebar-rows.spec.mjs b/tests/browser/sidebar-rows.spec.mjs new file mode 100644 index 0000000000..82036d9260 --- /dev/null +++ b/tests/browser/sidebar-rows.spec.mjs @@ -0,0 +1,110 @@ +import { test as appTest } from "./fixture.mjs"; +import { expect } from "./playwright-test.mjs"; +import { + openPage, + openSettings, + closeSettings, + selectSettingsSection, + settleShellToggle, +} from "./navigation.mjs"; +import { setPluginEnabled } from "./plugin-settings.mjs"; + +appTest.use({ + pluginFixtures: true, + productionBroker: true, + channelIds: ["alpha", "beta", "gamma"], + channelNames: { alpha: "Alpha", beta: "Beta", gamma: "Gamma" }, + historyCounts: { alpha: 1, beta: 0, gamma: 0 }, +}); + +// Real menu hit targets and encrypted preference readback survive a full reload. +appTest( + "plugins decorate live rows and persist a custom section sort", + async ({ page, app }, testInfo) => { + await page.goto(app.origin); + await openPage(page, "Messages"); + const section = page.locator('[data-sidebar-section="channels"]'); + const rows = section.locator("[data-channel-id]"); + const expectOrder = (ids) => + expect + .poll(() => + rows.evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-channel-id")), + ), + ) + .toEqual(ids); + await expectOrder(["alpha", "beta", "gamma"]); + await page.screenshot({ + path: testInfo.outputPath("sidebar-rows-before.png"), + }); + await openSettings(page); + await selectSettingsSection(page, "Manage plugins"); + await setPluginEnabled(page, "Host row fixture", true); + await closeSettings(page); + await expect( + section.locator('[data-plugin-row="decorated"] [data-channel-id="beta"]'), + ).toBeVisible(); + await page + .getByRole("button", { name: "More actions for Channels" }) + .click(); + await page.getByRole("menuitem", { name: "Sort", exact: true }).click(); + await expect( + page.getByRole("menuitemradio", { name: "Reverse name" }), + ).toBeVisible(); + await page + .getByRole("menu", { name: "Sort Channels", exact: true }) + .evaluate(async (element) => { + await Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished), + ); + }); + await page.screenshot({ + path: testInfo.outputPath("sidebar-rows-sort-menu.png"), + }); + await page.getByRole("menuitemradio", { name: "Reverse name" }).click(); + await expectOrder(["gamma", "beta", "alpha"]); + await page.reload(); + await expectOrder(["gamma", "beta", "alpha"]); + for (const [width, mode, fontSize] of [ + [1440, "light", "16px"], + [1440, "dark", "20px"], + [390, "light", "20px"], + [390, "dark", "16px"], + ]) { + await page.setViewportSize({ width, height: 850 }); + await page.evaluate( + ({ mode, fontSize }) => { + document.documentElement.setAttribute("data-color-mode", mode); + document.documentElement.style.fontSize = fontSize; + }, + { mode, fontSize }, + ); + await settleShellToggle(page); + const toggle = page.locator("[data-shell-sidebar-toggle]"); + if ((await toggle.getAttribute("aria-label"))?.startsWith("Show")) + await toggle.click(); + const beta = section.locator('[data-channel-id="beta"]'); + await expect(beta).toBeVisible(); + await beta.focus(); + await expect(beta).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(beta).toHaveAttribute("aria-current", "page"); + if (width <= 650) + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + if ((await toggle.getAttribute("aria-label"))?.startsWith("Show")) + await toggle.click(); + await expect(beta).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath(`sidebar-rows-after-${width}-${mode}.png`), + }); + } + await openSettings(page); + await selectSettingsSection(page, "Manage plugins"); + await setPluginEnabled(page, "Host row fixture", false); + await closeSettings(page); + await expectOrder(["alpha", "beta", "gamma"]); + await expect(section.locator("[data-plugin-row]")).toHaveCount(0); + }, +);