diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 877cd948ad..5fc8554b04 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -11,6 +11,7 @@ import { useBackForwardControls } from "@/app/navigation/useBackForwardControls" import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions"; import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions"; import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog"; +import { useChannelNavigationShortcuts } from "@/app/useChannelNavigationShortcuts"; import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; import { useSettingsShortcuts } from "@/app/useSettingsShortcuts"; import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications"; @@ -91,7 +92,7 @@ import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationCon import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; -import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; +import { useGlobalActionShortcuts } from "@/app/useGlobalActionShortcuts"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; @@ -628,69 +629,15 @@ export function AppShell() { () => setIsCreateChannelOpen(true), [], ); - React.useLayoutEffect(() => { - if (settingsOpen) { - return; - } - - function handleKeyDown(event: KeyboardEvent) { - if (!hasPrimaryShortcutModifier(event) || event.altKey || event.repeat) { - return; - } - - // A focused surface may claim the shortcut first — e.g. the composer - // consumes ⌘K to open the link editor when text is selected. Its - // element-level handler runs before this window-level bubble listener - // and calls `preventDefault()`; respect that instead of also opening - // the global dialog. - if (event.defaultPrevented) { - return; - } - - const key = event.key.toLowerCase(); - if (key === "k" && !event.shiftKey) { - event.preventDefault(); - handleOpenSearch(); - return; - } - - if (key === "k" && event.shiftKey) { - event.preventDefault(); - handleOpenNewDm(); - return; - } - - if (key === "n" && event.shiftKey) { - event.preventDefault(); - handleOpenCreateChannel(); - return; - } - - if (key === "o" && event.shiftKey) { - event.preventDefault(); - handleOpenBrowseChannels(); - return; - } - - if (key === "a" && event.shiftKey) { - event.preventDefault(); - void goHome(); - return; - } - } - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [ - handleOpenBrowseChannels, - handleOpenNewDm, - handleOpenCreateChannel, - handleOpenSearch, - goHome, - settingsOpen, - ]); + const handleGoHomeShortcut = React.useCallback(() => void goHome(), [goHome]); + useGlobalActionShortcuts({ + enabled: !settingsOpen, + onGoHome: handleGoHomeShortcut, + onOpenBrowseChannels: handleOpenBrowseChannels, + onOpenCreateChannel: handleOpenCreateChannel, + onOpenNewDm: handleOpenNewDm, + onOpenSearch: handleOpenSearch, + }); useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, @@ -703,6 +650,16 @@ export function AppShell() { markChannelRead, selectedView, }); + useChannelNavigationShortcuts({ + channels: sidebarChannels, + currentPubkey: identityQuery.data?.pubkey, + mutedChannelIds, + onSelectChannel: (channelId) => void goChannel(channelId), + relayUrl: communitiesHook.activeCommunity?.relayUrl, + selectedChannelId, + selectedView, + starredChannelIds, + }); return ( diff --git a/desktop/src/app/useChannelNavigationShortcuts.ts b/desktop/src/app/useChannelNavigationShortcuts.ts new file mode 100644 index 0000000000..03d742e4bc --- /dev/null +++ b/desktop/src/app/useChannelNavigationShortcuts.ts @@ -0,0 +1,128 @@ +import * as React from "react"; + +import { readChannelSectionsStore } from "@/features/sidebar/lib/channelSectionsStorage"; +import { + readChannelSortStore, + sortModeForGroup, + type ChannelSortGroupKey, +} from "@/features/sidebar/lib/channelSortPreference"; +import { + adjacentSidebarChannelId, + buildSidebarChannelGroups, + flattenSidebarChannelGroups, +} from "@/features/sidebar/lib/sidebarChannelOrder"; +import type { Channel } from "@/shared/api/types"; +import { isMacPlatform } from "@/shared/lib/platform"; + +/** + * Next/previous channel keyboard navigation: ⌥↓ / ⌥↑ on macOS and + * Ctrl+Alt+↓ / Ctrl+Alt+↑ on Windows/Linux move the active channel selection + * down/up the sidebar's stream-channel list (starred → custom sections → + * unassigned, each in its own saved sort order). Windows/Linux includes Ctrl + * because plain Alt+arrows are taken by back/forward navigation + * (useBackForwardControls). + * + * Scope matches the sidebar's Channels area: the active community's stream + * channels only — forums and DMs are not part of the cycle. Muted channels + * are skipped, the selection stops at both ends (no wraparound), and the + * shortcut is a no-op when no channel is selected (e.g. home feed) or the + * selected conversation isn't in the list. + * + * Section membership and per-group sort preferences are read from their + * relay-scoped localStorage stores at keypress time — the same stores the + * sidebar hooks keep in sync (locally and from remote NIP-78 blobs) — so the + * traversal order can't drift from what the sidebar displays without + * duplicating the sidebar's relay subscriptions here. + */ +export function useChannelNavigationShortcuts({ + channels, + currentPubkey, + mutedChannelIds, + onSelectChannel, + relayUrl, + selectedChannelId, + selectedView, + starredChannelIds, +}: { + channels: Channel[]; + currentPubkey: string | undefined; + mutedChannelIds: ReadonlySet; + onSelectChannel: (channelId: string) => void; + relayUrl: string | undefined; + selectedChannelId: string | null; + selectedView: string; + starredChannelIds: ReadonlySet | undefined; +}) { + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + if (event.defaultPrevented) return; + + const matchesCombo = isMacPlatform() + ? event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey + : event.ctrlKey && event.altKey && !event.metaKey && !event.shiftKey; + if (!matchesCombo) return; + + if (selectedView !== "channel" || !selectedChannelId) return; + + const streamChannels = channels.filter( + (channel) => channel.channelType === "stream", + ); + const sectionsStore = currentPubkey + ? readChannelSectionsStore(currentPubkey, relayUrl) + : null; + const sortStore = currentPubkey + ? readChannelSortStore(currentPubkey, relayUrl) + : null; + const sections = (sectionsStore?.sections ?? []) + .slice() + .sort((a, b) => a.order - b.order); + const sortModeFor = (group: ChannelSortGroupKey) => + sortStore ? sortModeForGroup(sortStore, group) : ("alpha" as const); + + const ordered = flattenSidebarChannelGroups( + buildSidebarChannelGroups({ + streamChannels, + starredChannelIds, + sections, + assignments: sectionsStore?.assignments ?? {}, + sortModeFor, + }), + sections, + ); + + // Only act when the active conversation is actually in the sidebar's + // stream list — for forums/DMs the combo keeps its default behavior. + if (!ordered.some((channel) => channel.id === selectedChannelId)) return; + + // The shortcut owns this combo whenever a stream channel is active, + // including at the list's ends — a boundary no-op shouldn't fall + // through to scrolling or text-caret movement. + event.preventDefault(); + + const nextChannelId = adjacentSidebarChannelId( + ordered, + selectedChannelId, + event.key === "ArrowDown" ? 1 : -1, + mutedChannelIds, + ); + if (nextChannelId) { + onSelectChannel(nextChannelId); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + channels, + currentPubkey, + mutedChannelIds, + onSelectChannel, + relayUrl, + selectedChannelId, + selectedView, + starredChannelIds, + ]); +} diff --git a/desktop/src/app/useGlobalActionShortcuts.ts b/desktop/src/app/useGlobalActionShortcuts.ts new file mode 100644 index 0000000000..00f8f68779 --- /dev/null +++ b/desktop/src/app/useGlobalActionShortcuts.ts @@ -0,0 +1,89 @@ +import * as React from "react"; + +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; + +/** + * Global app-action shortcuts on the primary modifier: ⌘K quick search, + * ⇧⌘K new DM, ⇧⌘N new channel, ⇧⌘O browse channels, ⇧⌘A home (Ctrl on + * Windows/Linux). Suspended while `enabled` is false (settings open), which + * mirrors the previous inline AppShell effect this was extracted from. + */ +export function useGlobalActionShortcuts({ + enabled, + onGoHome, + onOpenBrowseChannels, + onOpenCreateChannel, + onOpenNewDm, + onOpenSearch, +}: { + enabled: boolean; + onGoHome: () => void; + onOpenBrowseChannels: () => void; + onOpenCreateChannel: () => void; + onOpenNewDm: () => void; + onOpenSearch: () => void; +}) { + React.useLayoutEffect(() => { + if (!enabled) { + return; + } + + function handleKeyDown(event: KeyboardEvent) { + if (!hasPrimaryShortcutModifier(event) || event.altKey || event.repeat) { + return; + } + + // A focused surface may claim the shortcut first — e.g. the composer + // consumes ⌘K to open the link editor when text is selected. Its + // element-level handler runs before this window-level bubble listener + // and calls `preventDefault()`; respect that instead of also opening + // the global dialog. + if (event.defaultPrevented) { + return; + } + + const key = event.key.toLowerCase(); + if (key === "k" && !event.shiftKey) { + event.preventDefault(); + onOpenSearch(); + return; + } + + if (key === "k" && event.shiftKey) { + event.preventDefault(); + onOpenNewDm(); + return; + } + + if (key === "n" && event.shiftKey) { + event.preventDefault(); + onOpenCreateChannel(); + return; + } + + if (key === "o" && event.shiftKey) { + event.preventDefault(); + onOpenBrowseChannels(); + return; + } + + if (key === "a" && event.shiftKey) { + event.preventDefault(); + onGoHome(); + return; + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + enabled, + onGoHome, + onOpenBrowseChannels, + onOpenCreateChannel, + onOpenNewDm, + onOpenSearch, + ]); +} diff --git a/desktop/src/features/sidebar/lib/sidebarChannelOrder.test.mjs b/desktop/src/features/sidebar/lib/sidebarChannelOrder.test.mjs new file mode 100644 index 0000000000..5b4b0bea72 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarChannelOrder.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + adjacentSidebarChannelId, + buildSidebarChannelGroups, + flattenSidebarChannelGroups, +} from "./sidebarChannelOrder.ts"; + +function makeChannel(id, name, lastMessageAt = null) { + return { id, name, channelType: "stream", lastMessageAt }; +} + +function makeSection(id, name, order) { + return { id, name, order }; +} + +const defaultSort = () => "alpha"; + +function orderedIds({ + streamChannels, + starredChannelIds = undefined, + sections = [], + assignments = {}, + sortModeFor = defaultSort, +}) { + const groups = buildSidebarChannelGroups({ + streamChannels, + starredChannelIds, + sections, + assignments, + sortModeFor, + }); + return flattenSidebarChannelGroups(groups, sections).map((c) => c.id); +} + +test("flattens starred, then sections in order, then unassigned", () => { + const streamChannels = [ + makeChannel("uz", "zeta"), + makeChannel("s1", "starred-one"), + makeChannel("b1", "bravo"), + makeChannel("a1", "alpha"), + makeChannel("ua", "apple"), + ]; + const sections = [makeSection("sec-a", "A", 0), makeSection("sec-b", "B", 1)]; + const ids = orderedIds({ + streamChannels, + starredChannelIds: new Set(["s1"]), + sections, + assignments: { a1: "sec-a", b1: "sec-b" }, + }); + assert.deepEqual(ids, ["s1", "a1", "b1", "ua", "uz"]); +}); + +test("starred channels are excluded from their assigned section", () => { + const streamChannels = [ + makeChannel("a1", "alpha"), + makeChannel("b1", "bravo"), + ]; + const sections = [makeSection("sec-a", "A", 0)]; + const groups = buildSidebarChannelGroups({ + streamChannels, + starredChannelIds: new Set(["a1"]), + sections, + assignments: { a1: "sec-a", b1: "sec-a" }, + sortModeFor: defaultSort, + }); + assert.deepEqual( + groups.starred.map((c) => c.id), + ["a1"], + ); + assert.deepEqual( + groups.bySection["sec-a"].map((c) => c.id), + ["b1"], + ); +}); + +test("channels assigned to a deleted section fall back to unassigned", () => { + const ids = orderedIds({ + streamChannels: [makeChannel("a1", "alpha"), makeChannel("b1", "bravo")], + sections: [], + assignments: { a1: "gone-section" }, + }); + assert.deepEqual(ids, ["a1", "b1"]); +}); + +test("each grouping honors its own sort preference", () => { + const streamChannels = [ + makeChannel("old", "old", "2024-01-01T00:00:00Z"), + makeChannel("new", "new", "2025-01-01T00:00:00Z"), + makeChannel("a1", "alpha"), + makeChannel("z1", "zulu"), + ]; + const sections = [makeSection("sec-a", "A", 0)]; + const ids = orderedIds({ + streamChannels, + sections, + assignments: { old: "sec-a", new: "sec-a" }, + // Section sorts by recency (newest first), unassigned stays alphabetical. + sortModeFor: (group) => (group === "section:sec-a" ? "recent" : "alpha"), + }); + assert.deepEqual(ids, ["new", "old", "a1", "z1"]); +}); + +test("sections flatten in the provided display order", () => { + const streamChannels = [ + makeChannel("a1", "alpha"), + makeChannel("b1", "bravo"), + ]; + const sections = [makeSection("sec-b", "B", 0), makeSection("sec-a", "A", 1)]; + const ids = orderedIds({ + streamChannels, + sections, + assignments: { a1: "sec-a", b1: "sec-b" }, + }); + assert.deepEqual(ids, ["b1", "a1"]); +}); + +test("adjacent: steps down and up through the ordered list", () => { + const channels = [ + makeChannel("a", "a"), + makeChannel("b", "b"), + makeChannel("c", "c"), + ]; + assert.equal(adjacentSidebarChannelId(channels, "a", 1), "b"); + assert.equal(adjacentSidebarChannelId(channels, "b", 1), "c"); + assert.equal(adjacentSidebarChannelId(channels, "c", -1), "b"); +}); + +test("adjacent: skips muted channels in both directions", () => { + const channels = [ + makeChannel("a", "a"), + makeChannel("b", "b"), + makeChannel("c", "c"), + makeChannel("d", "d"), + ]; + const muted = new Set(["b", "c"]); + assert.equal(adjacentSidebarChannelId(channels, "a", 1, muted), "d"); + assert.equal(adjacentSidebarChannelId(channels, "d", -1, muted), "a"); +}); + +test("adjacent: stops at both ends without wrapping", () => { + const channels = [makeChannel("a", "a"), makeChannel("b", "b")]; + assert.equal(adjacentSidebarChannelId(channels, "a", -1), null); + assert.equal(adjacentSidebarChannelId(channels, "b", 1), null); +}); + +test("adjacent: returns null when only muted channels remain toward the end", () => { + const channels = [makeChannel("a", "a"), makeChannel("b", "b")]; + assert.equal( + adjacentSidebarChannelId(channels, "a", 1, new Set(["b"])), + null, + ); +}); + +test("adjacent: no-op when no channel is selected or it is not in the list", () => { + const channels = [makeChannel("a", "a")]; + assert.equal(adjacentSidebarChannelId(channels, null, 1), null); + assert.equal(adjacentSidebarChannelId(channels, "missing", 1), null); + assert.equal(adjacentSidebarChannelId([], "a", 1), null); +}); + +test("adjacent: navigates from a muted active channel", () => { + const channels = [makeChannel("a", "a"), makeChannel("b", "b")]; + assert.equal(adjacentSidebarChannelId(channels, "a", 1, new Set(["a"])), "b"); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarChannelOrder.ts b/desktop/src/features/sidebar/lib/sidebarChannelOrder.ts new file mode 100644 index 0000000000..51157ae0e5 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarChannelOrder.ts @@ -0,0 +1,132 @@ +import type { Channel } from "@/shared/api/types"; +import type { ChannelSection } from "./channelSectionsStorage"; +import { + sectionSortGroupKey, + sortChannelsForSidebar, + type ChannelSortGroupKey, + type ChannelSortMode, +} from "./channelSortPreference"; + +/** + * The sidebar's stream-channel groupings in display form: starred channels, + * channels bucketed per custom section, and channels assigned to no section. + * Each grouping is already sorted by its own saved sort preference. + */ +export type SidebarChannelGroups = { + starred: Channel[]; + bySection: Record; + unassigned: Channel[]; +}; + +/** + * Buckets stream channels into the groups the sidebar displays — starred, + * each live custom section, and unassigned — applying each grouping's own + * sort preference. Starred channels are excluded from section/unassigned + * buckets (starring moves a channel, it doesn't duplicate it), and channels + * assigned to a section that no longer exists fall back to unassigned. + * + * This is the single source of truth for sidebar channel ordering: the + * sidebar renders from it, and keyboard channel navigation flattens it via + * {@link flattenSidebarChannelGroups}, so the two can't drift. + */ +export function buildSidebarChannelGroups({ + streamChannels, + starredChannelIds, + sections, + assignments, + sortModeFor, +}: { + streamChannels: Channel[]; + starredChannelIds: ReadonlySet | undefined; + sections: ChannelSection[]; + assignments: Record; + sortModeFor: (group: ChannelSortGroupKey) => ChannelSortMode; +}): SidebarChannelGroups { + const bySection: Record = {}; + const unassigned: Channel[] = []; + const liveSectionIds = new Set(sections.map((s) => s.id)); + + for (const channel of streamChannels) { + if (starredChannelIds?.has(channel.id)) continue; + const sectionId = assignments[channel.id]; + if (sectionId && liveSectionIds.has(sectionId)) { + if (!bySection[sectionId]) { + bySection[sectionId] = []; + } + bySection[sectionId].push(channel); + } else { + unassigned.push(channel); + } + } + // Apply each grouping's own sort preference; section membership itself + // is untouched. + for (const sectionId of Object.keys(bySection)) { + bySection[sectionId] = sortChannelsForSidebar( + bySection[sectionId], + sortModeFor(sectionSortGroupKey(sectionId)), + ); + } + + const starred = starredChannelIds?.size + ? sortChannelsForSidebar( + streamChannels.filter((channel) => starredChannelIds.has(channel.id)), + sortModeFor("starred"), + ) + : []; + + return { + starred, + bySection, + unassigned: sortChannelsForSidebar(unassigned, sortModeFor("channels")), + }; +} + +/** + * Flattens {@link buildSidebarChannelGroups} output into the top-to-bottom + * order the sidebar displays: starred, then each custom section in section + * order, then unassigned channels. + * + * `sections` must already be in display order (sorted by `order`), matching + * what `useChannelSections` returns. + */ +export function flattenSidebarChannelGroups( + groups: SidebarChannelGroups, + sections: ChannelSection[], +): Channel[] { + return [ + ...groups.starred, + ...sections.flatMap((section) => groups.bySection[section.id] ?? []), + ...groups.unassigned, + ]; +} + +/** + * Returns the id of the channel `direction` steps away from the active one + * in the flattened sidebar order, skipping muted channels, or null when + * there is nowhere to go: no active selection, active channel not in the + * list (e.g. home feed or a DM), or already at the list's end (no + * wraparound). + */ +export function adjacentSidebarChannelId( + orderedChannels: Channel[], + activeChannelId: string | null, + direction: 1 | -1, + mutedChannelIds?: ReadonlySet, +): string | null { + if (!activeChannelId) return null; + const activeIndex = orderedChannels.findIndex( + (channel) => channel.id === activeChannelId, + ); + if (activeIndex === -1) return null; + + for ( + let index = activeIndex + direction; + index >= 0 && index < orderedChannels.length; + index += direction + ) { + const candidate = orderedChannels[index]; + if (mutedChannelIds?.has(candidate.id)) continue; + return candidate.id; + } + return null; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 55f467f215..4c44e141f2 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -20,6 +20,7 @@ import { sortChannelsForSidebar, } from "@/features/sidebar/lib/channelSortPreference"; import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference"; +import { buildSidebarChannelGroups } from "@/features/sidebar/lib/sidebarChannelOrder"; import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; import { isSidebarBackgroundTarget } from "@/features/sidebar/lib/sidebarBackgroundTarget"; import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; @@ -388,50 +389,26 @@ export function AppSidebar({ [channels], ); - const sectionBuckets = React.useMemo(() => { - const bySection: Record = {}; - const unassigned: Channel[] = []; - const sectionIds = new Set(channelSections.map((s) => s.id)); - - for (const channel of streamChannels) { - if (starredChannelIds?.has(channel.id)) continue; - const sectionId = channelAssignments[channel.id]; - if (sectionId && sectionIds.has(sectionId)) { - if (!bySection[sectionId]) { - bySection[sectionId] = []; - } - bySection[sectionId].push(channel); - } else { - unassigned.push(channel); - } - } - // Apply each grouping's own sort preference; section membership itself - // is untouched. - for (const sectionId of Object.keys(bySection)) { - bySection[sectionId] = sortChannelsForSidebar( - bySection[sectionId], - sortModeFor(sectionSortGroupKey(sectionId)), - ); - } - return { - bySection, - unassigned: sortChannelsForSidebar(unassigned, sortModeFor("channels")), - }; - }, [ - streamChannels, - channelSections, - channelAssignments, - starredChannelIds, - sortModeFor, - ]); - - const starredChannels = React.useMemo(() => { - if (!starredChannelIds || starredChannelIds.size === 0) return []; - return sortChannelsForSidebar( - streamChannels.filter((channel) => starredChannelIds.has(channel.id)), - sortModeFor("starred"), - ); - }, [streamChannels, starredChannelIds, sortModeFor]); + // Shared with keyboard channel navigation (useChannelNavigationShortcuts) + // so the shortcut steps through exactly the order the sidebar displays. + const sectionBuckets = React.useMemo( + () => + buildSidebarChannelGroups({ + streamChannels, + starredChannelIds, + sections: channelSections, + assignments: channelAssignments, + sortModeFor, + }), + [ + streamChannels, + channelSections, + channelAssignments, + starredChannelIds, + sortModeFor, + ], + ); + const starredChannels = sectionBuckets.starred; const handleCreateSectionForChannel = React.useCallback( (channelId: string) => { diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index 422c45c5f3..6a1885b71f 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -73,6 +73,22 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ keysWindows: "Alt+→", category: "Navigation", }, + { + id: "next-channel", + label: "Next channel", + description: "Go to the next channel in the sidebar", + keys: "⌥↓", + keysWindows: "Ctrl+Alt+↓", + category: "Navigation", + }, + { + id: "previous-channel", + label: "Previous channel", + description: "Go to the previous channel in the sidebar", + keys: "⌥↑", + keysWindows: "Ctrl+Alt+↑", + category: "Navigation", + }, { id: "find-in-channel", label: "Find in channel", diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index f7a96cd568..2bb5425e6f 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -32,6 +32,48 @@ async function createWorkflow( await expect(dialog).not.toBeVisible(); } +// ⌥↓/⌥↑ on macOS; Ctrl+Alt+↓/↑ on Windows/Linux (plain Alt+arrows are +// bound to back/forward there). Playwright drives the platform the runner +// is on, so pick the combo to match. +const NEXT_CHANNEL_SHORTCUT = + process.platform === "darwin" ? "Alt+ArrowDown" : "Control+Alt+ArrowDown"; +const PREVIOUS_CHANNEL_SHORTCUT = + process.platform === "darwin" ? "Alt+ArrowUp" : "Control+Alt+ArrowUp"; + +test("keyboard shortcut steps through sidebar channels without wrapping", async ({ + page, +}) => { + await page.goto("/"); + + // Mock member stream channels in default alphabetical sidebar order: + // agents, all-replies, deep-history, engineering, general, random, + // secret-projects. Forums (watercooler, announcements) and DMs are not + // part of the cycle. + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.keyboard.press(NEXT_CHANNEL_SHORTCUT); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + await page.keyboard.press(NEXT_CHANNEL_SHORTCUT); + await expect(page.getByTestId("chat-title")).toHaveText("secret-projects"); + + await page.keyboard.press(PREVIOUS_CHANNEL_SHORTCUT); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + await page.keyboard.press(PREVIOUS_CHANNEL_SHORTCUT); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.keyboard.press(PREVIOUS_CHANNEL_SHORTCUT); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + + // Top of the list: stays put, no wraparound. + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + await page.keyboard.press(PREVIOUS_CHANNEL_SHORTCUT); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); +}); + test("global back and forward move across channel routes", async ({ page }) => { await page.goto("/");