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 { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
|
||||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
|
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
||||||
|
|
||||||
export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
||||||
|
|
||||||
@@ -397,6 +398,89 @@ export function ComposeOverlay({
|
|||||||
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
|
[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 ---
|
// --- Keyboard handling ---
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -422,54 +506,33 @@ export function ComposeOverlay({
|
|||||||
|
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case "idle": {
|
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) {
|
if (e.key === "`" && !e.repeat) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
recordStartRef.current = Date.now();
|
handleRecordIntent();
|
||||||
setRecordingSource("media");
|
|
||||||
setStepSync("recording");
|
|
||||||
startRecording();
|
|
||||||
} else if (e.key === "s" || e.key === "S") {
|
} else if (e.key === "s" || e.key === "S") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!guardIdle()) break;
|
||||||
setRecordingSource("screen");
|
setRecordingSource("screen");
|
||||||
setStepSync("picking");
|
setStepSync("picking");
|
||||||
} else if (e.key === "t" || e.key === "T") {
|
} else if (e.key === "t" || e.key === "T") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setStepSync("typing");
|
handleTextIntent();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "recording": {
|
case "recording": {
|
||||||
if (e.key === "`" && !e.repeat) {
|
if (e.key === "`" && !e.repeat) {
|
||||||
// Second tap stops recording (toggle mode)
|
// Second tap stops media recording (toggle mode)
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
stopRecording();
|
handleStopIntent();
|
||||||
} else if ((e.key === "s" || e.key === "S") && recordingSourceRef.current === "screen") {
|
} else if ((e.key === "s" || e.key === "S") && recordingSourceRef.current === "screen") {
|
||||||
// S stops screen recording when main window is focused
|
// S stops screen recording when main window is focused
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
stopScreenRecording();
|
handleStopIntent();
|
||||||
} else if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
} else if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (recordingSourceRef.current === "screen") {
|
handleCancelIntent();
|
||||||
cancelScreenRecording();
|
|
||||||
} else {
|
|
||||||
cancelRecording();
|
|
||||||
}
|
|
||||||
cancel();
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -477,19 +540,10 @@ export function ComposeOverlay({
|
|||||||
case "reviewing": {
|
case "reviewing": {
|
||||||
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (recordingSourceRef.current === "screen") {
|
handleCancelIntent();
|
||||||
cancelScreenRecording();
|
|
||||||
} else {
|
|
||||||
cancelRecording();
|
|
||||||
}
|
|
||||||
cancel();
|
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (targetPath) {
|
handleSendIntent();
|
||||||
onSubmitReply();
|
|
||||||
} else {
|
|
||||||
setStepSync("configuring");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -502,7 +556,7 @@ export function ComposeOverlay({
|
|||||||
// Only stop on release if held long enough (hold-to-record mode).
|
// Only stop on release if held long enough (hold-to-record mode).
|
||||||
// Quick taps are handled by the second keydown (toggle mode).
|
// Quick taps are handled by the second keydown (toggle mode).
|
||||||
if (recordStartRef.current > 0 && Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS) {
|
if (recordStartRef.current > 0 && Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS) {
|
||||||
stopRecording();
|
handleStopIntent();
|
||||||
recordStartRef.current = 0;
|
recordStartRef.current = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,7 +568,7 @@ export function ComposeOverlay({
|
|||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
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 ---
|
// --- Screen source selection handler ---
|
||||||
|
|
||||||
@@ -571,18 +625,28 @@ 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">
|
||||||
<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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
S
|
S
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
stop
|
stop
|
||||||
</span>
|
</button>
|
||||||
<span>
|
<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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Q
|
Q
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
cancel
|
cancel
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,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 { useComposeIntentStore } from "@/stores/compose-intent-store";
|
||||||
|
|
||||||
interface RecordingOverlayProps {
|
interface RecordingOverlayProps {
|
||||||
step: "recording" | "reviewing";
|
step: "recording" | "reviewing";
|
||||||
@@ -150,6 +151,7 @@ export function RecordingOverlay({
|
|||||||
const isReviewing = step === "reviewing";
|
const isReviewing = step === "reviewing";
|
||||||
const isRecording = step === "recording";
|
const isRecording = step === "recording";
|
||||||
const isLoading = isRecording && !mediaStream;
|
const isLoading = isRecording && !mediaStream;
|
||||||
|
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -217,14 +219,24 @@ 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">
|
||||||
<span>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => requestIntent("stop")}
|
||||||
|
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
||||||
|
title="Finish recording (or release `)"
|
||||||
|
>
|
||||||
Release{" "}
|
Release{" "}
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
`
|
`
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
to review
|
to review
|
||||||
</span>
|
</button>
|
||||||
<span>
|
<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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Esc
|
Esc
|
||||||
</kbd>{" or "}
|
</kbd>{" or "}
|
||||||
@@ -232,7 +244,7 @@ export function RecordingOverlay({
|
|||||||
Q
|
Q
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
to cancel
|
to cancel
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -248,13 +260,23 @@ 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">
|
||||||
<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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Enter
|
Enter
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
next
|
next
|
||||||
</span>
|
</button>
|
||||||
<span>
|
<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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Esc
|
Esc
|
||||||
</kbd>{" or "}
|
</kbd>{" or "}
|
||||||
@@ -262,7 +284,7 @@ export function RecordingOverlay({
|
|||||||
Q
|
Q
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
to cancel
|
to cancel
|
||||||
</span>
|
</button>
|
||||||
<span>
|
<span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { ComposeOverlay } from "./compose/compose-overlay";
|
import { ComposeOverlay } from "./compose/compose-overlay";
|
||||||
|
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
||||||
import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator";
|
import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
||||||
@@ -91,6 +92,7 @@ export default function NetworkRoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NetworkRootControls() {
|
function NetworkRootControls() {
|
||||||
|
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>
|
<span>
|
||||||
@@ -109,18 +111,28 @@ function NetworkRootControls() {
|
|||||||
jump
|
jump
|
||||||
</span>
|
</span>
|
||||||
<VideoAudioToggle />
|
<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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Hold `
|
Hold `
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
to start
|
to start
|
||||||
</span>
|
</button>
|
||||||
<span>
|
<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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
T
|
T
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
text
|
text
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { apiClient } from "@/api/client";
|
|||||||
import { isParticleDeleted, type Particle } from "@/api/types";
|
import { isParticleDeleted, type Particle } from "@/api/types";
|
||||||
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||||
import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay";
|
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 { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
||||||
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
|
import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view";
|
||||||
import { TextParticleView } from "@/features/particles/text-particle-view";
|
import { TextParticleView } from "@/features/particles/text-particle-view";
|
||||||
@@ -526,6 +527,7 @@ function StreamViewControls({
|
|||||||
showEscape?: boolean;
|
showEscape?: boolean;
|
||||||
onOpenKeybindings: () => void;
|
onOpenKeybindings: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
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 && (
|
||||||
@@ -537,18 +539,28 @@ function StreamViewControls({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<VideoAudioToggle />
|
<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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Hold `
|
Hold `
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
to reply
|
to reply
|
||||||
</span>
|
</button>
|
||||||
<span>
|
<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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
T
|
T
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
text
|
text
|
||||||
</span>
|
</button>
|
||||||
<span>
|
<span>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
H
|
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