feat: allow clicking keyboard hints throughout (#266)

* allow clicking keyboard hints

* Update js/desktop/src/components/key-hint.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* nits

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit was merged in pull request #266.
This commit is contained in:
Arjun Patel
2026-06-11 10:48:59 -07:00
committed by GitHub
co-authored by coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
parent 9fd7e611f3
commit 95f2362947
15 changed files with 283 additions and 208 deletions
@@ -12,6 +12,7 @@ import {
import { useDownloadUrl } from '@/hooks/use-download-url';
import { useObjectUrl } from '@/hooks/use-object-url';
import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { platform } from '@/lib/platform';
@@ -294,37 +295,46 @@ export function AttachmentLightbox({
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-4 text-xs text-white/50">
{hasMultiple && (
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>
<KeyHint
keys="←"
onClick={() => goTo(-1)}
title="Previous (or press ←)"
aria-label="Previous attachment"
/>
<KeyHint
keys="→"
onClick={() => goTo(1)}
title="Next (or press →)"
aria-label="Next attachment"
/>
navigate
</span>
)}
{canDownload && (
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
D
</kbd>
<KeyHint
keys="D"
onClick={handleDownload}
title="Download (or press D)"
>
download
</span>
</KeyHint>
)}
{onRemove && (
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>
<KeyHint
keys="⌫"
onClick={handleRemove}
title="Remove (or press Backspace)"
>
remove
</span>
</KeyHint>
)}
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>
<KeyHint
keys="Esc"
onClick={() => onOpenChange(null)}
title="Close (or press Esc)"
>
close
</span>
</KeyHint>
</div>
</>
)}
@@ -17,6 +17,7 @@ import { particlePath, parseParticlePath } from '@/lib/particle-path';
import type { ParticlePath } from '@/lib/particle-path';
import { RecordingOverlay } from '@/features/compose/recording-overlay';
import { ScreenSourcePicker } from '@/components/screen-source-picker';
import { KeyHint } from '@/components/key-hint';
import { TextComposeStep } from '@/features/compose/text-compose-step';
import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
import { apiClient } from '@/api/client';
@@ -726,28 +727,20 @@ export function ComposeOverlay({
</div>
</div>
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<button
type="button"
<KeyHint
keys="S"
onClick={handleStopIntent}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Stop screen recording (or press S)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
S
</kbd>{' '}
stop
</button>
<button
type="button"
</KeyHint>
<KeyHint
keys="Q"
onClick={handleCancelIntent}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Cancel screen recording (or press Q)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{' '}
cancel
</button>
</KeyHint>
</div>
</div>
)}
@@ -5,6 +5,7 @@ import { metaKey } from '@/lib/platform';
import { useAuthStore } from '@/stores/auth-store';
import { generateRandomName } from '@/lib/random-name';
import { Input } from '@/components/ui/input';
import { KeyHint } from '@/components/key-hint';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -158,18 +159,16 @@ export function ConfigureStreamStep({
{/* Keyboard hints */}
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+Enter
</kbd>{' '}
</KeyHint>
<KeyHint
keys={`${metaKey}+Enter`}
onClick={handleSubmit}
title={`Create stream (or press ${metaKey}+Enter)`}
>
create
</span>
</KeyHint>
</div>
</div>
);
@@ -8,6 +8,7 @@ import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
interface RecordingOverlayProps {
@@ -205,33 +206,22 @@ export function RecordingOverlay({
{/* Bottom center: keyboard hints */}
{isRecording && !isLoading && (
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<button
type="button"
<KeyHint
keys="`"
prefix="Release"
onClick={() => requestIntent('stop')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Finish recording (or release `)"
>
Release{' '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
`
</kbd>{' '}
to review
</button>
<button
type="button"
</KeyHint>
<KeyHint
keys={['Esc', 'Q']}
separator="or"
onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard recording (or press Esc / Q)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>
{' or '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{' '}
to cancel
</button>
</KeyHint>
</div>
)}
@@ -247,32 +237,21 @@ export function RecordingOverlay({
</div>
)}
<div className="flex items-center gap-4 text-sm text-white/50">
<button
type="button"
<KeyHint
keys="Enter"
onClick={() => requestIntent('send')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Send (or press Enter)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{' '}
next
</button>
<button
type="button"
</KeyHint>
<KeyHint
keys={['Esc', 'Q']}
separator="or"
onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard (or press Esc / Q)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>
{' or '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{' '}
to cancel
</button>
</KeyHint>
<span>
<Button
variant="ghost"
+17 -15
View File
@@ -6,6 +6,7 @@ import { useAllLinkMetadata } from '@/hooks/use-link-metadata';
import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { MarkdownEditor } from '@/features/compose/markdown-editor';
export interface TextEditorAttachmentProps {
@@ -111,25 +112,26 @@ export function TextEditor({
const keyboardHints = (
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+Enter
</kbd>{' '}
</KeyHint>
<KeyHint
keys={`${metaKey}+Enter`}
onClick={() => {
if (textContent.trim()) onSubmit();
}}
title={`Submit (or press ${metaKey}+Enter)`}
>
{submitHint}
</span>
</KeyHint>
{immersive && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+M
</kbd>{' '}
<KeyHint
keys={`${metaKey}+M`}
onClick={() => setForceCardMode(true)}
title={`Switch to markdown editor (or press ${metaKey}+M)`}
>
markdown
</span>
</KeyHint>
)}
{attachmentProps && (
<span>
+9 -29
View File
@@ -4,6 +4,7 @@ import { CircleDot, CircleCheckBig } from 'lucide-react';
import { particlePath } from '@/lib/particle-path';
import { ParticleListView } from '@/features/particles/particle-list-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { ComposeOverlay } from './compose/compose-overlay';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { ComposeQuotaIndicator } from './compose/compose-quota-indicator';
@@ -103,44 +104,23 @@ function NetworkRootControls() {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>{' '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{' '}
navigate
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
19
</kbd>{' '}
jump
</span>
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
<KeyHint keys="19">jump</KeyHint>
<VideoAudioToggle />
<button
type="button"
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Start recording (or hold `)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{' '}
to start
</button>
<button
type="button"
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Compose text (or press T)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{' '}
text
</button>
</KeyHint>
</div>
);
}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { updateParticleProperties } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import type { Particle } from '@/api/types';
@@ -63,12 +64,14 @@ export function RenameStreamOverlay({
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
<span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint
keys="Esc"
onClick={onClose}
title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close
</span>
</KeyHint>
</div>
<Input
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { KeyHint } from '@/components/key-hint';
import {
buildCustomVisibility,
buildNetworkVisibility,
@@ -104,12 +105,14 @@ export function StreamMembersOverlay({
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Members</h2>
<span className="text-xs text-white/30">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint
keys="Esc"
onClick={onClose}
title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close
</span>
</KeyHint>
</div>
{/* Visibility */}
@@ -29,6 +29,7 @@ import { TextParticleView } from '@/features/particles/text-particle-view';
import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
import {
KeybindingsOverlay,
@@ -259,12 +260,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const { fastPlayback } = usePlaybackKeys({ mediaRef });
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
useStreamNavigationKeys({
next,
prev,
currentIndex,
childrenLength: children.length,
mediaRef,
onExit: handleExitNavigate,
});
const handleOpenHuddle = useCallback(() => {
@@ -325,10 +331,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
// Always show controls when compose is active or exit countdown is visible
const controlsVisible = showControls || composeActive || status === 'ended';
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
// Reset progress when the particle changes.
@@ -362,6 +364,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
<StreamViewControls
showEscape
onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/>
<ComposeOverlay
networkId={networkId}
@@ -505,6 +509,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
onlineHumanIds={onlineHumanIds}
exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/>
<KeybindingsOverlay
@@ -527,6 +533,8 @@ function BottomBar({
onlineHumanIds,
exitRemainingMs,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
visible: boolean;
total: number;
@@ -540,6 +548,8 @@ function BottomBar({
onlineHumanIds: Set<string>;
exitRemainingMs: number | null;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
return (
<div
@@ -580,6 +590,8 @@ function BottomBar({
<StreamViewControls
showEscape
onOpenKeybindings={onOpenKeybindings}
onOpenHuddle={onOpenHuddle}
onExit={onExit}
/>
</div>
</div>
@@ -590,58 +602,54 @@ function BottomBar({
function StreamViewControls({
showEscape,
onOpenKeybindings,
onOpenHuddle,
onExit,
}: {
showEscape?: boolean;
onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
<KeyHint
keys="Esc"
onClick={onExit}
title="Back to network (or press Esc)"
>
back
</span>
</KeyHint>
)}
<VideoAudioToggle />
<button
type="button"
<KeyHint
keys="Hold `"
onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with a recording (or hold `)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{' '}
to reply
</button>
<button
type="button"
</KeyHint>
<KeyHint
keys="T"
onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with text (or press T)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{' '}
text
</button>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
</kbd>{' '}
huddle
</span>
<kbd
role="button"
onClick={onOpenKeybindings}
className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80"
title="Show all shortcuts"
</KeyHint>
<KeyHint
keys="H"
onClick={onOpenHuddle}
title="Start a huddle (or press H)"
>
?
</kbd>
huddle
</KeyHint>
<KeyHint
keys="?"
onClick={onOpenKeybindings}
title="Show all shortcuts"
aria-label="Show keyboard shortcuts"
/>
</div>
);
}