infra: add linting and formatting for js projects (#230)
* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
This commit was merged in pull request #230.
This commit is contained in:
@@ -1,34 +1,48 @@
|
||||
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";
|
||||
import { useComposeIntentStore } from "@/stores/compose-intent-store";
|
||||
import { platform } from "@/lib/platform";
|
||||
import { requireDesktop } from "@/lib/platform/desktop-only";
|
||||
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 { 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';
|
||||
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" | "configuring" | "submitting";
|
||||
export type ComposeStep =
|
||||
| 'idle'
|
||||
| 'picking'
|
||||
| 'recording'
|
||||
| 'reviewing'
|
||||
| 'typing'
|
||||
| 'configuring'
|
||||
| 'submitting';
|
||||
|
||||
type RecordingSource = "media" | "screen";
|
||||
type RecordingSource = 'media' | 'screen';
|
||||
|
||||
interface ComposeOverlayProps {
|
||||
networkId: string;
|
||||
@@ -41,7 +55,6 @@ interface ComposeOverlayProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
const HOLD_THRESHOLD_MS = 250;
|
||||
|
||||
/**
|
||||
@@ -56,16 +69,17 @@ export function ComposeOverlay({
|
||||
onParticleCreated,
|
||||
disabled,
|
||||
}: ComposeOverlayProps) {
|
||||
const [step, setStep] = useState<ComposeStep>("idle");
|
||||
const [step, setStep] = useState<ComposeStep>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [textContent, setTextContent] = useState("");
|
||||
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 [recordingSource, setRecordingSource] =
|
||||
useState<RecordingSource>('media');
|
||||
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const savedMic = useMediaDevicesStore((s) => s.mic);
|
||||
@@ -80,87 +94,95 @@ export function ComposeOverlay({
|
||||
const invalidateUsage = useInvalidateNetworkUsage();
|
||||
const quotaExhausted = isUsageExhausted(usage);
|
||||
|
||||
// Refs for synchronous reads in keyboard handlers
|
||||
// Latest props/state 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;
|
||||
useEffect(() => {
|
||||
disabledRef.current = disabled;
|
||||
quotaExhaustedRef.current = quotaExhausted;
|
||||
recordingSourceRef.current = recordingSource;
|
||||
}, [disabled, quotaExhausted, recordingSource]);
|
||||
|
||||
const setStepSync = useCallback((next: ComposeStep) => {
|
||||
stepRef.current = next;
|
||||
setStep(next);
|
||||
}, []);
|
||||
|
||||
useSuspendPlayback(step !== "idle", "compose");
|
||||
useSuspendPlayback(step !== 'idle', 'compose');
|
||||
|
||||
// Notify parent when active state changes
|
||||
useEffect(() => {
|
||||
onActiveChange?.(step !== "idle");
|
||||
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") {
|
||||
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 revokeAttachmentThumbnails = useCallback(
|
||||
(items: PendingAttachment[]) => {
|
||||
for (const a of items) {
|
||||
if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setStepSync("idle");
|
||||
setStepSync('idle');
|
||||
setError(null);
|
||||
setTextContent("");
|
||||
setMediaStream(null);
|
||||
setReviewBlob(null);
|
||||
setReviewDurationMs(0);
|
||||
setReviewMimeType(null);
|
||||
setRecordingSource("media");
|
||||
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 addAttachments = useCallback(
|
||||
async (files: File[]) => {
|
||||
const available = MAX_ATTACHMENTS - attachments.length;
|
||||
if (available <= 0) {
|
||||
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`);
|
||||
return;
|
||||
}
|
||||
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 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) => {
|
||||
@@ -172,7 +194,7 @@ export function ComposeOverlay({
|
||||
|
||||
const { openFilePicker, isDragging, dropZoneProps } = useFileInput({
|
||||
onFilesSelected: addAttachments,
|
||||
enabled: step === "typing" || step === "reviewing",
|
||||
enabled: step === 'typing' || step === 'reviewing',
|
||||
});
|
||||
|
||||
const { startRecording, stopRecording, cancelRecording } = useRecorder({
|
||||
@@ -182,7 +204,7 @@ export function ComposeOverlay({
|
||||
onStreamReady: (stream) => setMediaStream(stream),
|
||||
onStreamCleanup: () => setMediaStream(null),
|
||||
onFinish: (blob, durationMs, mimeType) => {
|
||||
setStepSync("reviewing");
|
||||
setStepSync('reviewing');
|
||||
setReviewBlob(blob);
|
||||
setReviewDurationMs(durationMs);
|
||||
setReviewMimeType(mimeType);
|
||||
@@ -197,7 +219,7 @@ export function ComposeOverlay({
|
||||
} = useScreenRecorder({
|
||||
micDeviceId,
|
||||
onFinish: (blob, durationMs, mimeType) => {
|
||||
setStepSync("reviewing");
|
||||
setStepSync('reviewing');
|
||||
setReviewBlob(blob);
|
||||
setReviewDurationMs(durationMs);
|
||||
setReviewMimeType(mimeType);
|
||||
@@ -212,7 +234,7 @@ export function ComposeOverlay({
|
||||
|
||||
const uploadMedia = useCallback(
|
||||
async (blob: Blob, mimeType: string) => {
|
||||
const ext = "webm";
|
||||
const ext = 'webm';
|
||||
const fileName = `recording-${Date.now()}.${ext}`;
|
||||
|
||||
const { object_id, upload_url, upload_headers } =
|
||||
@@ -224,7 +246,7 @@ export function ComposeOverlay({
|
||||
});
|
||||
|
||||
await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
headers: upload_headers,
|
||||
body: blob,
|
||||
});
|
||||
@@ -242,12 +264,12 @@ export function ComposeOverlay({
|
||||
await apiClient.prepareUpload({
|
||||
network_id: networkId,
|
||||
name: file.name,
|
||||
content_type: file.type || "application/octet-stream",
|
||||
content_type: file.type || 'application/octet-stream',
|
||||
content_length: file.size,
|
||||
});
|
||||
|
||||
await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
headers: upload_headers,
|
||||
body: file,
|
||||
});
|
||||
@@ -270,7 +292,9 @@ export function ComposeOverlay({
|
||||
attachments.map(async (attachment) => {
|
||||
setAttachments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === attachment.id ? { ...a, status: "uploading" as const } : a,
|
||||
a.id === attachment.id
|
||||
? { ...a, status: 'uploading' as const }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -278,11 +302,11 @@ export function ComposeOverlay({
|
||||
|
||||
await createParticle.mutateAsync({
|
||||
path: childrenPath,
|
||||
type: "file",
|
||||
type: 'file',
|
||||
properties: {
|
||||
object_id,
|
||||
filename: attachment.file.name,
|
||||
mime_type: attachment.file.type || "application/octet-stream",
|
||||
mime_type: attachment.file.type || 'application/octet-stream',
|
||||
size_bytes: attachment.file.size,
|
||||
},
|
||||
createdByHumanId: userId,
|
||||
@@ -290,9 +314,11 @@ export function ComposeOverlay({
|
||||
}),
|
||||
);
|
||||
|
||||
const failed = results.filter((r) => r.status === "rejected");
|
||||
const failed = results.filter((r) => r.status === 'rejected');
|
||||
if (failed.length > 0) {
|
||||
toast.error(`${failed.length} attachment${failed.length > 1 ? "s" : ""} failed to upload`);
|
||||
toast.error(
|
||||
`${failed.length} attachment${failed.length > 1 ? 's' : ''} failed to upload`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[attachments, userId, uploadFile, createParticle],
|
||||
@@ -306,7 +332,7 @@ export function ComposeOverlay({
|
||||
if (textContent.trim()) {
|
||||
particleId = await createParticle.mutateAsync({
|
||||
path,
|
||||
type: "text",
|
||||
type: 'text',
|
||||
properties: { content: textContent },
|
||||
createdByHumanId: userId,
|
||||
});
|
||||
@@ -316,17 +342,20 @@ export function ComposeOverlay({
|
||||
reviewMimeType,
|
||||
);
|
||||
|
||||
const isAudioOnly = reviewMimeType.startsWith("audio/");
|
||||
const isAudioOnly = reviewMimeType.startsWith('audio/');
|
||||
particleId = await createParticle.mutateAsync({
|
||||
path,
|
||||
type: "media",
|
||||
type: 'media',
|
||||
properties: {
|
||||
object_id,
|
||||
mime_type: reviewMimeType,
|
||||
duration_ms: reviewDurationMs,
|
||||
size_bytes,
|
||||
...(!isAudioOnly && {
|
||||
source: recordingSource === "screen" ? "screen" as const : "camera" as const,
|
||||
source:
|
||||
recordingSource === 'screen'
|
||||
? ('screen' as const)
|
||||
: ('camera' as const),
|
||||
}),
|
||||
},
|
||||
createdByHumanId: userId,
|
||||
@@ -348,36 +377,49 @@ export function ComposeOverlay({
|
||||
createParticle,
|
||||
uploadMedia,
|
||||
uploadAttachments,
|
||||
onParticleCreated
|
||||
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]);
|
||||
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");
|
||||
// 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");
|
||||
if (!userId || stepRef.current === 'submitting') return;
|
||||
setStepSync('submitting');
|
||||
|
||||
try {
|
||||
const streamId = await createStream.mutateAsync({
|
||||
@@ -391,13 +433,21 @@ export function ComposeOverlay({
|
||||
|
||||
const streamChildrenPath = particlePath(networkId, [streamId]);
|
||||
await createChildParticle(streamChildrenPath);
|
||||
|
||||
cancel();
|
||||
setTextContent('');
|
||||
} catch (err) {
|
||||
if (!handleQuotaError(err)) throw err;
|
||||
}
|
||||
},
|
||||
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
|
||||
[
|
||||
networkId,
|
||||
userId,
|
||||
createStream,
|
||||
createChildParticle,
|
||||
cancel,
|
||||
handleQuotaError,
|
||||
setStepSync,
|
||||
],
|
||||
);
|
||||
|
||||
// --- Compose intent handlers ---
|
||||
@@ -406,13 +456,15 @@ export function ComposeOverlay({
|
||||
// guards (disabled, quota) and screen-vs-media branching live in one place.
|
||||
|
||||
const guardIdle = useCallback((): boolean => {
|
||||
if (stepRef.current !== "idle") return false;
|
||||
if (stepRef.current !== 'idle') return false;
|
||||
if (disabledRef.current) {
|
||||
toast.info("This stream is closed");
|
||||
toast.info('This stream is closed');
|
||||
return false;
|
||||
}
|
||||
if (quotaExhaustedRef.current) {
|
||||
toast.info("Daily message limit reached. Upgrade to Pro to keep sending.");
|
||||
toast.info(
|
||||
'Daily message limit reached. Upgrade to Pro to keep sending.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -421,19 +473,19 @@ export function ComposeOverlay({
|
||||
const handleRecordIntent = useCallback(() => {
|
||||
if (!guardIdle()) return;
|
||||
recordStartRef.current = Date.now();
|
||||
setRecordingSource("media");
|
||||
setStepSync("recording");
|
||||
setRecordingSource('media');
|
||||
setStepSync('recording');
|
||||
startRecording();
|
||||
}, [guardIdle, setStepSync, startRecording]);
|
||||
|
||||
const handleTextIntent = useCallback(() => {
|
||||
if (!guardIdle()) return;
|
||||
setStepSync("typing");
|
||||
setStepSync('typing');
|
||||
}, [guardIdle, setStepSync]);
|
||||
|
||||
const handleStopIntent = useCallback(() => {
|
||||
if (stepRef.current !== "recording") return;
|
||||
if (recordingSourceRef.current === "screen") {
|
||||
if (stepRef.current !== 'recording') return;
|
||||
if (recordingSourceRef.current === 'screen') {
|
||||
stopScreenRecording();
|
||||
} else {
|
||||
stopRecording();
|
||||
@@ -442,24 +494,24 @@ export function ComposeOverlay({
|
||||
|
||||
const handleCancelIntent = useCallback(() => {
|
||||
const s = stepRef.current;
|
||||
if (s === "recording" || s === "reviewing") {
|
||||
if (recordingSourceRef.current === "screen") {
|
||||
if (s === 'recording' || s === 'reviewing') {
|
||||
if (recordingSourceRef.current === 'screen') {
|
||||
cancelScreenRecording();
|
||||
} else {
|
||||
cancelRecording();
|
||||
}
|
||||
cancel();
|
||||
} else if (s === "typing" || s === "configuring" || s === "picking") {
|
||||
} else if (s === 'typing' || s === 'configuring' || s === 'picking') {
|
||||
cancel();
|
||||
}
|
||||
}, [cancel, cancelRecording, cancelScreenRecording]);
|
||||
|
||||
const handleSendIntent = useCallback(() => {
|
||||
if (stepRef.current !== "reviewing") return;
|
||||
if (stepRef.current !== 'reviewing') return;
|
||||
if (targetPath) {
|
||||
onSubmitReply();
|
||||
} else {
|
||||
setStepSync("configuring");
|
||||
setStepSync('configuring');
|
||||
}
|
||||
}, [targetPath, onSubmitReply, setStepSync]);
|
||||
|
||||
@@ -468,20 +520,42 @@ export function ComposeOverlay({
|
||||
// executes the matching handler and clears the intent. Keyboard handlers
|
||||
// call the same handlers directly without a store round-trip.
|
||||
|
||||
const intent = useComposeIntentStore((s) => s.intent);
|
||||
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(() => {
|
||||
if (!intent) return;
|
||||
switch (intent.kind) {
|
||||
case "record": handleRecordIntent(); break;
|
||||
case "text": handleTextIntent(); break;
|
||||
case "stop": handleStopIntent(); break;
|
||||
case "cancel": handleCancelIntent(); break;
|
||||
case "send": handleSendIntent(); break;
|
||||
}
|
||||
clearIntent();
|
||||
}, [intent, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
|
||||
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 'stop':
|
||||
handleStopIntent();
|
||||
break;
|
||||
case 'cancel':
|
||||
handleCancelIntent();
|
||||
break;
|
||||
case 'send':
|
||||
handleSendIntent();
|
||||
break;
|
||||
}
|
||||
clearIntent();
|
||||
});
|
||||
}, [
|
||||
handleRecordIntent,
|
||||
handleTextIntent,
|
||||
handleStopIntent,
|
||||
handleCancelIntent,
|
||||
handleSendIntent,
|
||||
clearIntent,
|
||||
]);
|
||||
|
||||
// --- Keyboard handling ---
|
||||
|
||||
@@ -489,8 +563,12 @@ export function ComposeOverlay({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const currentStep = stepRef.current;
|
||||
|
||||
if (currentStep === "typing" || currentStep === "configuring" || currentStep === "picking") {
|
||||
if (e.key === "Escape") {
|
||||
if (
|
||||
currentStep === 'typing' ||
|
||||
currentStep === 'configuring' ||
|
||||
currentStep === 'picking'
|
||||
) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
@@ -499,52 +577,55 @@ export function ComposeOverlay({
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case "idle": {
|
||||
if (e.key === "`" && !e.repeat) {
|
||||
case 'idle': {
|
||||
if (e.key === '`' && !e.repeat) {
|
||||
e.preventDefault();
|
||||
handleRecordIntent();
|
||||
} else if (e.key === "s" || e.key === "S") {
|
||||
} 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") {
|
||||
if (!requireDesktop('Screen recording')) break;
|
||||
setRecordingSource('screen');
|
||||
setStepSync('picking');
|
||||
} else if (e.key === 't' || e.key === 'T') {
|
||||
e.preventDefault();
|
||||
handleTextIntent();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "recording": {
|
||||
if (e.key === "`" && !e.repeat) {
|
||||
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") {
|
||||
} 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") {
|
||||
} 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") {
|
||||
case 'reviewing': {
|
||||
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelIntent();
|
||||
} else if (e.key === "Enter") {
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSendIntent();
|
||||
}
|
||||
@@ -554,30 +635,46 @@ export function ComposeOverlay({
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (stepRef.current === "recording" && e.key === "`" && recordingSourceRef.current === "media") {
|
||||
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) {
|
||||
if (
|
||||
recordStartRef.current > 0 &&
|
||||
Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS
|
||||
) {
|
||||
handleStopIntent();
|
||||
recordStartRef.current = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [cancel, setStepSync, guardIdle, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent]);
|
||||
}, [
|
||||
cancel,
|
||||
setStepSync,
|
||||
guardIdle,
|
||||
handleRecordIntent,
|
||||
handleTextIntent,
|
||||
handleStopIntent,
|
||||
handleCancelIntent,
|
||||
handleSendIntent,
|
||||
]);
|
||||
|
||||
// --- Screen source selection handler ---
|
||||
|
||||
const handleScreenSourceSelected = useCallback(
|
||||
(sourceId: string) => {
|
||||
setStepSync("recording");
|
||||
setStepSync('recording');
|
||||
startScreenRecording(sourceId);
|
||||
},
|
||||
[setStepSync, startScreenRecording],
|
||||
@@ -585,15 +682,15 @@ export function ComposeOverlay({
|
||||
|
||||
// --- Render ---
|
||||
|
||||
if (step === "idle") return null;
|
||||
if (step === 'idle') return null;
|
||||
|
||||
const handleTextAdvance = targetPath
|
||||
? onSubmitReply
|
||||
: () => setStepSync("configuring");
|
||||
: () => setStepSync('configuring');
|
||||
|
||||
return (
|
||||
<>
|
||||
{step === "picking" && (
|
||||
{step === 'picking' && (
|
||||
<ScreenSourcePicker
|
||||
title="Record your screen"
|
||||
confirmLabel="Record"
|
||||
@@ -602,24 +699,25 @@ export function ComposeOverlay({
|
||||
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" && (
|
||||
{(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">
|
||||
@@ -636,7 +734,7 @@ export function ComposeOverlay({
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
S
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
stop
|
||||
</button>
|
||||
<button
|
||||
@@ -647,13 +745,13 @@ export function ComposeOverlay({
|
||||
>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Q
|
||||
</kbd>{" "}
|
||||
</kbd>{' '}
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === "reviewing" && recordingSource === "screen" && reviewBlob && (
|
||||
{step === 'reviewing' && recordingSource === 'screen' && reviewBlob && (
|
||||
<RecordingOverlay
|
||||
step="reviewing"
|
||||
mediaStream={null}
|
||||
@@ -670,7 +768,7 @@ export function ComposeOverlay({
|
||||
objectFit="contain"
|
||||
/>
|
||||
)}
|
||||
{step === "typing" && (
|
||||
{step === 'typing' && (
|
||||
<TextComposeStep
|
||||
textContent={textContent}
|
||||
onTextChange={setTextContent}
|
||||
@@ -683,16 +781,18 @@ export function ComposeOverlay({
|
||||
dropZoneProps={dropZoneProps}
|
||||
/>
|
||||
)}
|
||||
{!targetPath && step === "configuring" && (
|
||||
{!targetPath && step === 'configuring' && (
|
||||
<ConfigureStreamStep
|
||||
networkId={networkId}
|
||||
onCancel={cancel}
|
||||
onSubmit={handleStreamSubmit}
|
||||
/>
|
||||
)}
|
||||
{step === "submitting" && (
|
||||
{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>
|
||||
<span className="animate-pulse text-sm text-white/60">
|
||||
Sending...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user