import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react"; import { toast } from "sonner"; import { useAuthStore } from "@/stores/auth-store"; import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle"; import { QuotaExceededError } from "@/lib/errors"; import { isUsageExhausted, useInvalidateNetworkUsage, useNetworkUsage } from "@/hooks/use-network-usage"; import { useRecorder } from "@/features/compose/use-recorder"; import { useScreenRecorder } from "@/features/compose/use-screen-recorder"; import { particlePath, parseParticlePath } from "@/lib/particle-path"; import type { ParticlePath } from "@/lib/particle-path"; import { RecordingOverlay } from "@/features/compose/recording-overlay"; import { ScreenSourcePicker } from "@/components/screen-source-picker"; import { TextComposeStep } from "@/features/compose/text-compose-step"; import { ConfigureStreamStep } from "@/features/compose/configure-stream-step"; import { apiClient } from "@/api/client"; import { useMediaSettingsStore } from "@/stores/media-settings-store"; import { useMediaDevicesStore } from "@/stores/media-devices-store"; import { useMediaDevices } from "@/hooks/use-media-devices"; import { resolveEffectiveDeviceId } from "@/hooks/use-effective-device-id"; import { useFileInput } from "@/hooks/use-file-input"; 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"; export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting"; type RecordingSource = "media" | "screen"; interface ComposeOverlayProps { networkId: string; // Optional target path for reply mode. If not provided, compose creates a new stream. targetPath?: ParticlePath; onActiveChange?: (active: boolean) => void; onStepChange?: (step: ComposeStep) => void; onParticleCreated?: (particleId: string) => void; /** When true, composing is blocked (e.g. stream is closed). */ disabled?: boolean; } const HOLD_THRESHOLD_MS = 250; /** * Self-contained compose overlay. Each consumer renders its own instance * with props that determine the mode (new stream vs. reply). */ export function ComposeOverlay({ networkId, targetPath, onActiveChange, onStepChange, onParticleCreated, disabled, }: ComposeOverlayProps) { const [step, setStep] = useState("idle"); const [error, setError] = useState(null); const [textContent, setTextContent] = useState(""); const [mediaStream, setMediaStream] = useState(null); const [reviewBlob, setReviewBlob] = useState(null); const [reviewDurationMs, setReviewDurationMs] = useState(0); const [reviewMimeType, setReviewMimeType] = useState(null); const [attachments, setAttachments] = useState([]); const [recordingSource, setRecordingSource] = useState("media"); const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const savedMic = useMediaDevicesStore((s) => s.mic); const savedCamera = useMediaDevicesStore((s) => s.camera); const { audioInputs, videoInputs } = useMediaDevices(); const micDeviceId = resolveEffectiveDeviceId(savedMic, audioInputs); const cameraDeviceId = resolveEffectiveDeviceId(savedCamera, videoInputs); const userId = useAuthStore((s) => s.user?.id); const createParticle = useCreateParticle(); const createStream = useCreateStreamParticle(); const { data: usage } = useNetworkUsage(networkId); const invalidateUsage = useInvalidateNetworkUsage(); const quotaExhausted = isUsageExhausted(usage); // Refs for synchronous reads in keyboard handlers const stepRef = useRef(step); const recordStartRef = useRef(0); const disabledRef = useRef(disabled); disabledRef.current = disabled; const quotaExhaustedRef = useRef(quotaExhausted); quotaExhaustedRef.current = quotaExhausted; const recordingSourceRef = useRef(recordingSource); recordingSourceRef.current = recordingSource; const setStepSync = useCallback((next: ComposeStep) => { stepRef.current = next; setStep(next); }, []); useSuspendPlayback(step !== "idle", "compose"); // Notify parent when active state changes useEffect(() => { onActiveChange?.(step !== "idle"); onStepChange?.(step); // Refresh quota when the overlay activates — user is about to send, so // we want the most accurate count before the client-side gate kicks in. if (step !== "idle") { void invalidateUsage(networkId); } }, [step, onActiveChange, onStepChange, invalidateUsage, networkId]); const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => { for (const a of items) { if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl); } }, []); const cancel = useCallback(() => { setStepSync("idle"); setError(null); setTextContent(""); setMediaStream(null); setReviewBlob(null); setReviewDurationMs(0); setReviewMimeType(null); setRecordingSource("media"); setAttachments((prev) => { revokeAttachmentThumbnails(prev); return []; }); }, [setStepSync, revokeAttachmentThumbnails]); const addAttachments = useCallback(async (files: File[]) => { const currentCount = attachments.length; const available = MAX_ATTACHMENTS - currentCount; if (available <= 0) { toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`); return; } const accepted = files.slice(0, available); if (accepted.length < files.length) { toast.error(`Maximum ${MAX_ATTACHMENTS} attachments — ${files.length - accepted.length} skipped`); } const newAttachments: PendingAttachment[] = []; for (const file of accepted) { if (file.size > MAX_ATTACHMENT_SIZE_BYTES) { toast.error(`${file.name} is too large (max 25 MB)`); continue; } const thumbnailUrl = await createImageThumbnail(file); newAttachments.push({ id: crypto.randomUUID(), file, thumbnailUrl, status: "pending", }); } if (newAttachments.length > 0) { setAttachments((prev) => [...prev, ...newAttachments]); } }, [attachments.length]); const removeAttachment = useCallback((id: string) => { setAttachments((prev) => { const removed = prev.find((a) => a.id === id); if (removed?.thumbnailUrl) URL.revokeObjectURL(removed.thumbnailUrl); return prev.filter((a) => a.id !== id); }); }, []); const { openFilePicker, isDragging, dropZoneProps } = useFileInput({ onFilesSelected: addAttachments, enabled: step === "typing" || step === "reviewing", }); const { startRecording, stopRecording, cancelRecording } = useRecorder({ mode: recordingMode, micDeviceId, cameraDeviceId, onStreamReady: (stream) => setMediaStream(stream), onStreamCleanup: () => setMediaStream(null), onFinish: (blob, durationMs, mimeType) => { setStepSync("reviewing"); setReviewBlob(blob); setReviewDurationMs(durationMs); setReviewMimeType(mimeType); }, onError: (message) => setError(message), }); const { startRecording: startScreenRecording, stopRecording: stopScreenRecording, cancelRecording: cancelScreenRecording, } = useScreenRecorder({ micDeviceId, onFinish: (blob, durationMs, mimeType) => { setStepSync("reviewing"); setReviewBlob(blob); setReviewDurationMs(durationMs); setReviewMimeType(mimeType); }, onError: (message) => { setError(message); cancel(); }, }); // --- Submission --- const uploadMedia = useCallback( async (blob: Blob, mimeType: string) => { const ext = "webm"; const fileName = `recording-${Date.now()}.${ext}`; const { object_id, upload_url, upload_headers } = await apiClient.prepareUpload({ network_id: networkId, name: fileName, content_type: mimeType, content_length: blob.size, }); await fetch(upload_url, { method: "PUT", headers: upload_headers, body: blob, }); await apiClient.confirmUpload(object_id); return { object_id, size_bytes: blob.size }; }, [networkId], ); const uploadFile = useCallback( async (file: File) => { const { object_id, upload_url, upload_headers } = await apiClient.prepareUpload({ network_id: networkId, name: file.name, content_type: file.type || "application/octet-stream", content_length: file.size, }); await fetch(upload_url, { method: "PUT", headers: upload_headers, body: file, }); await apiClient.confirmUpload(object_id); return { object_id, size_bytes: file.size }; }, [networkId], ); const uploadAttachments = useCallback( async (parentPath: ParticlePath, parentId: string) => { if (attachments.length === 0 || !userId) return; const { networkId: netId, segments } = parseParticlePath(parentPath); const childrenPath = particlePath(netId, [...segments, parentId]); const results = await Promise.allSettled( attachments.map(async (attachment) => { setAttachments((prev) => prev.map((a) => a.id === attachment.id ? { ...a, status: "uploading" as const } : a, ), ); const { object_id } = await uploadFile(attachment.file); await createParticle.mutateAsync({ path: childrenPath, type: "file", properties: { object_id, filename: attachment.file.name, mime_type: attachment.file.type || "application/octet-stream", size_bytes: attachment.file.size, }, createdByHumanId: userId, }); }), ); const failed = results.filter((r) => r.status === "rejected"); if (failed.length > 0) { toast.error(`${failed.length} attachment${failed.length > 1 ? "s" : ""} failed to upload`); } }, [attachments, userId, uploadFile, createParticle], ); const createChildParticle = useCallback( async (path: ParticlePath) => { if (!userId) return; let particleId: undefined | string; if (textContent.trim()) { particleId = await createParticle.mutateAsync({ path, type: "text", properties: { content: textContent }, createdByHumanId: userId, }); } else if (reviewBlob && reviewMimeType) { const { object_id, size_bytes } = await uploadMedia( reviewBlob, reviewMimeType, ); const isAudioOnly = reviewMimeType.startsWith("audio/"); particleId = await createParticle.mutateAsync({ path, type: "media", properties: { object_id, mime_type: reviewMimeType, duration_ms: reviewDurationMs, size_bytes, ...(!isAudioOnly && { source: recordingSource === "screen" ? "screen" as const : "camera" as const, }), }, createdByHumanId: userId, }); } if (particleId) { await uploadAttachments(path, particleId); onParticleCreated?.(particleId); } }, [ userId, textContent, reviewBlob, reviewMimeType, reviewDurationMs, recordingSource, createParticle, uploadMedia, uploadAttachments, onParticleCreated ], ); const handleQuotaError = useCallback((err: unknown): boolean => { if (err instanceof QuotaExceededError) { toast.error("Daily message limit reached. Upgrade to Pro to keep sending."); cancel(); return true; } return false; }, [cancel]); // Reply mode: create particle directly under targetPath const onSubmitReply = useEffectEvent(async () => { if (!targetPath || !userId || stepRef.current === "submitting") return; setStepSync("submitting"); try { await createChildParticle(targetPath); cancel(); } catch (err) { if (!handleQuotaError(err)) throw err; } }); // New stream mode: create stream + first child const handleStreamSubmit = useCallback( async (streamName: string, visibleTo: string[]) => { if (!userId || stepRef.current === "submitting") return; setStepSync("submitting"); try { const streamId = await createStream.mutateAsync({ networkId, properties: { name: streamName, }, createdByHumanId: userId, visibleTo, }); const streamChildrenPath = particlePath(networkId, [streamId]); await createChildParticle(streamChildrenPath); cancel(); } catch (err) { if (!handleQuotaError(err)) throw err; } }, [networkId, userId, createStream, createChildParticle, cancel, handleQuotaError], ); // --- Keyboard handling --- useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const currentStep = stepRef.current; if (currentStep === "typing" || currentStep === "configuring" || currentStep === "picking") { if (e.key === "Escape") { e.preventDefault(); cancel(); } return; } const target = e.target as HTMLElement; if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ) { return; } 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(); } else if (e.key === "s" || e.key === "S") { e.preventDefault(); setRecordingSource("screen"); setStepSync("picking"); } else if (e.key === "t" || e.key === "T") { e.preventDefault(); setStepSync("typing"); } break; } case "recording": { if (e.key === "`" && !e.repeat) { // Second tap stops recording (toggle mode) e.preventDefault(); stopRecording(); } else if ((e.key === "s" || e.key === "S") && recordingSourceRef.current === "screen") { // S stops screen recording when main window is focused e.preventDefault(); stopScreenRecording(); } else if (e.key === "q" || e.key === "Q" || e.key === "Escape") { e.preventDefault(); if (recordingSourceRef.current === "screen") { cancelScreenRecording(); } else { cancelRecording(); } cancel(); } break; } case "reviewing": { if (e.key === "q" || e.key === "Q" || e.key === "Escape") { e.preventDefault(); if (recordingSourceRef.current === "screen") { cancelScreenRecording(); } else { cancelRecording(); } cancel(); } else if (e.key === "Enter") { e.preventDefault(); if (targetPath) { onSubmitReply(); } else { setStepSync("configuring"); } } break; } } }; const handleKeyUp = (e: KeyboardEvent) => { if (stepRef.current === "recording" && e.key === "`" && recordingSourceRef.current === "media") { e.preventDefault(); // 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(); recordStartRef.current = 0; } } }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; }, [targetPath, startRecording, stopRecording, cancelRecording, startScreenRecording, stopScreenRecording, cancelScreenRecording, cancel, setStepSync]); // --- Screen source selection handler --- const handleScreenSourceSelected = useCallback( (sourceId: string) => { setStepSync("recording"); startScreenRecording(sourceId); }, [setStepSync, startScreenRecording], ); // --- Render --- if (step === "idle") return null; const handleTextAdvance = targetPath ? onSubmitReply : () => setStepSync("configuring"); return ( <> {step === "picking" && ( )} {(step === "recording" || step === "reviewing") && recordingSource === "media" && ( )} {step === "recording" && recordingSource === "screen" && (
Recording screen…
S {" "} stop Q {" "} cancel
)} {step === "reviewing" && recordingSource === "screen" && reviewBlob && ( )} {step === "typing" && ( )} {!targetPath && step === "configuring" && ( )} {step === "submitting" && (
Sending...
)} ); }