3 Commits

Author SHA1 Message Date
Arjun Patel 1cf144bce3 nits 2026-06-11 10:45:11 -07:00
Arjun Patel dd850d28d1 Update js/desktop/src/components/key-hint.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-11 10:40:19 -07:00
Arjun Patel 023e332e01 allow clicking keyboard hints 2026-06-11 10:25:01 -07:00
15 changed files with 283 additions and 208 deletions
@@ -1,6 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
interface ConfirmDestructiveOverlayProps { interface ConfirmDestructiveOverlayProps {
title: string; title: string;
@@ -43,12 +44,14 @@ export function ConfirmDestructiveOverlay({
<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="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-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">{title}</h2> <h2 className="text-sm font-semibold text-white/70">{title}</h2>
<span className="text-xs text-white/30"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="Esc"
Esc onClick={onClose}
</kbd>{' '} title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close to close
</span> </KeyHint>
</div> </div>
<div className="text-sm text-white/60">{description}</div> <div className="text-sm text-white/60">{description}</div>
+79
View File
@@ -0,0 +1,79 @@
import { Fragment } from 'react';
import { Kbd } from '@/components/ui/kbd';
import { cn } from '@/lib/utils';
/** Dark-overlay chip restyle of the design-system Kbd, kept in one place. */
const chipClass =
'rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
interface KeyHintProps {
/** Key chip(s): "Esc" or ["Esc", "Q"]. */
keys: string | string[];
/** Rendered between chips, e.g. "or". Defaults to a plain space. */
separator?: React.ReactNode;
/** Text before the first chip, e.g. "Release". */
prefix?: React.ReactNode;
/** Trailing label, e.g. "cancel". May contain icons. */
children?: React.ReactNode;
/** When set, renders a <button> with hover affordance; otherwise a plain <span>. */
onClick?: () => void;
/** Tooltip explaining the action; pass alongside onClick. */
title?: string;
className?: string;
'aria-label'?: string;
}
/**
* A keyboard-shortcut hint: one or more key chips with optional surrounding
* text. Keyboard-first, but every hint with an `onClick` is also a real
* button so mouse users can trigger the same action by clicking it.
*/
export function KeyHint({
keys,
separator,
prefix,
children,
onClick,
title,
className,
...rest
}: KeyHintProps) {
const keyList = Array.isArray(keys) ? keys : [keys];
const content = (
<>
{prefix != null && <>{prefix} </>}
{keyList.map((k, i) => (
<Fragment key={`${k}-${i}`}>
{i > 0 && (separator != null ? <> {separator} </> : ' ')}
<Kbd className={chipClass}>{k}</Kbd>
</Fragment>
))}
{children != null && <> {children}</>}
</>
);
if (!onClick) {
return (
<span className={className} {...rest}>
{content}
</span>
);
}
return (
<button
type="button"
onClick={onClick}
// Keep focus where it is (e.g. the compose textarea); the click still fires.
onMouseDown={(e) => e.preventDefault()}
title={title}
className={cn(
'cursor-pointer rounded transition-colors hover:text-white/80',
className,
)}
{...rest}
>
{content}
</button>
);
}
@@ -1,6 +1,7 @@
import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { KeyHint } from '@/components/key-hint';
export interface KeybindingEntry { export interface KeybindingEntry {
keys: string[]; keys: string[];
@@ -54,16 +55,15 @@ export function KeybindingsOverlay({
<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="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-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">{title}</h2> <h2 className="text-sm font-semibold text-white/70">{title}</h2>
<span className="text-xs text-white/30"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys={['Esc', '?']}
Esc separator="or"
</kbd>{' '} onClick={onClose}
or{' '} title="Close (or press Esc / ?)"
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> className="text-xs text-white/30"
? >
</kbd>{' '}
to close to close
</span> </KeyHint>
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{groups.map((group) => ( {groups.map((group) => (
@@ -80,16 +80,10 @@ export function KeybindingsOverlay({
<span className="text-sm text-white/70"> <span className="text-sm text-white/70">
{binding.description} {binding.description}
</span> </span>
<span className="flex items-center gap-1"> <KeyHint
{binding.keys.map((k) => ( keys={binding.keys}
<kbd className="flex items-center gap-1 text-white/60"
key={k} />
className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs text-white/60"
>
{k}
</kbd>
))}
</span>
</li> </li>
))} ))}
</ul> </ul>
+26
View File
@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils';
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className,
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };
@@ -1,4 +1,5 @@
import { Video, Mic } from 'lucide-react'; import { Video, Mic } from 'lucide-react';
import { KeyHint } from '@/components/key-hint';
import { useMediaSettingsStore } from '@/stores/media-settings-store'; import { useMediaSettingsStore } from '@/stores/media-settings-store';
export function VideoAudioToggle() { export function VideoAudioToggle() {
@@ -6,8 +7,8 @@ export function VideoAudioToggle() {
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode); const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
return ( return (
<span <KeyHint
role="button" keys="V"
onClick={() => onClick={() =>
setRecordingMode(recordingMode === 'video' ? 'audio' : 'video') setRecordingMode(recordingMode === 'video' ? 'audio' : 'video')
} }
@@ -16,11 +17,7 @@ export function VideoAudioToggle() {
? 'Switch to audio-only (V)' ? 'Switch to audio-only (V)'
: 'Switch to video (V)' : 'Switch to video (V)'
} }
className="cursor-pointer transition-colors hover:text-white/80"
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
V
</kbd>{' '}
{recordingMode === 'video' ? ( {recordingMode === 'video' ? (
<> <>
<Video className="inline size-3" /> video <Video className="inline size-3" /> video
@@ -30,6 +27,6 @@ export function VideoAudioToggle() {
<Mic className="inline size-3" /> audio <Mic className="inline size-3" /> audio
</> </>
)} )}
</span> </KeyHint>
); );
} }
@@ -12,6 +12,7 @@ import {
import { useDownloadUrl } from '@/hooks/use-download-url'; import { useDownloadUrl } from '@/hooks/use-download-url';
import { useObjectUrl } from '@/hooks/use-object-url'; import { useObjectUrl } from '@/hooks/use-object-url';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { platform } from '@/lib/platform'; 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"> <div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-4 text-xs text-white/50">
{hasMultiple && ( {hasMultiple && (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <KeyHint
keys="←"
</kbd> onClick={() => goTo(-1)}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> title="Previous (or press ←)"
aria-label="Previous attachment"
</kbd> />
<KeyHint
keys="→"
onClick={() => goTo(1)}
title="Next (or press →)"
aria-label="Next attachment"
/>
navigate navigate
</span> </span>
)} )}
{canDownload && ( {canDownload && (
<span className="flex items-center gap-1.5"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="D"
D onClick={handleDownload}
</kbd> title="Download (or press D)"
>
download download
</span> </KeyHint>
)} )}
{onRemove && ( {onRemove && (
<span className="flex items-center gap-1.5"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="⌫"
onClick={handleRemove}
</kbd> title="Remove (or press Backspace)"
>
remove remove
</span> </KeyHint>
)} )}
<span className="flex items-center gap-1.5"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="Esc"
Esc onClick={() => onOpenChange(null)}
</kbd> title="Close (or press Esc)"
>
close close
</span> </KeyHint>
</div> </div>
</> </>
)} )}
@@ -17,6 +17,7 @@ import { particlePath, parseParticlePath } from '@/lib/particle-path';
import type { ParticlePath } from '@/lib/particle-path'; import type { ParticlePath } from '@/lib/particle-path';
import { RecordingOverlay } from '@/features/compose/recording-overlay'; import { RecordingOverlay } from '@/features/compose/recording-overlay';
import { ScreenSourcePicker } from '@/components/screen-source-picker'; import { ScreenSourcePicker } from '@/components/screen-source-picker';
import { KeyHint } from '@/components/key-hint';
import { TextComposeStep } from '@/features/compose/text-compose-step'; import { TextComposeStep } from '@/features/compose/text-compose-step';
import { ConfigureStreamStep } from '@/features/compose/configure-stream-step'; import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
@@ -726,28 +727,20 @@ export function ComposeOverlay({
</div> </div>
</div> </div>
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50"> <div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<button <KeyHint
type="button" keys="S"
onClick={handleStopIntent} onClick={handleStopIntent}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Stop screen recording (or press S)" 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 stop
</button> </KeyHint>
<button <KeyHint
type="button" keys="Q"
onClick={handleCancelIntent} onClick={handleCancelIntent}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Cancel screen recording (or press Q)" 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 cancel
</button> </KeyHint>
</div> </div>
</div> </div>
)} )}
@@ -5,6 +5,7 @@ import { metaKey } from '@/lib/platform';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { generateRandomName } from '@/lib/random-name'; import { generateRandomName } from '@/lib/random-name';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { KeyHint } from '@/components/key-hint';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@@ -158,18 +159,16 @@ export function ConfigureStreamStep({
{/* Keyboard hints */} {/* Keyboard hints */}
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50"> <div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<span> <KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
cancel cancel
</span> </KeyHint>
<span> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys={`${metaKey}+Enter`}
{metaKey}+Enter onClick={handleSubmit}
</kbd>{' '} title={`Create stream (or press ${metaKey}+Enter)`}
>
create create
</span> </KeyHint>
</div> </div>
</div> </div>
); );
@@ -8,6 +8,7 @@ import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip'; import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { useComposeIntentStore } from '@/stores/compose-intent-store'; import { useComposeIntentStore } from '@/stores/compose-intent-store';
interface RecordingOverlayProps { interface RecordingOverlayProps {
@@ -208,33 +209,22 @@ export function RecordingOverlay({
{/* Bottom center: keyboard hints */} {/* Bottom center: keyboard hints */}
{isRecording && !isLoading && ( {isRecording && !isLoading && (
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50"> <div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<button <KeyHint
type="button" keys="`"
prefix="Release"
onClick={() => requestIntent('stop')} onClick={() => requestIntent('stop')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Finish recording (or release `)" 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 to review
</button> </KeyHint>
<button <KeyHint
type="button" keys={['Esc', 'Q']}
separator="or"
onClick={() => requestIntent('cancel')} onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard recording (or press Esc / Q)" 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 to cancel
</button> </KeyHint>
</div> </div>
)} )}
@@ -250,32 +240,21 @@ export function RecordingOverlay({
</div> </div>
)} )}
<div className="flex items-center gap-4 text-sm text-white/50"> <div className="flex items-center gap-4 text-sm text-white/50">
<button <KeyHint
type="button" keys="Enter"
onClick={() => requestIntent('send')} onClick={() => requestIntent('send')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Send (or press Enter)" title="Send (or press Enter)"
> >
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{' '}
next next
</button> </KeyHint>
<button <KeyHint
type="button" keys={['Esc', 'Q']}
separator="or"
onClick={() => requestIntent('cancel')} onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard (or press Esc / Q)" 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 to cancel
</button> </KeyHint>
<span> <span>
<Button <Button
variant="ghost" 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 { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip'; import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { MarkdownEditor } from '@/features/compose/markdown-editor'; import { MarkdownEditor } from '@/features/compose/markdown-editor';
export interface TextEditorAttachmentProps { export interface TextEditorAttachmentProps {
@@ -111,25 +112,26 @@ export function TextEditor({
const keyboardHints = ( const keyboardHints = (
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50"> <div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
<span> <KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{' '}
cancel cancel
</span> </KeyHint>
<span> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys={`${metaKey}+Enter`}
{metaKey}+Enter onClick={() => {
</kbd>{' '} if (textContent.trim()) onSubmit();
}}
title={`Submit (or press ${metaKey}+Enter)`}
>
{submitHint} {submitHint}
</span> </KeyHint>
{immersive && ( {immersive && (
<span> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys={`${metaKey}+M`}
{metaKey}+M onClick={() => setForceCardMode(true)}
</kbd>{' '} title={`Switch to markdown editor (or press ${metaKey}+M)`}
>
markdown markdown
</span> </KeyHint>
)} )}
{attachmentProps && ( {attachmentProps && (
<span> <span>
+9 -29
View File
@@ -4,6 +4,7 @@ import { CircleDot, CircleCheckBig } from 'lucide-react';
import { particlePath } from '@/lib/particle-path'; import { particlePath } from '@/lib/particle-path';
import { ParticleListView } from '@/features/particles/particle-list-view'; import { ParticleListView } from '@/features/particles/particle-list-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle'; import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { ComposeOverlay } from './compose/compose-overlay'; import { ComposeOverlay } from './compose/compose-overlay';
import { useComposeIntentStore } from '@/stores/compose-intent-store'; import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { ComposeQuotaIndicator } from './compose/compose-quota-indicator'; import { ComposeQuotaIndicator } from './compose/compose-quota-indicator';
@@ -103,44 +104,23 @@ function NetworkRootControls() {
const requestIntent = useComposeIntentStore((s) => s.request); const requestIntent = useComposeIntentStore((s) => s.request);
return ( return (
<div className="flex items-center gap-4 text-sm text-white/50"> <div className="flex items-center gap-4 text-sm text-white/50">
<span> <KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> <KeyHint keys="19">jump</KeyHint>
</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>
<VideoAudioToggle /> <VideoAudioToggle />
<button <KeyHint
type="button" keys="Hold `"
onClick={() => requestIntent('record')} onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Start recording (or hold `)" 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 to start
</button> </KeyHint>
<button <KeyHint
type="button" keys="T"
onClick={() => requestIntent('text')} onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Compose text (or press T)" 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 text
</button> </KeyHint>
</div> </div>
); );
} }
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { updateParticleProperties } from '@/lib/firestore-particles'; import { updateParticleProperties } from '@/lib/firestore-particles';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path'; import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import type { Particle } from '@/api/types'; 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="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"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2> <h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
<span className="text-xs text-white/30"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="Esc"
Esc onClick={onClose}
</kbd>{' '} title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close to close
</span> </KeyHint>
</div> </div>
<Input <Input
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react'; import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { KeyHint } from '@/components/key-hint';
import { import {
buildCustomVisibility, buildCustomVisibility,
buildNetworkVisibility, buildNetworkVisibility,
@@ -104,12 +105,14 @@ export function StreamMembersOverlay({
{/* Header */} {/* Header */}
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Members</h2> <h2 className="text-sm font-semibold text-white/70">Members</h2>
<span className="text-xs text-white/30"> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="Esc"
Esc onClick={onClose}
</kbd>{' '} title="Close (or press Esc)"
className="text-xs text-white/30"
>
to close to close
</span> </KeyHint>
</div> </div>
{/* Visibility */} {/* Visibility */}
@@ -29,6 +29,7 @@ import { TextParticleView } from '@/features/particles/text-particle-view';
import { FallbackParticleView } from '@/features/particles/fallback-particle-view'; import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
import { DeletedParticleView } from '@/features/particles/deleted-particle-view'; import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
import { VideoAudioToggle } from '@/components/video-audio-toggle'; import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint';
import { useMediaSettingsStore } from '@/stores/media-settings-store'; import { useMediaSettingsStore } from '@/stores/media-settings-store';
import { import {
KeybindingsOverlay, KeybindingsOverlay,
@@ -259,12 +260,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const { fastPlayback } = usePlaybackKeys({ mediaRef }); const { fastPlayback } = usePlaybackKeys({ mediaRef });
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
useStreamNavigationKeys({ useStreamNavigationKeys({
next, next,
prev, prev,
currentIndex, currentIndex,
childrenLength: children.length, childrenLength: children.length,
mediaRef, mediaRef,
onExit: handleExitNavigate,
}); });
const handleOpenHuddle = useCallback(() => { const handleOpenHuddle = useCallback(() => {
@@ -325,10 +331,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
// Always show controls when compose is active or exit countdown is visible // Always show controls when compose is active or exit countdown is visible
const controlsVisible = showControls || composeActive || status === 'ended'; const controlsVisible = showControls || composeActive || status === 'ended';
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate); const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
// Reset progress when the particle changes. // Reset progress when the particle changes.
@@ -362,6 +364,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
<StreamViewControls <StreamViewControls
showEscape showEscape
onOpenKeybindings={() => setShowKeybindings(true)} onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/> />
<ComposeOverlay <ComposeOverlay
networkId={networkId} networkId={networkId}
@@ -505,6 +509,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
onlineHumanIds={onlineHumanIds} onlineHumanIds={onlineHumanIds}
exitRemainingMs={exitRemainingMs} exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)} onOpenKeybindings={() => setShowKeybindings(true)}
onOpenHuddle={handleOpenHuddle}
onExit={handleExitNavigate}
/> />
<KeybindingsOverlay <KeybindingsOverlay
@@ -527,6 +533,8 @@ function BottomBar({
onlineHumanIds, onlineHumanIds,
exitRemainingMs, exitRemainingMs,
onOpenKeybindings, onOpenKeybindings,
onOpenHuddle,
onExit,
}: { }: {
visible: boolean; visible: boolean;
total: number; total: number;
@@ -540,6 +548,8 @@ function BottomBar({
onlineHumanIds: Set<string>; onlineHumanIds: Set<string>;
exitRemainingMs: number | null; exitRemainingMs: number | null;
onOpenKeybindings: () => void; onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) { }) {
return ( return (
<div <div
@@ -580,6 +590,8 @@ function BottomBar({
<StreamViewControls <StreamViewControls
showEscape showEscape
onOpenKeybindings={onOpenKeybindings} onOpenKeybindings={onOpenKeybindings}
onOpenHuddle={onOpenHuddle}
onExit={onExit}
/> />
</div> </div>
</div> </div>
@@ -590,58 +602,54 @@ function BottomBar({
function StreamViewControls({ function StreamViewControls({
showEscape, showEscape,
onOpenKeybindings, onOpenKeybindings,
onOpenHuddle,
onExit,
}: { }: {
showEscape?: boolean; showEscape?: boolean;
onOpenKeybindings: () => void; onOpenKeybindings: () => void;
onOpenHuddle: () => void;
onExit: () => void;
}) { }) {
const requestIntent = useComposeIntentStore((s) => s.request); const requestIntent = useComposeIntentStore((s) => s.request);
return ( return (
<div className="flex items-center gap-4 text-sm text-white/50"> <div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && ( {showEscape && (
<span> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="Esc"
Esc onClick={onExit}
</kbd>{' '} title="Back to network (or press Esc)"
>
back back
</span> </KeyHint>
)} )}
<VideoAudioToggle /> <VideoAudioToggle />
<button <KeyHint
type="button" keys="Hold `"
onClick={() => requestIntent('record')} onClick={() => requestIntent('record')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with a recording (or hold `)" 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 to reply
</button> </KeyHint>
<button <KeyHint
type="button" keys="T"
onClick={() => requestIntent('text')} onClick={() => requestIntent('text')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with text (or press T)" 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 text
</button> </KeyHint>
<span> <KeyHint
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> keys="H"
H onClick={onOpenHuddle}
</kbd>{' '} title="Start a huddle (or press H)"
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"
> >
? huddle
</kbd> </KeyHint>
<KeyHint
keys="?"
onClick={onOpenKeybindings}
title="Show all shortcuts"
aria-label="Show keyboard shortcuts"
/>
</div> </div>
); );
} }
@@ -1,5 +1,4 @@
import { useEffect, type RefObject } from 'react'; import { useEffect, type RefObject } from 'react';
import { useNavigate } from 'react-router-dom';
import type { MediaParticleHandle } from '@/features/particles/media-particle-view'; import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
import { isTypingTarget } from '@/lib/keyboard'; import { isTypingTarget } from '@/lib/keyboard';
@@ -11,6 +10,7 @@ interface UseStreamNavigationKeysOptions {
currentIndex: number; currentIndex: number;
childrenLength: number; childrenLength: number;
mediaRef: RefObject<MediaParticleHandle | null>; mediaRef: RefObject<MediaParticleHandle | null>;
onExit: () => void;
} }
/** /**
@@ -23,9 +23,8 @@ export function useStreamNavigationKeys({
currentIndex, currentIndex,
childrenLength, childrenLength,
mediaRef, mediaRef,
onExit,
}: UseStreamNavigationKeysOptions) { }: UseStreamNavigationKeysOptions) {
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e)) return; if (isTypingTarget(e)) return;
@@ -53,12 +52,12 @@ export function useStreamNavigationKeys({
break; break;
case 'Escape': case 'Escape':
e.preventDefault(); e.preventDefault();
navigate(-1); onExit();
break; break;
} }
}; };
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [next, prev, currentIndex, childrenLength, mediaRef, navigate]); }, [next, prev, currentIndex, childrenLength, mediaRef, onExit]);
} }