refactor: organize desktop vs. mobile into separate folders

This commit is contained in:
Arjun Patel
2026-04-29 08:42:56 -07:00
parent 3d9fe67936
commit 3a11a82cd3
194 changed files with 213 additions and 213 deletions
@@ -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 };
}