mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(desktop): keyboard shortcuts registry + settings panel (#387)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,10 @@ import {
|
||||
} from "@/features/notifications/lib/desktop";
|
||||
import { usePresenceSession } from "@/features/presence/hooks";
|
||||
import { useProfileQuery } from "@/features/profile/hooks";
|
||||
import type { SettingsSection } from "@/features/settings/ui/SettingsPanels";
|
||||
import {
|
||||
DEFAULT_SETTINGS_SECTION,
|
||||
type SettingsSection,
|
||||
} from "@/features/settings/ui/SettingsPanels";
|
||||
import { HuddleBar, HuddleProvider } from "@/features/huddle";
|
||||
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
@@ -45,7 +48,6 @@ import {
|
||||
} from "@/shared/ui/sidebar";
|
||||
|
||||
type AppView = "home" | "channel" | "agents" | "workflows" | "pulse";
|
||||
const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
|
||||
|
||||
const LazySettingsScreen = React.lazy(async () => {
|
||||
const module = await import("@/features/settings/ui/SettingsScreen");
|
||||
@@ -120,6 +122,7 @@ export function AppShell() {
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const [browseDialogType, setBrowseDialogType] =
|
||||
React.useState<BrowseDialogType>(null);
|
||||
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { goAgents, goChannel, goHome, goPulse, goWorkflows, openSearchHit } =
|
||||
@@ -324,6 +327,10 @@ export function AppShell() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenNewDm = React.useCallback(() => {
|
||||
setIsNewDmOpen(true);
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (settingsOpen) {
|
||||
return;
|
||||
@@ -341,9 +348,22 @@ export function AppShell() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "k" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleOpenNewDm();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "o" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleOpenBrowseChannels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "a" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void goHome();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +371,13 @@ export function AppShell() {
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleOpenBrowseChannels, handleOpenSearch, settingsOpen]);
|
||||
}, [
|
||||
handleOpenBrowseChannels,
|
||||
handleOpenNewDm,
|
||||
handleOpenSearch,
|
||||
goHome,
|
||||
settingsOpen,
|
||||
]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -432,7 +458,9 @@ export function AppShell() {
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isOpeningDm={openDmMutation.isPending}
|
||||
isNewDmOpen={isNewDmOpen}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
onNewDmOpenChange={setIsNewDmOpen}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
onCreateChannel={async ({
|
||||
description,
|
||||
|
||||
75
desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx
Normal file
75
desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
|
||||
import {
|
||||
getShortcutsByCategory,
|
||||
getPlatformKeys,
|
||||
type KeyboardShortcut,
|
||||
} from "@/shared/lib/keyboard-shortcuts";
|
||||
|
||||
function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) {
|
||||
const keys = getPlatformKeys(shortcut);
|
||||
// Split on "+" but keep "+" as a standalone key (e.g. for zoom-in "⌘+")
|
||||
const parts = keys
|
||||
.split(/(?<!\+)\+(?!\s*$)/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{parts.map((part) => (
|
||||
<kbd
|
||||
className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-border/70 bg-muted/60 px-1.5 font-mono text-xs text-muted-foreground"
|
||||
key={part}
|
||||
>
|
||||
{part}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsCard() {
|
||||
const categories = getShortcutsByCategory();
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-shortcuts">
|
||||
<div className="mb-3 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold tracking-tight">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
All available keyboard shortcuts. Shortcuts are read-only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[...categories.entries()].map(([category, shortcuts]) => (
|
||||
<div key={category}>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="rounded-lg border border-border/70 bg-background/70">
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
<div
|
||||
className={`flex items-center justify-between px-3 py-2 text-sm ${i !== shortcuts.length - 1 ? "border-b border-border/50" : ""}`}
|
||||
key={shortcut.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-foreground">{shortcut.label}</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
</div>
|
||||
<KeyCombo shortcut={shortcut} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useMemo, useRef } from "react";
|
||||
import {
|
||||
BellRing,
|
||||
Check,
|
||||
Keyboard,
|
||||
KeyRound,
|
||||
MonitorCog,
|
||||
Moon,
|
||||
@@ -21,6 +22,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
|
||||
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
|
||||
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
|
||||
import { MobilePairingCard } from "./MobilePairingCard";
|
||||
import { NotificationSettingsCard } from "./NotificationSettingsCard";
|
||||
import { ProfileSettingsCard } from "./ProfileSettingsCard";
|
||||
@@ -29,6 +31,7 @@ export type SettingsSection =
|
||||
| "profile"
|
||||
| "notifications"
|
||||
| "appearance"
|
||||
| "shortcuts"
|
||||
| "tokens"
|
||||
| "mobile"
|
||||
| "doctor";
|
||||
@@ -70,6 +73,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
label: "Appearance",
|
||||
icon: MonitorCog,
|
||||
},
|
||||
{
|
||||
value: "shortcuts",
|
||||
label: "Shortcuts",
|
||||
icon: Keyboard,
|
||||
},
|
||||
{
|
||||
value: "tokens",
|
||||
label: "Tokens",
|
||||
@@ -234,6 +242,8 @@ export function renderSettingsSection(
|
||||
);
|
||||
case "appearance":
|
||||
return <ThemeSettingsCard />;
|
||||
case "shortcuts":
|
||||
return <KeyboardShortcutsCard />;
|
||||
case "tokens":
|
||||
return <TokenSettingsCard currentPubkey={props.currentPubkey} />;
|
||||
case "mobile":
|
||||
|
||||
@@ -92,6 +92,8 @@ type AppSidebarProps = {
|
||||
onSelectSettings: () => void;
|
||||
onSetPresenceStatus?: (status: "online" | "away" | "offline") => void;
|
||||
isPresencePending?: boolean;
|
||||
isNewDmOpen?: boolean;
|
||||
onNewDmOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -231,9 +233,13 @@ export function AppSidebar({
|
||||
onSelectSettings,
|
||||
onSetPresenceStatus,
|
||||
isPresencePending,
|
||||
isNewDmOpen: isNewDmOpenProp,
|
||||
onNewDmOpenChange,
|
||||
}: AppSidebarProps) {
|
||||
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
|
||||
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
|
||||
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
|
||||
const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
|
||||
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
|
||||
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
|
||||
const [createDialogKind, setCreateDialogKind] =
|
||||
React.useState<CreateChannelKind | null>(null);
|
||||
|
||||
221
desktop/src/shared/lib/keyboard-shortcuts.ts
Normal file
221
desktop/src/shared/lib/keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
export type ShortcutCategory =
|
||||
| "Navigation"
|
||||
| "Messages"
|
||||
| "Formatting"
|
||||
| "Zoom";
|
||||
|
||||
export type KeyboardShortcut = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
keys: string;
|
||||
keysWindows: string;
|
||||
category: ShortcutCategory;
|
||||
};
|
||||
|
||||
export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
|
||||
// Navigation
|
||||
{
|
||||
id: "quick-search",
|
||||
label: "Quick search",
|
||||
description: "Open the search dialog",
|
||||
keys: "⌘K",
|
||||
keysWindows: "Ctrl+K",
|
||||
category: "Navigation",
|
||||
},
|
||||
{
|
||||
id: "browse-channels",
|
||||
label: "Browse channels",
|
||||
description: "Open the channel browser",
|
||||
keys: "⇧⌘O",
|
||||
keysWindows: "Shift+Ctrl+O",
|
||||
category: "Navigation",
|
||||
},
|
||||
{
|
||||
id: "browse-dms",
|
||||
label: "New direct message",
|
||||
description: "Open the new DM dialog",
|
||||
keys: "⇧⌘K",
|
||||
keysWindows: "Shift+Ctrl+K",
|
||||
category: "Navigation",
|
||||
},
|
||||
{
|
||||
id: "open-settings",
|
||||
label: "Settings",
|
||||
description: "Open or close settings",
|
||||
keys: "⌘,",
|
||||
keysWindows: "Ctrl+,",
|
||||
category: "Navigation",
|
||||
},
|
||||
{
|
||||
id: "go-back",
|
||||
label: "Go back",
|
||||
description: "Navigate to the previous page",
|
||||
keys: "⌘[",
|
||||
keysWindows: "Alt+←",
|
||||
category: "Navigation",
|
||||
},
|
||||
{
|
||||
id: "go-forward",
|
||||
label: "Go forward",
|
||||
description: "Navigate to the next page",
|
||||
keys: "⌘]",
|
||||
keysWindows: "Alt+→",
|
||||
category: "Navigation",
|
||||
},
|
||||
{
|
||||
id: "go-home",
|
||||
label: "Home",
|
||||
description: "Navigate to the home feed",
|
||||
keys: "⇧⌘A",
|
||||
keysWindows: "Shift+Ctrl+A",
|
||||
category: "Navigation",
|
||||
},
|
||||
{
|
||||
id: "toggle-sidebar",
|
||||
label: "Toggle sidebar",
|
||||
description: "Show or hide the sidebar",
|
||||
keys: "⌘S",
|
||||
keysWindows: "Ctrl+S",
|
||||
category: "Navigation",
|
||||
},
|
||||
|
||||
// Zoom
|
||||
{
|
||||
id: "zoom-in",
|
||||
label: "Zoom in",
|
||||
description: "Increase the zoom level",
|
||||
keys: "⌘+",
|
||||
keysWindows: "Ctrl+=",
|
||||
category: "Zoom",
|
||||
},
|
||||
{
|
||||
id: "zoom-out",
|
||||
label: "Zoom out",
|
||||
description: "Decrease the zoom level",
|
||||
keys: "⌘-",
|
||||
keysWindows: "Ctrl+-",
|
||||
category: "Zoom",
|
||||
},
|
||||
{
|
||||
id: "zoom-reset",
|
||||
label: "Reset zoom",
|
||||
description: "Reset zoom to default level",
|
||||
keys: "⌘0",
|
||||
keysWindows: "Ctrl+0",
|
||||
category: "Zoom",
|
||||
},
|
||||
|
||||
// Messages
|
||||
{
|
||||
id: "send-message",
|
||||
label: "Send message",
|
||||
description: "Send the current message",
|
||||
keys: "Enter",
|
||||
keysWindows: "Enter",
|
||||
category: "Messages",
|
||||
},
|
||||
{
|
||||
id: "new-line",
|
||||
label: "New line",
|
||||
description: "Insert a line break in the composer",
|
||||
keys: "Shift+Enter",
|
||||
keysWindows: "Shift+Enter",
|
||||
category: "Messages",
|
||||
},
|
||||
{
|
||||
id: "publish-note",
|
||||
label: "Publish note",
|
||||
description: "Publish a Pulse note",
|
||||
keys: "⌘Enter",
|
||||
keysWindows: "Ctrl+Enter",
|
||||
category: "Messages",
|
||||
},
|
||||
{
|
||||
id: "close-dialog",
|
||||
label: "Close dialog",
|
||||
description: "Close the current dialog or settings",
|
||||
keys: "Escape",
|
||||
keysWindows: "Escape",
|
||||
category: "Messages",
|
||||
},
|
||||
{
|
||||
id: "push-to-talk",
|
||||
label: "Push to talk",
|
||||
description: "Hold to unmute in a huddle",
|
||||
keys: "Ctrl+Space",
|
||||
keysWindows: "Ctrl+Space",
|
||||
category: "Messages",
|
||||
},
|
||||
|
||||
// Formatting
|
||||
{
|
||||
id: "format-bold",
|
||||
label: "Bold",
|
||||
description: "Toggle bold formatting",
|
||||
keys: "⌘B",
|
||||
keysWindows: "Ctrl+B",
|
||||
category: "Formatting",
|
||||
},
|
||||
{
|
||||
id: "format-italic",
|
||||
label: "Italic",
|
||||
description: "Toggle italic formatting",
|
||||
keys: "⌘I",
|
||||
keysWindows: "Ctrl+I",
|
||||
category: "Formatting",
|
||||
},
|
||||
{
|
||||
id: "format-strikethrough",
|
||||
label: "Strikethrough",
|
||||
description: "Toggle strikethrough formatting",
|
||||
keys: "⌘⇧X",
|
||||
keysWindows: "Ctrl+Shift+X",
|
||||
category: "Formatting",
|
||||
},
|
||||
{
|
||||
id: "format-code",
|
||||
label: "Inline code",
|
||||
description: "Toggle inline code formatting",
|
||||
keys: "⌘E",
|
||||
keysWindows: "Ctrl+E",
|
||||
category: "Formatting",
|
||||
},
|
||||
{
|
||||
id: "format-link",
|
||||
label: "Insert link",
|
||||
description: "Insert or edit a link in the composer",
|
||||
keys: "⌘K",
|
||||
keysWindows: "Ctrl+K",
|
||||
category: "Formatting",
|
||||
},
|
||||
];
|
||||
|
||||
const CATEGORY_ORDER: ShortcutCategory[] = [
|
||||
"Navigation",
|
||||
"Messages",
|
||||
"Formatting",
|
||||
"Zoom",
|
||||
];
|
||||
|
||||
export function getShortcutsByCategory(): Map<
|
||||
ShortcutCategory,
|
||||
KeyboardShortcut[]
|
||||
> {
|
||||
const map = new Map<ShortcutCategory, KeyboardShortcut[]>();
|
||||
for (const cat of CATEGORY_ORDER) {
|
||||
map.set(
|
||||
cat,
|
||||
KEYBOARD_SHORTCUTS.filter((s) => s.category === cat),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function isMac(): boolean {
|
||||
return /mac|iphone|ipad|ipod/i.test(navigator.platform);
|
||||
}
|
||||
|
||||
export function getPlatformKeys(shortcut: KeyboardShortcut): string {
|
||||
return isMac() ? shortcut.keys : shortcut.keysWindows;
|
||||
}
|
||||
Reference in New Issue
Block a user