enhance keyboard experience

Now we have network root and stream-view defining their own hints
instead of trying to re-use one component. And we also introduce an
overlay for additional commands for people.
This commit is contained in:
talksik
2026-04-08 08:40:07 -07:00
parent f95ccf134d
commit 7e11d66dc2
5 changed files with 271 additions and 111 deletions
+99
View File
@@ -0,0 +1,99 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
export interface KeybindingEntry {
keys: string[];
description: string;
}
export interface KeybindingGroup {
label: string;
bindings: KeybindingEntry[];
}
interface KeybindingsOverlayProps {
open: boolean;
onClose: () => void;
groups: KeybindingGroup[];
title?: string;
}
export function KeybindingsOverlay({
open,
onClose,
groups,
title = "Keyboard Shortcuts",
}: KeybindingsOverlayProps) {
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "?") {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () => window.removeEventListener("keydown", handler, { capture: true });
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-[100]">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<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">
<h2 className="text-sm font-semibold text-white/70">{title}</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>{" "}
or{" "}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
?
</kbd>{" "}
to close
</span>
</div>
<div className="flex flex-col gap-5">
{groups.map((group) => (
<section key={group.label}>
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
{group.label}
</h3>
<ul className="flex flex-col gap-1">
{group.bindings.map((binding) => (
<li
key={binding.description}
className="flex items-center justify-between border-b border-white/5 pb-1 last:border-0 last:pb-0"
>
<span className="text-sm text-white/70">
{binding.description}
</span>
<span className="flex items-center gap-1">
{binding.keys.map((k) => (
<kbd
key={k}
className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs text-white/60"
>
{k}
</kbd>
))}
</span>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</div>,
document.body,
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Video, Mic } from "lucide-react";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
export function VideoAudioToggle() {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
return (
<span>
<kbd
role="button"
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
title={
recordingMode === "video" ? "Switch to audio-only" : "Switch to video"
}
className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80"
>
{recordingMode === "video" ? (
<>
<Video className="inline size-3" /> Video
</>
) : (
<>
<Mic className="inline size-3" /> Audio
</>
)}
</kbd>
</span>
);
}
@@ -1,90 +0,0 @@
import { Video, Mic, Paperclip } from "lucide-react";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { PropsWithChildren } from "react";
interface ControlsIndicatorProps {
type: "reply" | "new";
attachmentCount?: number;
onOpenAttachments?: () => void;
showEscape?: boolean;
}
export default function ControlsIndicator({
type,
attachmentCount = 0,
showEscape = false,
onOpenAttachments,
children
}: PropsWithChildren<ControlsIndicatorProps>) {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
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>{" "}
back
</span>
)}
{type === "new" && (
<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
role="button"
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
title={
recordingMode === "video"
? "Switch to audio-only"
: "Switch to video"
}
className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80"
>
{recordingMode === "video" ? (
<><Video className="inline size-3" /> Video</>
) : (
<><Mic className="inline size-3" /> Audio</>
)}
</kbd>
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "}
to {type === "reply" ? "reply" : "start"}
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{" "}
text
</span>
{attachmentCount > 0 && (
<button
type="button"
onClick={onOpenAttachments}
className="flex items-center gap-1 text-white/50 transition-colors hover:text-white/80"
>
<kbd className="self-end rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
A
</kbd>{" "}
<Paperclip className="size-3" />
{attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"}
</button>
)}
{children}
</div>
);
}
+37 -2
View File
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { CircleDot, CircleCheckBig } from "lucide-react";
import { particlePath } from "@/lib/particle-path";
import { ParticleListView } from "@/features/particles/particle-list-view";
import ControlsIndicator from "@/features/compose/controls-indicator";
import { VideoAudioToggle } from "@/components/video-audio-toggle";
import { ComposeOverlay } from "./compose/compose-overlay";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStreamParticles } from "@/hooks/use-stream-particles";
@@ -63,9 +63,44 @@ export default function NetworkRoot() {
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
<div className="pointer-events-auto">
<ControlsIndicator type={"new"} />
<NetworkRootControls />
</div>
</div>
</div>
);
}
function NetworkRootControls() {
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>
<VideoAudioToggle />
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "}
to start
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{" "}
text
</span>
</div>
);
}
+103 -19
View File
@@ -10,7 +10,8 @@ import { MediaParticleView, type MediaParticleHandle } from "@/features/particle
import { TextParticleView } from "@/features/particles/text-particle-view";
import { FallbackParticleView } from "@/features/particles/fallback-particle-view";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import ControlsIndicator from "@/features/compose/controls-indicator";
import { VideoAudioToggle } from "@/components/video-audio-toggle";
import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNetwork, useNetworks } from "@/hooks/use-networks";
import { Button } from "@/components/ui/button";
@@ -101,6 +102,34 @@ function useExitCountdown(
return remainingMs;
}
// --- Keybindings ---
const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
{
label: "Navigation",
bindings: [
{ keys: ["←", "→"], description: "Previous / next particle" },
{ keys: ["↑", "↓"], description: "Previous / next particle" },
{ keys: ["Esc"], description: "Back to network" },
],
},
{
label: "Playback",
bindings: [
{ keys: ["Hold", "Space"], description: "Pause" },
{ keys: ["Hold", "Shift"], description: "1.5× speed" },
],
},
{
label: "Compose",
bindings: [
{ keys: ["Hold", "`"], description: "Reply" },
{ keys: ["T"], description: "Text compose" },
{ keys: ["H"], description: "Join huddle" },
],
},
];
// --- StreamView ---
interface StreamViewProps {
@@ -145,6 +174,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
const [composeActive, setComposeActive] = useState(false);
const [progress, setProgress] = useState(0);
const [fastPlayback, setFastPlayback] = useState(false);
const [showKeybindings, setShowKeybindings] = useState(false);
// Show/hide chrome on mouse activity (YouTube-style)
const [showControls, setShowControls] = useState(true);
@@ -228,6 +258,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
navigate(`/${networkId}`);
break;
}
case "?":
e.preventDefault();
setShowKeybindings((v) => !v);
break;
}
};
@@ -252,7 +286,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
window.removeEventListener("keyup", handleKeyUp);
};
},
[composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id],
[composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings],
);
// Click-to-navigate: left 30% = prev, right 70% = next
@@ -276,14 +310,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
<p className="text-muted-foreground text-sm">
No particles in this stream yet
</p>
<ControlsIndicator type="reply" showEscape={true}>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
</kbd>{" "}
huddle
</span>
</ControlsIndicator>
<StreamViewControls
showEscape
onOpenKeybindings={() => setShowKeybindings(true)}
/>
<ComposeOverlay
networkId={networkId}
targetPath={path}
@@ -373,6 +403,14 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
onGoTo={goTo}
presenceBySegment={presenceBySegment}
exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)}
/>
<KeybindingsOverlay
open={showKeybindings}
onClose={() => setShowKeybindings(false)}
groups={STREAM_VIEW_KEYBINDINGS}
title="Stream View"
/>
</div>
);
@@ -386,6 +424,7 @@ function BottomBar({
onGoTo,
presenceBySegment,
exitRemainingMs,
onOpenKeybindings,
}: {
visible: boolean;
total: number;
@@ -394,6 +433,7 @@ function BottomBar({
onGoTo: (index: number) => void;
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
exitRemainingMs: number | null;
onOpenKeybindings: () => void;
}) {
return (
<div className={cn(
@@ -418,7 +458,7 @@ function BottomBar({
onGoTo={onGoTo}
layer="tracks"
/>
<div className="flex items-center justify-center px-3 pt-2">
<div className="flex items-center justify-center px-3 pt-2 gap-2">
{exitRemainingMs !== null && (
<div className="flex justify-center">
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
@@ -426,20 +466,64 @@ function BottomBar({
</span>
</div>
)}
<ControlsIndicator type="reply" showEscape={true}>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
</kbd>{" "}
huddle
</span>
</ControlsIndicator>
<StreamViewControls
showEscape
onOpenKeybindings={onOpenKeybindings}
/>
</div>
</div>
</div>
);
}
function StreamViewControls({
showEscape,
onOpenKeybindings,
}: {
showEscape?: boolean;
onOpenKeybindings: () => void;
}) {
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>{" "}
back
</span>
)}
<VideoAudioToggle />
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "}
to reply
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{" "}
text
</span>
<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"
>
?
</kbd>
</div>
);
}
function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) {
const navigate = useNavigate();
const network = useNetwork(networkId);