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:
@@ -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"
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user