From 424f27737b329ffdca372a295b7e98c9947f1d4f Mon Sep 17 00:00:00 2001 From: talksik Date: Thu, 30 Apr 2026 17:45:03 -0700 Subject: [PATCH] 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 --- .../src/features/compose/compose-overlay.tsx | 154 +++++++++++++----- .../features/compose/recording-overlay.tsx | 38 ++++- js/desktop/src/features/network-root.tsx | 20 ++- .../src/features/particles/stream-view.tsx | 20 ++- js/desktop/src/stores/compose-intent-store.ts | 34 ++++ 5 files changed, 205 insertions(+), 61 deletions(-) create mode 100644 js/desktop/src/stores/compose-intent-store.ts diff --git a/js/desktop/src/features/compose/compose-overlay.tsx b/js/desktop/src/features/compose/compose-overlay.tsx index eba1a40..ef5157b 100644 --- a/js/desktop/src/features/compose/compose-overlay.tsx +++ b/js/desktop/src/features/compose/compose-overlay.tsx @@ -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({
- + +
)} diff --git a/js/desktop/src/features/compose/recording-overlay.tsx b/js/desktop/src/features/compose/recording-overlay.tsx index 978fdb2..fbdcf02 100644 --- a/js/desktop/src/features/compose/recording-overlay.tsx +++ b/js/desktop/src/features/compose/recording-overlay.tsx @@ -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 (
- + +
)} @@ -248,13 +260,23 @@ export function RecordingOverlay({ )}
- + + +
); } diff --git a/js/desktop/src/features/particles/stream-view.tsx b/js/desktop/src/features/particles/stream-view.tsx index 3d16eb3..ad941df 100644 --- a/js/desktop/src/features/particles/stream-view.tsx +++ b/js/desktop/src/features/particles/stream-view.tsx @@ -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 (
{showEscape && ( @@ -537,18 +539,28 @@ function StreamViewControls({ )} - + + H diff --git a/js/desktop/src/stores/compose-intent-store.ts b/js/desktop/src/stores/compose-intent-store.ts new file mode 100644 index 0000000..7d11819 --- /dev/null +++ b/js/desktop/src/stores/compose-intent-store.ts @@ -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((set) => ({ + intent: null, + // New object each call — ensures useEffect refires for repeated same kind. + request: (kind) => set({ intent: { kind } }), + clear: () => set({ intent: null }), +}));