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({