feat: improve usability with clickable keyboard hints

This allows people to use the app even if they aren't used to their
keyboard.
Closes #186
This commit is contained in:
talksik
2026-04-30 17:45:03 -07:00
parent aaf9ad4518
commit 424f27737b
5 changed files with 205 additions and 61 deletions
@@ -22,6 +22,7 @@ import { createImageThumbnail } from "@/lib/image-thumbnail";
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { useComposeIntentStore } from "@/stores/compose-intent-store";
export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
@@ -397,6 +398,89 @@ export function ComposeOverlay({
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
);
// --- Compose intent handlers ---
// Single source of truth for the step transitions triggered by the user.
// Both the keyboard handler and the intent store dispatch into these so
// guards (disabled, quota) and screen-vs-media branching live in one place.
const guardIdle = useCallback((): boolean => {
if (stepRef.current !== "idle") return false;
if (disabledRef.current) {
toast.info("This stream is closed");
return false;
}
if (quotaExhaustedRef.current) {
toast.info("Daily message limit reached. Upgrade to Pro to keep sending.");
return false;
}
return true;
}, []);
const handleRecordIntent = useCallback(() => {
if (!guardIdle()) return;
recordStartRef.current = Date.now();
setRecordingSource("media");
setStepSync("recording");
startRecording();
}, [guardIdle, setStepSync, startRecording]);
const handleTextIntent = useCallback(() => {
if (!guardIdle()) return;
setStepSync("typing");
}, [guardIdle, setStepSync]);
const handleStopIntent = useCallback(() => {
if (stepRef.current !== "recording") return;
if (recordingSourceRef.current === "screen") {
stopScreenRecording();
} else {
stopRecording();
}
}, [stopRecording, stopScreenRecording]);
const handleCancelIntent = useCallback(() => {
const s = stepRef.current;
if (s === "recording" || s === "reviewing") {
if (recordingSourceRef.current === "screen") {
cancelScreenRecording();
} else {
cancelRecording();
}
cancel();
} else if (s === "typing" || s === "configuring" || s === "picking") {
cancel();
}
}, [cancel, cancelRecording, cancelScreenRecording]);
const handleSendIntent = useCallback(() => {
if (stepRef.current !== "reviewing") return;
if (targetPath) {
onSubmitReply();
} else {
setStepSync("configuring");
}
}, [targetPath, onSubmitReply, setStepSync]);
// --- Intent store subscription ---
// External callers (clickable hints) dispatch via the store; this overlay
// executes the matching handler and clears the intent. Keyboard handlers
// call the same handlers directly without a store round-trip.
const intent = useComposeIntentStore((s) => s.intent);
const clearIntent = useComposeIntentStore((s) => s.clear);
useEffect(() => {
if (!intent) return;
switch (intent.kind) {
case "record": handleRecordIntent(); break;
case "text": handleTextIntent(); break;
case "stop": handleStopIntent(); break;
case "cancel": handleCancelIntent(); break;
case "send": handleSendIntent(); break;
}
clearIntent();
}, [intent, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
// --- Keyboard handling ---
useEffect(() => {
@@ -422,54 +506,33 @@ export function ComposeOverlay({
switch (currentStep) {
case "idle": {
if (disabledRef.current) {
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T" || e.key === "s" || e.key === "S") {
e.preventDefault();
toast.info("This stream is closed");
}
break;
}
if (quotaExhaustedRef.current) {
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T" || e.key === "s" || e.key === "S") {
e.preventDefault();
toast.info("Daily message limit reached. Upgrade to Pro to keep sending.");
}
break;
}
if (e.key === "`" && !e.repeat) {
e.preventDefault();
recordStartRef.current = Date.now();
setRecordingSource("media");
setStepSync("recording");
startRecording();
handleRecordIntent();
} else if (e.key === "s" || e.key === "S") {
e.preventDefault();
if (!guardIdle()) break;
setRecordingSource("screen");
setStepSync("picking");
} else if (e.key === "t" || e.key === "T") {
e.preventDefault();
setStepSync("typing");
handleTextIntent();
}
break;
}
case "recording": {
if (e.key === "`" && !e.repeat) {
// Second tap stops recording (toggle mode)
// Second tap stops media recording (toggle mode)
e.preventDefault();
stopRecording();
handleStopIntent();
} else if ((e.key === "s" || e.key === "S") && recordingSourceRef.current === "screen") {
// S stops screen recording when main window is focused
e.preventDefault();
stopScreenRecording();
handleStopIntent();
} else if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
if (recordingSourceRef.current === "screen") {
cancelScreenRecording();
} else {
cancelRecording();
}
cancel();
handleCancelIntent();
}
break;
}
@@ -477,19 +540,10 @@ export function ComposeOverlay({
case "reviewing": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
if (recordingSourceRef.current === "screen") {
cancelScreenRecording();
} else {
cancelRecording();
}
cancel();
handleCancelIntent();
} else if (e.key === "Enter") {
e.preventDefault();
if (targetPath) {
onSubmitReply();
} else {
setStepSync("configuring");
}
handleSendIntent();
}
break;
}
@@ -502,7 +556,7 @@ export function ComposeOverlay({
// Only stop on release if held long enough (hold-to-record mode).
// Quick taps are handled by the second keydown (toggle mode).
if (recordStartRef.current > 0 && Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS) {
stopRecording();
handleStopIntent();
recordStartRef.current = 0;
}
}
@@ -514,7 +568,7 @@ export function ComposeOverlay({
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [targetPath, startRecording, stopRecording, cancelRecording, startScreenRecording, stopScreenRecording, cancelScreenRecording, cancel, setStepSync]);
}, [cancel, setStepSync, guardIdle, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent]);
// --- Screen source selection handler ---
@@ -571,18 +625,28 @@ export function ComposeOverlay({
</div>
</div>
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<span>
<button
type="button"
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
</span>
<span>
</button>
<button
type="button"
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
</span>
</button>
</div>
</div>
)}
@@ -7,6 +7,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 { useComposeIntentStore } from "@/stores/compose-intent-store";
interface RecordingOverlayProps {
step: "recording" | "reviewing";
@@ -150,6 +151,7 @@ export function RecordingOverlay({
const isReviewing = step === "reviewing";
const isRecording = step === "recording";
const isLoading = isRecording && !mediaStream;
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div
@@ -217,14 +219,24 @@ 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">
<span>
<button
type="button"
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
</span>
<span>
</button>
<button
type="button"
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 "}
@@ -232,7 +244,7 @@ export function RecordingOverlay({
Q
</kbd>{" "}
to cancel
</span>
</button>
</div>
)}
@@ -248,13 +260,23 @@ export function RecordingOverlay({
</div>
)}
<div className="flex items-center gap-4 text-sm text-white/50">
<span>
<button
type="button"
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
</span>
<span>
</button>
<button
type="button"
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 "}
@@ -262,7 +284,7 @@ export function RecordingOverlay({
Q
</kbd>{" "}
to cancel
</span>
</button>
<span>
<Button
variant="ghost"
+16 -4
View File
@@ -5,6 +5,7 @@ import { particlePath } from "@/lib/particle-path";
import { ParticleListView } from "@/features/particles/particle-list-view";
import { VideoAudioToggle } from "@/components/video-audio-toggle";
import { ComposeOverlay } from "./compose/compose-overlay";
import { useComposeIntentStore } from "@/stores/compose-intent-store";
import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStreamParticles } from "@/hooks/use-stream-particles";
@@ -91,6 +92,7 @@ export default function NetworkRoot() {
}
function NetworkRootControls() {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
<span>
@@ -109,18 +111,28 @@ function NetworkRootControls() {
jump
</span>
<VideoAudioToggle />
<span>
<button
type="button"
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
</span>
<span>
</button>
<button
type="button"
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
</span>
</button>
</div>
);
}
@@ -5,6 +5,7 @@ import { apiClient } from "@/api/client";
import { isParticleDeleted, type Particle } from "@/api/types";
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
import { useComposeIntentStore } from "@/stores/compose-intent-store";
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
import { TextParticleView } from "@/features/particles/text-particle-view";
@@ -526,6 +527,7 @@ function StreamViewControls({
showEscape?: boolean;
onOpenKeybindings: () => void;
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && (
@@ -537,18 +539,28 @@ function StreamViewControls({
</span>
)}
<VideoAudioToggle />
<span>
<button
type="button"
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
</span>
<span>
</button>
<button
type="button"
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
</span>
</button>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
@@ -0,0 +1,34 @@
import { create } from "zustand";
/**
* Fire-and-forget intents that drive the compose flow from outside the active
* ComposeOverlay (e.g. clickable keyboard hints in the bottom controls or in
* the recording overlay). The active overlay subscribes to `intent`, runs the
* step-aware handler, then calls `clear()`.
*
* | intent | valid step | effect |
* |--------|-----------------|--------------------------------------------|
* | record | idle | start a media (camera/mic) recording |
* | text | idle | open the text compose step |
* | stop | recording | finish recording → review |
* | cancel | recording, etc. | abort recording / discard review |
* | send | reviewing | submit the recorded particle |
*
* Keyboard handlers stay local to ComposeOverlay (no round-trip), but they
* ultimately invoke the same callbacks the intent dispatcher does, so the
* guard logic stays in one place.
*/
export type ComposeIntent = "record" | "text" | "stop" | "cancel" | "send";
interface ComposeIntentState {
intent: { kind: ComposeIntent } | null;
request: (kind: ComposeIntent) => void;
clear: () => void;
}
export const useComposeIntentStore = create<ComposeIntentState>((set) => ({
intent: null,
// New object each call — ensures useEffect refires for repeated same kind.
request: (kind) => set({ intent: { kind } }),
clear: () => set({ intent: null }),
}));