Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 21 additions & 64 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
<PreventSleepProvider>
Expand Down
128 changes: 128 additions & 0 deletions desktop/src/app/useChannelNavigationShortcuts.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
onSelectChannel: (channelId: string) => void;
relayUrl: string | undefined;
selectedChannelId: string | null;
selectedView: string;
starredChannelIds: ReadonlySet<string> | 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,
]);
}
89 changes: 89 additions & 0 deletions desktop/src/app/useGlobalActionShortcuts.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
}
Loading