Files
llink/js/desktop/src/features/compose/compose-overlay.tsx
T

839 lines
25 KiB
TypeScript

import { useCallback, useEffect, 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 { KeyHint } from '@/components/key-hint';
import { TextComposeStep } from '@/features/compose/text-compose-step';
import { TaskComposeStep } from '@/features/compose/task-compose-step';
import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
import type { TaskProperties } from '@/api/types';
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';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { platform } from '@/lib/platform';
import { requireDesktop } from '@/lib/platform/desktop-only';
export type ComposeStep =
| 'idle'
| 'picking'
| 'recording'
| 'reviewing'
| 'typing'
| 'task'
| 'configuring'
| 'submitting';
type RecordingSource = 'media' | 'screen';
type PendingArtifact = { type: 'task'; properties: TaskProperties };
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;
}
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,
}: 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);
// Latest props/state for synchronous reads in keyboard handlers.
const stepRef = useRef(step);
const recordStartRef = useRef(0);
const quotaExhaustedRef = useRef(quotaExhausted);
const recordingSourceRef = useRef(recordingSource);
const pendingArtifactRef = useRef<PendingArtifact | null>(null);
useEffect(() => {
quotaExhaustedRef.current = quotaExhausted;
recordingSourceRef.current = recordingSource;
}, [quotaExhausted, 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);
setMediaStream(null);
setReviewBlob(null);
setReviewDurationMs(0);
setReviewMimeType(null);
setRecordingSource('media');
pendingArtifactRef.current = null;
setAttachments((prev) => {
revokeAttachmentThumbnails(prev);
return [];
});
}, [setStepSync, revokeAttachmentThumbnails]);
const addAttachments = useCallback(
async (files: File[]) => {
const available = MAX_ATTACHMENTS - attachments.length;
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;
const pendingArtifact = pendingArtifactRef.current;
if (pendingArtifact) {
particleId = await createParticle.mutateAsync({
path,
type: pendingArtifact.type,
properties: pendingArtifact.properties,
createdByHumanId: userId,
});
} else 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 = useCallback(async () => {
if (!targetPath || !userId || stepRef.current === 'submitting') return;
setStepSync('submitting');
try {
await createChildParticle(targetPath);
cancel();
setTextContent('');
} catch (err) {
if (!handleQuotaError(err)) throw err;
}
}, [
targetPath,
userId,
setStepSync,
createChildParticle,
cancel,
handleQuotaError,
]);
// 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();
setTextContent('');
} catch (err) {
if (!handleQuotaError(err)) throw err;
}
},
[
networkId,
userId,
createStream,
createChildParticle,
cancel,
handleQuotaError,
setStepSync,
],
);
// --- Compose intent handlers ---
// Single source of truth for the step transitions triggered by the user.
// Both the keyboard handler and the intent store dispatch into these so
// guards (quota) and screen-vs-media branching live in one place.
const guardIdle = useCallback((): boolean => {
if (stepRef.current !== 'idle') return false;
if (quotaExhaustedRef.current) {
toast.info(
'Daily message limit reached. Upgrade to Pro to keep sending.',
);
return false;
}
return true;
}, []);
const handleRecordIntent = useCallback(() => {
if (!guardIdle()) return;
recordStartRef.current = Date.now();
setRecordingSource('media');
setStepSync('recording');
startRecording();
}, [guardIdle, setStepSync, startRecording]);
const handleTextIntent = useCallback(() => {
if (!guardIdle()) return;
setStepSync('typing');
}, [guardIdle, setStepSync]);
const handleTaskIntent = useCallback(() => {
if (!guardIdle()) return;
setStepSync('task');
}, [guardIdle, setStepSync]);
// Artifact submit: capture the artifact, then reuse the standard flow —
// reply mode creates it under targetPath, root mode configures a stream
// that will hold it as its first child.
const handleArtifactSubmit = useCallback(
(artifact: PendingArtifact) => {
pendingArtifactRef.current = artifact;
if (targetPath) {
void onSubmitReply();
} else {
setStepSync('configuring');
}
},
[targetPath, onSubmitReply, setStepSync],
);
const handleStopIntent = useCallback(() => {
if (stepRef.current !== 'recording') return;
if (recordingSourceRef.current === 'screen') {
stopScreenRecording();
} else {
stopRecording();
}
}, [stopRecording, stopScreenRecording]);
const handleCancelIntent = useCallback(() => {
const s = stepRef.current;
if (s === 'recording' || s === 'reviewing') {
if (recordingSourceRef.current === 'screen') {
cancelScreenRecording();
} else {
cancelRecording();
}
cancel();
} else if (s === 'typing' || s === 'configuring' || s === 'picking') {
cancel();
}
}, [cancel, cancelRecording, cancelScreenRecording]);
const handleSendIntent = useCallback(() => {
if (stepRef.current !== 'reviewing') return;
if (targetPath) {
onSubmitReply();
} else {
setStepSync('configuring');
}
}, [targetPath, onSubmitReply, setStepSync]);
// --- Intent store subscription ---
// External callers (clickable hints) dispatch via the store; this overlay
// executes the matching handler and clears the intent. Keyboard handlers
// call the same handlers directly without a store round-trip.
const clearIntent = useComposeIntentStore((s) => s.clear);
// Consume fire-and-forget intents from the external store. Reacting in the
// store subscription (not an effect body) keeps these state-updating handlers
// off the render path and avoids an extra dispatch→render bounce.
useEffect(() => {
return useComposeIntentStore.subscribe((state, prev) => {
const intent = state.intent;
if (!intent || intent === prev.intent) return;
switch (intent.kind) {
case 'record':
handleRecordIntent();
break;
case 'text':
handleTextIntent();
break;
case 'task':
handleTaskIntent();
break;
case 'stop':
handleStopIntent();
break;
case 'cancel':
handleCancelIntent();
break;
case 'send':
handleSendIntent();
break;
}
clearIntent();
});
}, [
handleRecordIntent,
handleTextIntent,
handleTaskIntent,
handleStopIntent,
handleCancelIntent,
handleSendIntent,
clearIntent,
]);
// --- Keyboard handling ---
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const currentStep = stepRef.current;
if (
currentStep === 'typing' ||
currentStep === 'task' ||
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 (e.key === '`' && !e.repeat) {
e.preventDefault();
handleRecordIntent();
} else if (e.key === 's' || e.key === 'S') {
e.preventDefault();
if (!guardIdle()) break;
if (!requireDesktop('Screen recording')) break;
setRecordingSource('screen');
setStepSync('picking');
} else if (e.key === 't' || e.key === 'T') {
e.preventDefault();
handleTextIntent();
} else if (e.key === 'd' || e.key === 'D') {
e.preventDefault();
handleTaskIntent();
}
break;
}
case 'recording': {
if (e.key === '`' && !e.repeat) {
// Second tap stops media recording (toggle mode)
e.preventDefault();
handleStopIntent();
} else if (
(e.key === 's' || e.key === 'S') &&
recordingSourceRef.current === 'screen'
) {
// S stops screen recording when main window is focused
e.preventDefault();
handleStopIntent();
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
e.preventDefault();
handleCancelIntent();
}
break;
}
case 'reviewing': {
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
e.preventDefault();
handleCancelIntent();
} else if (e.key === 'Enter') {
e.preventDefault();
handleSendIntent();
}
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
) {
handleStopIntent();
recordStartRef.current = 0;
}
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [
cancel,
setStepSync,
guardIdle,
handleRecordIntent,
handleTextIntent,
handleTaskIntent,
handleStopIntent,
handleCancelIntent,
handleSendIntent,
]);
// --- 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={platform.screenRecord.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">
<KeyHint
keys="S"
onClick={handleStopIntent}
title="Stop screen recording (or press S)"
>
stop
</KeyHint>
<KeyHint
keys="Q"
onClick={handleCancelIntent}
title="Cancel screen recording (or press Q)"
>
cancel
</KeyHint>
</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}
/>
)}
{step === 'task' && (
<TaskComposeStep
networkId={networkId}
onCancel={cancel}
onSubmit={(properties) =>
handleArtifactSubmit({ type: 'task', properties })
}
/>
)}
{!targetPath && step === 'configuring' && (
<ConfigureContainerStep
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>
)}
</>
);
}