634 lines
21 KiB
TypeScript
634 lines
21 KiB
TypeScript
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<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 [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
|
const [recordingSource, setRecordingSource] = useState<RecordingSource>("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" && (
|
|
<ScreenSourcePicker
|
|
title="Record your screen"
|
|
confirmLabel="Record"
|
|
getSources={window.electronScreen.getScreenSources}
|
|
onSelect={handleScreenSourceSelected}
|
|
onCancel={cancel}
|
|
/>
|
|
)}
|
|
{(step === "recording" || step === "reviewing") && recordingSource === "media" && (
|
|
<RecordingOverlay
|
|
step={step}
|
|
mediaStream={mediaStream}
|
|
recordingMode={recordingMode}
|
|
reviewBlob={reviewBlob}
|
|
error={error}
|
|
onClose={cancel}
|
|
attachments={attachments}
|
|
onRemoveAttachment={removeAttachment}
|
|
onAddFiles={openFilePicker}
|
|
isDragging={isDragging}
|
|
dropZoneProps={dropZoneProps}
|
|
mirror={true}
|
|
objectFit="cover"
|
|
/>
|
|
)}
|
|
{step === "recording" && recordingSource === "screen" && (
|
|
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
|
<div className="absolute top-8 z-10">
|
|
<div className="flex items-center gap-2">
|
|
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
|
<span className="text-sm text-white/80">Recording screen…</span>
|
|
</div>
|
|
</div>
|
|
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
|
|
<span>
|
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
S
|
|
</kbd>{" "}
|
|
stop
|
|
</span>
|
|
<span>
|
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
Q
|
|
</kbd>{" "}
|
|
cancel
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{step === "reviewing" && recordingSource === "screen" && reviewBlob && (
|
|
<RecordingOverlay
|
|
step="reviewing"
|
|
mediaStream={null}
|
|
recordingMode="video"
|
|
reviewBlob={reviewBlob}
|
|
error={error}
|
|
onClose={cancel}
|
|
attachments={attachments}
|
|
onRemoveAttachment={removeAttachment}
|
|
onAddFiles={openFilePicker}
|
|
isDragging={isDragging}
|
|
dropZoneProps={dropZoneProps}
|
|
mirror={false}
|
|
objectFit="contain"
|
|
/>
|
|
)}
|
|
{step === "typing" && (
|
|
<TextComposeStep
|
|
textContent={textContent}
|
|
onTextChange={setTextContent}
|
|
onAdvance={handleTextAdvance}
|
|
onCancel={cancel}
|
|
attachments={attachments}
|
|
onRemoveAttachment={removeAttachment}
|
|
onAddFiles={openFilePicker}
|
|
isDragging={isDragging}
|
|
dropZoneProps={dropZoneProps}
|
|
/>
|
|
)}
|
|
{!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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|