315 lines
9.2 KiB
TypeScript
315 lines
9.2 KiB
TypeScript
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
|
import { useRecorder } from "@/features/compose/use-recorder";
|
|
import { particlePath } from "@/lib/particle-path";
|
|
import type { ParticlePath } from "@/lib/particle-path";
|
|
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
|
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";
|
|
|
|
type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
|
|
|
interface ComposeOverlayProps {
|
|
networkId: string;
|
|
// Optional target path for reply mode. If not provided, compose creates a new stream.
|
|
targetPath?: ParticlePath;
|
|
onActiveChange?: (active: boolean) => void;
|
|
}
|
|
|
|
|
|
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,
|
|
}: ComposeOverlayProps) {
|
|
const [step, setStep] = useState<ComposeStep>("idle");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [textContent, setTextContent] = useState("");
|
|
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
|
|
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
|
|
const [reviewDurationMs, setReviewDurationMs] = useState(0);
|
|
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
|
|
|
|
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
|
const userEmail = useAuthStore((s) => s.user?.email);
|
|
const createParticle = useCreateParticle();
|
|
const createStream = useCreateStreamParticle();
|
|
|
|
// Refs for synchronous reads in keyboard handlers
|
|
const stepRef = useRef(step);
|
|
const recordStartRef = useRef(0);
|
|
|
|
const setStepSync = useCallback((next: ComposeStep) => {
|
|
stepRef.current = next;
|
|
setStep(next);
|
|
}, []);
|
|
|
|
// Notify parent when active state changes
|
|
useEffect(() => {
|
|
onActiveChange?.(step !== "idle");
|
|
}, [step, onActiveChange]);
|
|
|
|
const cancel = useCallback(() => {
|
|
setStepSync("idle");
|
|
setError(null);
|
|
setTextContent("");
|
|
setMediaStream(null);
|
|
setReviewBlob(null);
|
|
setReviewDurationMs(0);
|
|
setReviewMimeType(null);
|
|
}, [setStepSync]);
|
|
|
|
const { startRecording, stopRecording, cancelRecording } = useRecorder({
|
|
mode: recordingMode,
|
|
onStreamReady: (stream) => setMediaStream(stream),
|
|
onStreamCleanup: () => setMediaStream(null),
|
|
onFinish: (blob, durationMs, mimeType) => {
|
|
setStepSync("reviewing");
|
|
setReviewBlob(blob);
|
|
setReviewDurationMs(durationMs);
|
|
setReviewMimeType(mimeType);
|
|
},
|
|
onError: (message) => setError(message),
|
|
});
|
|
|
|
// --- 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 createChildParticle = useCallback(
|
|
async (path: ParticlePath) => {
|
|
if (!userEmail) return;
|
|
|
|
if (textContent.trim()) {
|
|
await createParticle.mutateAsync({
|
|
path,
|
|
type: "text",
|
|
properties: { content: textContent },
|
|
createdByEmail: userEmail,
|
|
});
|
|
} else if (reviewBlob && reviewMimeType) {
|
|
const { object_id, size_bytes } = await uploadMedia(
|
|
reviewBlob,
|
|
reviewMimeType,
|
|
);
|
|
|
|
await createParticle.mutateAsync({
|
|
path,
|
|
type: "media",
|
|
properties: {
|
|
object_id,
|
|
mime_type: reviewMimeType,
|
|
duration_ms: reviewDurationMs,
|
|
size_bytes,
|
|
},
|
|
createdByEmail: userEmail,
|
|
});
|
|
}
|
|
},
|
|
[
|
|
userEmail,
|
|
textContent,
|
|
reviewBlob,
|
|
reviewMimeType,
|
|
reviewDurationMs,
|
|
createParticle,
|
|
uploadMedia,
|
|
],
|
|
);
|
|
|
|
// Reply mode: create particle directly under targetPath
|
|
const onSubmitReply = useEffectEvent(async () => {
|
|
if (!targetPath || !userEmail || stepRef.current === "submitting") return;
|
|
setStepSync("submitting");
|
|
await createChildParticle(targetPath);
|
|
cancel();
|
|
});
|
|
|
|
// New stream mode: create stream + first child
|
|
const handleStreamSubmit = useCallback(
|
|
async (streamName: string, visibleTo: string[]) => {
|
|
if (!userEmail || stepRef.current === "submitting") return;
|
|
setStepSync("submitting");
|
|
|
|
const streamId = await createStream.mutateAsync({
|
|
networkId,
|
|
properties: {
|
|
name: streamName,
|
|
status: "open",
|
|
},
|
|
createdByEmail: userEmail,
|
|
visibleTo,
|
|
});
|
|
|
|
const streamChildrenPath = particlePath(networkId, [streamId]);
|
|
await createChildParticle(streamChildrenPath);
|
|
|
|
cancel();
|
|
},
|
|
[networkId, userEmail, createParticle, createChildParticle, cancel],
|
|
);
|
|
|
|
// --- Keyboard handling ---
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const currentStep = stepRef.current;
|
|
|
|
if (currentStep === "typing" || currentStep === "configuring") {
|
|
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 (e.key === "`" && !e.repeat) {
|
|
e.preventDefault();
|
|
recordStartRef.current = Date.now();
|
|
setStepSync("recording");
|
|
startRecording();
|
|
} 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 === "q" || e.key === "Q" || e.key === "Escape") {
|
|
e.preventDefault();
|
|
cancelRecording();
|
|
cancel();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "reviewing": {
|
|
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
|
e.preventDefault();
|
|
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 === "`") {
|
|
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 (Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS) {
|
|
stopRecording();
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
window.addEventListener("keyup", handleKeyUp);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
window.removeEventListener("keyup", handleKeyUp);
|
|
};
|
|
}, [targetPath, startRecording, stopRecording, cancelRecording, cancel, setStepSync]);
|
|
|
|
// --- Render ---
|
|
|
|
if (step === "idle") return null;
|
|
|
|
const handleTextAdvance = targetPath
|
|
? onSubmitReply
|
|
: () => setStepSync("configuring");
|
|
|
|
return (
|
|
<>
|
|
{(step === "recording" || step === "reviewing") && (
|
|
<RecordingOverlay
|
|
step={step}
|
|
mediaStream={mediaStream}
|
|
recordingMode={recordingMode}
|
|
reviewBlob={reviewBlob}
|
|
error={error}
|
|
onClose={cancel}
|
|
/>
|
|
)}
|
|
{step === "typing" && (
|
|
<TextComposeStep
|
|
textContent={textContent}
|
|
onTextChange={setTextContent}
|
|
onAdvance={handleTextAdvance}
|
|
onCancel={cancel}
|
|
/>
|
|
)}
|
|
{!targetPath && step === "configuring" && (
|
|
<ConfigureStreamStep
|
|
networkId={networkId}
|
|
onCancel={cancel}
|
|
onSubmit={handleStreamSubmit}
|
|
/>
|
|
)}
|
|
{step === "submitting" && (
|
|
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
|
<span className="animate-pulse text-sm text-white/60">Sending...</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|