refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FileIcon, Globe, Loader2, Plus, X } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LinkPreviewEntry } from "@/hooks/use-link-metadata";
|
||||
import {
|
||||
AttachmentLightbox,
|
||||
getAttachmentHandler,
|
||||
type AttachmentItem,
|
||||
} from "@/features/attachments/attachment-lightbox";
|
||||
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
file: File;
|
||||
thumbnailUrl?: string;
|
||||
status: "pending" | "uploading" | "uploaded" | "error";
|
||||
}
|
||||
|
||||
interface AttachmentStripProps {
|
||||
attachments: PendingAttachment[];
|
||||
onRemove: (id: string) => void;
|
||||
onAddClick: () => void;
|
||||
linkPreviews?: LinkPreviewEntry[];
|
||||
}
|
||||
|
||||
function pendingToItem(p: PendingAttachment): AttachmentItem {
|
||||
return {
|
||||
id: p.id,
|
||||
filename: p.file.name,
|
||||
mimeType: p.file.type || "application/octet-stream",
|
||||
sizeBytes: p.file.size,
|
||||
source: { kind: "local", file: p.file },
|
||||
};
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function AttachmentThumbnail({
|
||||
attachment,
|
||||
onRemove,
|
||||
onPreview,
|
||||
}: {
|
||||
attachment: PendingAttachment;
|
||||
onRemove: () => void;
|
||||
onPreview?: () => void;
|
||||
}) {
|
||||
const isImage = attachment.file.type.startsWith("image/");
|
||||
const isUploading = attachment.status === "uploading";
|
||||
const isError = attachment.status === "error";
|
||||
const previewable = getAttachmentHandler(attachment.file.type) === "lightbox";
|
||||
|
||||
return (
|
||||
<div
|
||||
role={previewable ? "button" : undefined}
|
||||
tabIndex={previewable ? 0 : undefined}
|
||||
onClick={previewable && onPreview ? onPreview : undefined}
|
||||
className={cn(
|
||||
"group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10",
|
||||
previewable && "cursor-pointer",
|
||||
isError && "ring-1 ring-red-400/50",
|
||||
)}
|
||||
>
|
||||
{isImage && attachment.thumbnailUrl ? (
|
||||
<img
|
||||
src={attachment.thumbnailUrl}
|
||||
alt={attachment.file.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-0.5 px-1">
|
||||
<FileIcon className="size-5 text-white/60" />
|
||||
<span className="max-w-full truncate text-[9px] text-white/50">
|
||||
{attachment.file.name}
|
||||
</span>
|
||||
<span className="text-[9px] text-white/40">
|
||||
{formatFileSize(attachment.file.size)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<Loader2 className="size-4 animate-spin text-white/70" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="absolute right-0.5 top-0.5 hidden rounded-full bg-black/70 p-0.5 text-white/70 hover:text-white group-hover:block"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
||||
if (entry.isLoading) {
|
||||
return (
|
||||
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2">
|
||||
<Skeleton className="h-2 w-16 bg-white/10" />
|
||||
<Skeleton className="h-3 w-24 bg-white/10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entry.metadata) return null;
|
||||
|
||||
const { metadata } = entry;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.electronLink.openExternal(metadata.url)}
|
||||
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-[10px] text-white/40">
|
||||
{metadata.favicon ? (
|
||||
<img
|
||||
src={metadata.favicon}
|
||||
alt=""
|
||||
className="size-3 rounded-sm"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Globe className="size-3" />
|
||||
)}
|
||||
<span className="truncate">{metadata.domain}</span>
|
||||
</div>
|
||||
{metadata.title && (
|
||||
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
|
||||
{metadata.title}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttachmentStrip({
|
||||
attachments,
|
||||
onRemove,
|
||||
onAddClick,
|
||||
linkPreviews,
|
||||
}: AttachmentStripProps) {
|
||||
const hasLinks = linkPreviews && linkPreviews.length > 0;
|
||||
|
||||
// Lightbox state — only previewable attachments go in.
|
||||
const previewable = useMemo(
|
||||
() => attachments.filter((a) => getAttachmentHandler(a.file.type) === "lightbox"),
|
||||
[attachments],
|
||||
);
|
||||
const items = useMemo(() => previewable.map(pendingToItem), [previewable]);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
if (attachments.length === 0 && !hasLinks) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
{attachments.map((a) => (
|
||||
<AttachmentThumbnail
|
||||
key={a.id}
|
||||
attachment={a}
|
||||
onRemove={() => onRemove(a.id)}
|
||||
onPreview={() => {
|
||||
const idx = previewable.indexOf(a);
|
||||
if (idx >= 0) setOpenIndex(idx);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{linkPreviews?.map((entry) => (
|
||||
<LinkPreviewThumbnail key={entry.url} entry={entry} />
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddClick();
|
||||
}}
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
|
||||
{items.length > 0 && (
|
||||
<AttachmentLightbox
|
||||
items={items}
|
||||
openIndex={openIndex}
|
||||
onOpenChange={setOpenIndex}
|
||||
onRemove={(item) => onRemove(item.id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNetworkUsage } from "@/hooks/use-network-usage";
|
||||
import { useIsNetworkAdmin, useNetwork } from "@/hooks/use-networks";
|
||||
|
||||
interface ComposeQuotaIndicatorProps {
|
||||
networkId: string;
|
||||
}
|
||||
|
||||
const SHOW_PROGRESS_AT_FRACTION = 0.7;
|
||||
|
||||
/**
|
||||
* Surfaces freemium quota state near compose:
|
||||
* - Nothing below 70% used (avoid nagging).
|
||||
* - A subtle progress pill between 70% and the limit.
|
||||
* - A locked banner with an upgrade CTA once the limit is hit.
|
||||
*
|
||||
* Pro networks and any network still loading usage render nothing.
|
||||
*/
|
||||
export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: usage } = useNetworkUsage(networkId);
|
||||
const isAdmin = useIsNetworkAdmin(networkId);
|
||||
const network = useNetwork(networkId);
|
||||
|
||||
if (!usage || usage.limit == null) return null;
|
||||
|
||||
const fraction = usage.used / usage.limit;
|
||||
const exhausted = usage.used >= usage.limit;
|
||||
|
||||
if (exhausted) {
|
||||
return (
|
||||
<div className="pointer-events-auto flex max-w-md flex-col items-center gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 text-center shadow-lg backdrop-blur">
|
||||
<div className="text-sm font-medium">
|
||||
{isAdmin
|
||||
? `You've reached today's ${usage.limit}-message limit`
|
||||
: `This network reached today's ${usage.limit}-message limit`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Resets {formatResetRelative(usage.reset_at)} ({formatResetAbsolute(usage.reset_at)})
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => navigate(`/${networkId}/settings?section=billing`)}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Ask{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{network?.admin_human.email_prefix ?? "your admin"}
|
||||
</span>{" "}
|
||||
to upgrade to Pro
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fraction < SHOW_PROGRESS_AT_FRACTION) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-3 rounded-full border border-border bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur"
|
||||
title={`Resets ${formatResetRelative(usage.reset_at)} at ${formatResetAbsolute(usage.reset_at)}`}
|
||||
>
|
||||
<span className="tabular-nums">
|
||||
{usage.used}/{usage.limit} today
|
||||
</span>
|
||||
<Progress value={fraction * 100} className="h-1 w-24" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatResetRelative(resetAt: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = resetAt.getTime() - now.getTime();
|
||||
const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000)));
|
||||
if (hours < 1) return "soon";
|
||||
if (hours === 1) return "in 1 hour";
|
||||
return `in ${hours} hours`;
|
||||
}
|
||||
|
||||
function formatResetAbsolute(resetAt: Date): string {
|
||||
// Shows the user their local wall-clock time for the UTC-midnight reset,
|
||||
// so a user in UTC-8 sees "4:00 PM" instead of a relative hint alone.
|
||||
return resetAt.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNetworks } from "@/hooks/use-networks";
|
||||
import { cn, removeDuplicates } from "@/lib/utils";
|
||||
import { metaKey } from "@/lib/platform";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { generateRandomName } from "@/lib/random-name";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface ConfigureStreamStepProps {
|
||||
networkId: string | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (streamName: string, visibleTo: string[]) => void;
|
||||
}
|
||||
|
||||
export function ConfigureStreamStep({
|
||||
networkId,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ConfigureStreamStepProps) {
|
||||
const { data: networks } = useNetworks();
|
||||
const network = networks?.find((n) => n.id === networkId);
|
||||
|
||||
const [name, setName] = useState(() => generateRandomName());
|
||||
const [everyone, setEveryone] = useState(true);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const members = (network?.humans ?? []).filter((h) => h.id !== userId);
|
||||
|
||||
const toggleMember = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buildVisibleTo = useCallback((): string[] => {
|
||||
if (everyone && networkId) return [`network:${networkId}`];
|
||||
|
||||
return Array.from(removeDuplicates([...selectedIds, userId].filter(Boolean) as string[])).map((id) => `human:${id}`);
|
||||
}, [everyone, networkId, selectedIds, userId]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!name.trim() || !networkId) return;
|
||||
onSubmit(name.trim(), buildVisibleTo());
|
||||
}, [name, networkId, onSubmit, buildVisibleTo]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
|
||||
case "Enter":
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
[onCancel, handleSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
||||
{/* Stream name */}
|
||||
<div>
|
||||
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
placeholder="Give it a name..."
|
||||
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
|
||||
<div className="rounded-md border border-white/10">
|
||||
{/* Everyone in network */}
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => setEveryone((prev) => !prev)}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
|
||||
"text-white/70 hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={everyone}
|
||||
onCheckedChange={(checked) => setEveryone(checked === true)}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="font-medium">Everyone in network</span>
|
||||
</div>
|
||||
|
||||
{/* Per-member selection */}
|
||||
{!everyone && members.length > 0 && (
|
||||
<ScrollArea className="max-h-48">
|
||||
<div className="space-y-0.5 p-1">
|
||||
{members.map((member, index) => {
|
||||
const isSelected = selectedIds.has(member.id);
|
||||
const initials = member.email_prefix
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
role="button"
|
||||
onClick={() => toggleMember(member.id)}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors",
|
||||
"text-white/70 hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="flex-1 truncate">
|
||||
{member.email_prefix}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hints */}
|
||||
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-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">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
cancel
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
{metaKey}+Enter
|
||||
</kbd>{" "}
|
||||
create
|
||||
</span>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||
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";
|
||||
|
||||
interface RecordingOverlayProps {
|
||||
step: "recording" | "reviewing";
|
||||
mediaStream: MediaStream | null;
|
||||
recordingMode: RecordingMode;
|
||||
reviewBlob: Blob | null;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
attachments: PendingAttachment[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onAddFiles: () => void;
|
||||
isDragging: boolean;
|
||||
dropZoneProps: {
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
};
|
||||
/** Mirror the video horizontally. Defaults to true (selfie-view for webcam). */
|
||||
mirror?: boolean;
|
||||
/** How video fills its container. Defaults to "cover". Use "contain" for screen recordings. */
|
||||
objectFit?: "cover" | "contain";
|
||||
}
|
||||
|
||||
function RecordingTimer() {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setElapsed((prev) => prev + 1);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const minutes = Math.floor(elapsed / 60);
|
||||
const seconds = elapsed % 60;
|
||||
const display = `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
||||
<span className="font-mono text-sm text-white/80">{display}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewPlayback({
|
||||
blob,
|
||||
isVideo,
|
||||
mirror = true,
|
||||
objectFit = "cover",
|
||||
}: {
|
||||
blob: Blob;
|
||||
isVideo: boolean;
|
||||
mirror?: boolean;
|
||||
objectFit?: "cover" | "contain";
|
||||
}) {
|
||||
const urlRef = useRef<string | null>(null);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
urlRef.current = url;
|
||||
setObjectUrl(url);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
urlRef.current = null;
|
||||
};
|
||||
}, [blob]);
|
||||
|
||||
if (!objectUrl) return null;
|
||||
|
||||
if (isVideo) {
|
||||
return (
|
||||
<video
|
||||
src={objectUrl}
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
className={`absolute inset-0 h-full w-full ${objectFit === "contain" ? "object-contain" : "object-cover"}${mirror ? " -scale-x-100" : ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<audio
|
||||
ref={(el) => {
|
||||
audioElRef.current = el;
|
||||
setAudioEl(el);
|
||||
}}
|
||||
src={objectUrl}
|
||||
autoPlay
|
||||
loop
|
||||
/>
|
||||
{audioSource ? (
|
||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
||||
) : (
|
||||
<span className="text-sm text-white/60">Playing back audio...</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecordingOverlay({
|
||||
step,
|
||||
mediaStream,
|
||||
recordingMode,
|
||||
reviewBlob,
|
||||
error,
|
||||
onClose,
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
onAddFiles,
|
||||
isDragging,
|
||||
dropZoneProps,
|
||||
mirror = true,
|
||||
objectFit = "cover",
|
||||
}: RecordingOverlayProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const recordingAudioSource = useAudioSource(mediaStream ?? null);
|
||||
|
||||
// Set video srcObject for live preview
|
||||
useEffect(() => {
|
||||
if (videoRef.current && mediaStream && recordingMode === "video") {
|
||||
videoRef.current.srcObject = mediaStream;
|
||||
}
|
||||
}, [mediaStream, recordingMode]);
|
||||
|
||||
// Auto-close after error with a brief delay
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
const timeout = setTimeout(onClose, 1500);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [error, onClose]);
|
||||
|
||||
const isReviewing = step === "reviewing";
|
||||
const isRecording = step === "recording";
|
||||
const isLoading = isRecording && !mediaStream;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90",
|
||||
isReviewing && isDragging && "ring-2 ring-inset ring-white/30",
|
||||
)}
|
||||
{...(isReviewing ? dropZoneProps : {})}
|
||||
>
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="z-10 flex flex-col items-center gap-2">
|
||||
<span className="animate-pulse text-sm text-white/60">
|
||||
{recordingMode === "video"
|
||||
? "Starting camera..."
|
||||
: "Starting mic..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Camera preview (video mode, recording) */}
|
||||
{isRecording && recordingMode === "video" && mediaStream && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
className="absolute inset-0 h-full w-full -scale-x-100 object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Review playback */}
|
||||
{isReviewing && reviewBlob && (
|
||||
<ReviewPlayback
|
||||
blob={reviewBlob}
|
||||
isVideo={recordingMode === "video"}
|
||||
mirror={mirror}
|
||||
objectFit={objectFit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bottom gradient scrim for keyboard hint readability */}
|
||||
{(isRecording || isReviewing) && !isLoading && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
)}
|
||||
|
||||
{/* Top center: recording indicator */}
|
||||
<div className="absolute top-8 z-10">
|
||||
{isRecording && !isLoading ? (
|
||||
<RecordingTimer />
|
||||
) : isReviewing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-white/80">Review recording</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Bottom center: audio level bars (recording with active stream) */}
|
||||
{isRecording && recordingAudioSource && (
|
||||
<div className="z-10 absolute bottom-15">
|
||||
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom center: keyboard hints */}
|
||||
{isRecording && !isLoading && (
|
||||
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
|
||||
<span>
|
||||
Release{" "}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
`
|
||||
</kbd>{" "}
|
||||
to review
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" or "}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Q
|
||||
</kbd>{" "}
|
||||
to cancel
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReviewing && (
|
||||
<div className="absolute bottom-4 z-10 flex flex-col items-center gap-3">
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-4">
|
||||
<AttachmentStrip
|
||||
attachments={attachments}
|
||||
onRemove={onRemoveAttachment}
|
||||
onAddClick={onAddFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="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">
|
||||
Enter
|
||||
</kbd>{" "}
|
||||
next
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>{" or "}
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Q
|
||||
</kbd>{" "}
|
||||
to cancel
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddFiles();
|
||||
}}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
attach
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="z-10 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { TextEditor } from "@/features/compose/text-editor";
|
||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||
|
||||
interface TextComposeStepProps {
|
||||
textContent: string;
|
||||
onTextChange: (text: string) => void;
|
||||
onAdvance: () => void;
|
||||
onCancel: () => void;
|
||||
attachments: PendingAttachment[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onAddFiles: () => void;
|
||||
isDragging: boolean;
|
||||
dropZoneProps: {
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function TextComposeStep({
|
||||
textContent,
|
||||
onTextChange,
|
||||
onAdvance,
|
||||
onCancel,
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
onAddFiles,
|
||||
isDragging,
|
||||
dropZoneProps,
|
||||
}: TextComposeStepProps) {
|
||||
return (
|
||||
<TextEditor
|
||||
textContent={textContent}
|
||||
onTextChange={onTextChange}
|
||||
onSubmit={onAdvance}
|
||||
onCancel={onCancel}
|
||||
submitHint="next"
|
||||
attachmentProps={{
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
onAddFiles,
|
||||
isDragging,
|
||||
dropZoneProps,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { metaKey } from "@/lib/platform";
|
||||
import { useAllLinkMetadata } from "@/hooks/use-link-metadata";
|
||||
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
|
||||
export interface TextEditorAttachmentProps {
|
||||
attachments: PendingAttachment[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onAddFiles: () => void;
|
||||
isDragging: boolean;
|
||||
dropZoneProps: {
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface TextEditorProps {
|
||||
textContent: string;
|
||||
onTextChange: (text: string) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel: () => void;
|
||||
/** Label on the ⌘+Enter hint. Defaults to "next". */
|
||||
submitHint?: string;
|
||||
/** When omitted, the editor renders without attachment support (no attach button, no drop zone, no strip). */
|
||||
attachmentProps?: TextEditorAttachmentProps;
|
||||
}
|
||||
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function getImmersiveTextStyle(length: number) {
|
||||
if (length < 70) return { size: "text-5xl", weight: "font-semibold" };
|
||||
if (length < 130) return { size: "text-3xl", weight: "font-semibold" };
|
||||
return { size: "text-2xl", weight: "font-normal" };
|
||||
}
|
||||
|
||||
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
|
||||
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
|
||||
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
|
||||
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
|
||||
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
|
||||
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic text-white">{children}</em>,
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ className, children, ...props }) => {
|
||||
const isBlock = className?.startsWith("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={cn(className, "text-sm")} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
|
||||
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-4 border-white/10" />,
|
||||
};
|
||||
|
||||
function MarkdownPreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden break-words">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextEditor({
|
||||
textContent,
|
||||
onTextChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitHint = "next",
|
||||
attachmentProps,
|
||||
}: TextEditorProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [forceCardMode, setForceCardMode] = useState(false);
|
||||
|
||||
const [debouncedText, setDebouncedText] = useState(textContent);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedText(textContent), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [textContent]);
|
||||
const linkPreviews = useAllLinkMetadata(debouncedText);
|
||||
|
||||
const attachmentCount = attachmentProps?.attachments.length ?? 0;
|
||||
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
|
||||
const immersive =
|
||||
textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewMode) {
|
||||
const t = setTimeout(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.selectionStart = el.selectionEnd = el.value.length;
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [previewMode, forceCardMode, immersive]);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (textContent.trim()) onSubmit();
|
||||
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setForceCardMode(true);
|
||||
}
|
||||
},
|
||||
[onCancel, onSubmit, textContent],
|
||||
);
|
||||
|
||||
const strip = attachmentProps && (
|
||||
<AttachmentStrip
|
||||
attachments={attachmentProps.attachments}
|
||||
onRemove={attachmentProps.onRemoveAttachment}
|
||||
onAddClick={attachmentProps.onAddFiles}
|
||||
linkPreviews={linkPreviews}
|
||||
/>
|
||||
);
|
||||
|
||||
const keyboardHints = (
|
||||
<div className="absolute bottom-4 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">
|
||||
Esc
|
||||
</kbd>{" "}
|
||||
cancel
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
{metaKey}+Enter
|
||||
</kbd>{" "}
|
||||
{submitHint}
|
||||
</span>
|
||||
{immersive && (
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
{metaKey}+M
|
||||
</kbd>{" "}
|
||||
markdown
|
||||
</span>
|
||||
)}
|
||||
{attachmentProps && (
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
attachmentProps.onAddFiles();
|
||||
}}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
attach
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const dropZoneProps = attachmentProps?.dropZoneProps;
|
||||
const isDragging = attachmentProps?.isDragging ?? false;
|
||||
|
||||
if (immersive) {
|
||||
const style = getImmersiveTextStyle(textContent.length);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||
isDragging && "ring-2 ring-inset ring-white/30",
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center justify-center gap-6 px-6">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={textContent}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
className={cn(
|
||||
"w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
{keyboardHints}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||
isDragging && "ring-2 ring-inset ring-white/30",
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewMode(false)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
!previewMode
|
||||
? "bg-white/15 text-white"
|
||||
: "text-white/40 hover:text-white/60",
|
||||
)}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewMode(true)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
previewMode
|
||||
? "bg-white/15 text-white"
|
||||
: "text-white/40 hover:text-white/60",
|
||||
)}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{previewMode ? (
|
||||
<MarkdownPreview content={textContent} />
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={textContent}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message... (markdown supported)"
|
||||
className="min-h-0 flex-1 resize-none border-none bg-transparent font-mono text-sm leading-relaxed text-white placeholder-white/40 outline-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasEnrichments && strip && (
|
||||
<div className="shrink-0 border-t border-white/10 pt-3">
|
||||
{strip}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{keyboardHints}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
||||
|
||||
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
||||
const VIDEO_FALLBACK_MIME = "video/webm";
|
||||
const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus";
|
||||
const AUDIO_FALLBACK_MIME = "audio/webm";
|
||||
|
||||
function getMediaMime(mode: "video" | "audio"): string {
|
||||
if (mode === "audio") {
|
||||
return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME)
|
||||
? AUDIO_PREFERRED_MIME
|
||||
: AUDIO_FALLBACK_MIME;
|
||||
}
|
||||
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
|
||||
? VIDEO_PREFERRED_MIME
|
||||
: VIDEO_FALLBACK_MIME;
|
||||
}
|
||||
|
||||
interface UseRecorderOptions {
|
||||
mode: RecordingMode;
|
||||
micDeviceId?: string;
|
||||
cameraDeviceId?: string;
|
||||
onStreamReady: (stream: MediaStream) => void;
|
||||
onStreamCleanup: () => void;
|
||||
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
function buildConstraints(
|
||||
mode: RecordingMode,
|
||||
micDeviceId: string | undefined,
|
||||
cameraDeviceId: string | undefined,
|
||||
): MediaStreamConstraints {
|
||||
const audio: MediaTrackConstraints | boolean = micDeviceId
|
||||
? { deviceId: { exact: micDeviceId } }
|
||||
: true;
|
||||
|
||||
if (mode === "audio") return { audio };
|
||||
|
||||
const video: MediaTrackConstraints = cameraDeviceId
|
||||
? { deviceId: { exact: cameraDeviceId }, aspectRatio: { ideal: 4 / 3 } }
|
||||
: { aspectRatio: { ideal: 4 / 3 } };
|
||||
return { audio, video };
|
||||
}
|
||||
|
||||
async function getStreamWithFallback(
|
||||
constraints: MediaStreamConstraints,
|
||||
hasDeviceId: boolean,
|
||||
): Promise<MediaStream> {
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (err) {
|
||||
// When a saved device has been unplugged, `{ exact }` throws
|
||||
// OverconstrainedError. Fall back to the system default so users
|
||||
// aren't blocked from recording.
|
||||
if (
|
||||
hasDeviceId &&
|
||||
err instanceof Error &&
|
||||
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
|
||||
) {
|
||||
const relaxed: MediaStreamConstraints = {
|
||||
audio: typeof constraints.audio === "object" ? true : constraints.audio,
|
||||
...(constraints.video !== undefined && {
|
||||
video:
|
||||
typeof constraints.video === "object"
|
||||
? { aspectRatio: { ideal: 4 / 3 } }
|
||||
: constraints.video,
|
||||
}),
|
||||
};
|
||||
return navigator.mediaDevices.getUserMedia(relaxed);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
|
||||
* about application state. The consumer provides callbacks for all outputs.
|
||||
*/
|
||||
export function useRecorder({
|
||||
mode,
|
||||
micDeviceId,
|
||||
cameraDeviceId,
|
||||
onStreamReady,
|
||||
onStreamCleanup,
|
||||
onFinish,
|
||||
onError,
|
||||
}: UseRecorderOptions) {
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
|
||||
// Refs to avoid stale closures in MediaRecorder event handlers
|
||||
const onStreamCleanupRef = useRef(onStreamCleanup);
|
||||
const onFinishRef = useRef(onFinish);
|
||||
const onErrorRef = useRef(onError);
|
||||
useEffect(() => {
|
||||
onStreamCleanupRef.current = onStreamCleanup;
|
||||
onFinishRef.current = onFinish;
|
||||
onErrorRef.current = onError;
|
||||
});
|
||||
|
||||
const stopTracks = useCallback(() => {
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
recorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
onStreamCleanupRef.current();
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const constraints = buildConstraints(mode, micDeviceId, cameraDeviceId);
|
||||
const hasDeviceId = Boolean(micDeviceId || cameraDeviceId);
|
||||
const mediaStream = await getStreamWithFallback(constraints, hasDeviceId);
|
||||
|
||||
streamRef.current = mediaStream;
|
||||
onStreamReady(mediaStream);
|
||||
chunksRef.current = [];
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
const mime = getMediaMime(mode);
|
||||
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
|
||||
recorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const durationMs = Date.now() - startTimeRef.current;
|
||||
const blob = new Blob(chunksRef.current, { type: mime });
|
||||
stopTracks();
|
||||
|
||||
if (blob.size > 0) {
|
||||
onFinishRef.current(blob, durationMs, mime);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
} catch (err) {
|
||||
stopTracks();
|
||||
onErrorRef.current(
|
||||
err instanceof Error ? err.message : "Failed to start recording",
|
||||
);
|
||||
}
|
||||
}, [mode, micDeviceId, cameraDeviceId, onStreamReady, stopTracks]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.ondataavailable = null;
|
||||
recorderRef.current.onstop = null;
|
||||
if (recorderRef.current.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}
|
||||
stopTracks();
|
||||
}, [stopTracks]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => stopTracks();
|
||||
}, [stopTracks]);
|
||||
|
||||
return { startRecording, stopRecording, cancelRecording };
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
||||
const VIDEO_FALLBACK_MIME = "video/webm";
|
||||
|
||||
function getScreenMime(): string {
|
||||
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
|
||||
? VIDEO_PREFERRED_MIME
|
||||
: VIDEO_FALLBACK_MIME;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseScreenRecorderOptions {
|
||||
micDeviceId?: string;
|
||||
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
async function getMicStream(
|
||||
micDeviceId: string | undefined,
|
||||
): Promise<MediaStream> {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true,
|
||||
};
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (err) {
|
||||
if (
|
||||
micDeviceId &&
|
||||
err instanceof Error &&
|
||||
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
|
||||
) {
|
||||
return navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages screen recording via Electron's desktopCapturer.
|
||||
* Captures screen video + mic audio.
|
||||
*/
|
||||
export function useScreenRecorder({
|
||||
micDeviceId,
|
||||
onFinish,
|
||||
onError,
|
||||
}: UseScreenRecorderOptions) {
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
const micStreamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const cleanupIpcRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const onFinishRef = useRef(onFinish);
|
||||
const onErrorRef = useRef(onError);
|
||||
useEffect(() => {
|
||||
onFinishRef.current = onFinish;
|
||||
onErrorRef.current = onError;
|
||||
});
|
||||
|
||||
const stopAllTracks = useCallback(() => {
|
||||
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
micStreamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
micStreamRef.current = null;
|
||||
recorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
cleanupIpcRef.current?.();
|
||||
cleanupIpcRef.current = null;
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(
|
||||
async (sourceId: string) => {
|
||||
try {
|
||||
// 1. Screen video
|
||||
const screenStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: "desktop",
|
||||
chromeMediaSourceId: sourceId,
|
||||
},
|
||||
} as MediaTrackConstraints,
|
||||
});
|
||||
screenStreamRef.current = screenStream;
|
||||
|
||||
// 2. Mic audio
|
||||
const micStream = await getMicStream(micDeviceId);
|
||||
micStreamRef.current = micStream;
|
||||
|
||||
// 3. Combine screen video + mic audio
|
||||
const combined = new MediaStream([
|
||||
...screenStream.getVideoTracks(),
|
||||
...micStream.getAudioTracks(),
|
||||
]);
|
||||
|
||||
chunksRef.current = [];
|
||||
startTimeRef.current = Date.now();
|
||||
|
||||
const mime = getScreenMime();
|
||||
const recorder = new MediaRecorder(combined, { mimeType: mime });
|
||||
recorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const durationMs = Date.now() - startTimeRef.current;
|
||||
const blob = new Blob(chunksRef.current, { type: mime });
|
||||
stopAllTracks();
|
||||
window.electronScreen.stopRecordingWindow();
|
||||
|
||||
if (blob.size > 0) {
|
||||
onFinishRef.current(blob, durationMs, mime);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start(1000);
|
||||
|
||||
// 4. Show floating control window
|
||||
window.electronScreen.startRecordingWindow();
|
||||
|
||||
// 5. Listen for stop from floating window
|
||||
cleanupIpcRef.current = window.electronScreen.onStopRequested(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
stopAllTracks();
|
||||
window.electronScreen.stopRecordingWindow();
|
||||
onErrorRef.current(
|
||||
err instanceof Error ? err.message : "Failed to start screen recording",
|
||||
);
|
||||
}
|
||||
},
|
||||
[micDeviceId, stopAllTracks],
|
||||
);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.ondataavailable = null;
|
||||
recorderRef.current.onstop = null;
|
||||
if (recorderRef.current.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}
|
||||
stopAllTracks();
|
||||
window.electronScreen.stopRecordingWindow();
|
||||
}, [stopAllTracks]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopAllTracks();
|
||||
window.electronScreen.stopRecordingWindow();
|
||||
};
|
||||
}, [stopAllTracks]);
|
||||
|
||||
return { startRecording, stopRecording, cancelRecording };
|
||||
}
|
||||
Reference in New Issue
Block a user