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:
Arjun Patel
2026-06-02 07:44:24 -07:00
committed by GitHub
parent 2fe562ce2b
commit a8a0b7db1b
258 changed files with 7822 additions and 5195 deletions
@@ -1,21 +1,21 @@
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 { 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";
import { platform } from "@/lib/platform";
} from '@/features/attachments/attachment-lightbox';
import { platform } from '@/lib/platform';
export interface PendingAttachment {
id: string;
file: File;
thumbnailUrl?: string;
status: "pending" | "uploading" | "uploaded" | "error";
status: 'pending' | 'uploading' | 'uploaded' | 'error';
}
interface AttachmentStripProps {
@@ -29,9 +29,9 @@ function pendingToItem(p: PendingAttachment): AttachmentItem {
return {
id: p.id,
filename: p.file.name,
mimeType: p.file.type || "application/octet-stream",
mimeType: p.file.type || 'application/octet-stream',
sizeBytes: p.file.size,
source: { kind: "local", file: p.file },
source: { kind: 'local', file: p.file },
};
}
@@ -50,20 +50,20 @@ function AttachmentThumbnail({
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";
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}
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",
'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 ? (
@@ -131,7 +131,7 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
alt=""
className="size-3 rounded-sm"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
@@ -158,7 +158,10 @@ export function AttachmentStrip({
// Lightbox state — only previewable attachments go in.
const previewable = useMemo(
() => attachments.filter((a) => getAttachmentHandler(a.file.type) === "lightbox"),
() =>
attachments.filter(
(a) => getAttachmentHandler(a.file.type) === 'lightbox',
),
[attachments],
);
const items = useMemo(() => previewable.map(pendingToItem), [previewable]);
@@ -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>
)}
</>
@@ -1,8 +1,8 @@
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";
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;
@@ -18,7 +18,9 @@ const SHOW_PROGRESS_AT_FRACTION = 0.7;
*
* Pro networks and any network still loading usage render nothing.
*/
export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps) {
export function ComposeQuotaIndicator({
networkId,
}: ComposeQuotaIndicatorProps) {
const navigate = useNavigate();
const { data: usage } = useNetworkUsage(networkId);
const isAdmin = useIsNetworkAdmin(networkId);
@@ -38,7 +40,8 @@ export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps)
: `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)})
Resets {formatResetRelative(usage.reset_at)} (
{formatResetAbsolute(usage.reset_at)})
</div>
{isAdmin ? (
<Button
@@ -49,10 +52,10 @@ export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps)
</Button>
) : (
<div className="text-xs text-muted-foreground">
Ask{" "}
Ask{' '}
<span className="font-medium text-foreground">
{network?.admin_human.email_prefix ?? "your admin"}
</span>{" "}
{network?.admin_human.email_prefix ?? 'your admin'}
</span>{' '}
to upgrade to Pro
</div>
)}
@@ -79,8 +82,8 @@ 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";
if (hours < 1) return 'soon';
if (hours === 1) return 'in 1 hour';
return `in ${hours} hours`;
}
@@ -88,7 +91,7 @@ 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",
hour: 'numeric',
minute: '2-digit',
});
}
@@ -1,13 +1,13 @@
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";
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;
@@ -42,7 +42,9 @@ export function ConfigureStreamStep({
const buildVisibleTo = useCallback((): string[] => {
if (everyone && networkId) return [`network:${networkId}`];
return Array.from(removeDuplicates([...selectedIds, userId].filter(Boolean) as string[])).map((id) => `human:${id}`);
return Array.from(
removeDuplicates([...selectedIds, userId].filter(Boolean) as string[]),
).map((id) => `human:${id}`);
}, [everyone, networkId, selectedIds, userId]);
const handleSubmit = useCallback(() => {
@@ -53,12 +55,12 @@ export function ConfigureStreamStep({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case "Escape":
case 'Escape':
e.preventDefault();
onCancel();
return;
case "Enter":
case 'Enter':
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
handleSubmit();
@@ -100,8 +102,8 @@ export function ConfigureStreamStep({
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",
'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
@@ -117,7 +119,7 @@ export function ConfigureStreamStep({
{!everyone && members.length > 0 && (
<ScrollArea className="max-h-48">
<div className="space-y-0.5 p-1">
{members.map((member, index) => {
{members.map((member) => {
const isSelected = selectedIds.has(member.id);
const initials = member.email_prefix
.slice(0, 2)
@@ -129,8 +131,8 @@ export function ConfigureStreamStep({
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",
'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
@@ -159,16 +161,16 @@ export function ConfigureStreamStep({
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
</kbd>{' '}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+Enter
</kbd>{" "}
</kbd>{' '}
create
</span>
</div>
</div >
</div>
);
}
@@ -36,8 +36,9 @@
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
--crepe-font-default: inherit;
--crepe-font-title: inherit;
--crepe-font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
--crepe-font-code:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono',
monospace;
}
/* Glassy floating menus over content. */
@@ -1,9 +1,9 @@
import { useEffect, useRef } from "react";
import { Crepe } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame-dark.css";
import "./markdown-editor.css";
import { cn } from "@/lib/utils";
import { useEffect, useRef } from 'react';
import { Crepe } from '@milkdown/crepe';
import '@milkdown/crepe/theme/common/style.css';
import '@milkdown/crepe/theme/frame-dark.css';
import './markdown-editor.css';
import { cn } from '@/lib/utils';
interface MarkdownEditorProps {
/** Initial markdown. The editor owns its content after mount; edits flow out
@@ -61,7 +61,7 @@ export function MarkdownEditor({
[Crepe.Feature.AI]: false,
},
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: placeholder ?? "" },
[Crepe.Feature.Placeholder]: { text: placeholder ?? '' },
},
});
@@ -82,7 +82,7 @@ export function MarkdownEditor({
}
created = crepe;
if (autoFocus && !readOnly) {
root.querySelector<HTMLElement>(".ProseMirror")?.focus();
root.querySelector<HTMLElement>('.ProseMirror')?.focus();
}
});
@@ -97,7 +97,7 @@ export function MarkdownEditor({
return (
<div
ref={rootRef}
className={cn("llink-crepe", !readOnly && "llink-crepe--fill", className)}
className={cn('llink-crepe', !readOnly && 'llink-crepe--fill', className)}
/>
);
}
@@ -1,16 +1,17 @@
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";
import { useComposeIntentStore } from "@/stores/compose-intent-store";
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 { useObjectUrl } from '@/hooks/use-object-url';
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';
import { useComposeIntentStore } from '@/stores/compose-intent-store';
interface RecordingOverlayProps {
step: "recording" | "reviewing";
step: 'recording' | 'reviewing';
mediaStream: MediaStream | null;
recordingMode: RecordingMode;
reviewBlob: Blob | null;
@@ -29,7 +30,7 @@ interface RecordingOverlayProps {
/** 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";
objectFit?: 'cover' | 'contain';
}
function RecordingTimer() {
@@ -44,7 +45,7 @@ function RecordingTimer() {
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const display = `${minutes}:${seconds.toString().padStart(2, "0")}`;
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
return (
<div className="flex items-center gap-2">
@@ -58,30 +59,18 @@ function ReviewPlayback({
blob,
isVideo,
mirror = true,
objectFit = "cover",
objectFit = 'cover',
}: {
blob: Blob;
isVideo: boolean;
mirror?: boolean;
objectFit?: "cover" | "contain";
objectFit?: 'cover' | 'contain';
}) {
const urlRef = useRef<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const objectUrl = useObjectUrl(blob);
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) {
@@ -91,7 +80,7 @@ function ReviewPlayback({
autoPlay
loop
playsInline
className={`absolute inset-0 h-full w-full ${objectFit === "contain" ? "object-contain" : "object-cover"}${mirror ? " -scale-x-100" : ""}`}
className={`absolute inset-0 h-full w-full ${objectFit === 'contain' ? 'object-contain' : 'object-cover'}${mirror ? ' -scale-x-100' : ''}`}
/>
);
}
@@ -129,14 +118,14 @@ export function RecordingOverlay({
isDragging,
dropZoneProps,
mirror = true,
objectFit = "cover",
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") {
if (videoRef.current && mediaStream && recordingMode === 'video') {
videoRef.current.srcObject = mediaStream;
}
}, [mediaStream, recordingMode]);
@@ -148,16 +137,16 @@ export function RecordingOverlay({
return () => clearTimeout(timeout);
}, [error, onClose]);
const isReviewing = step === "reviewing";
const isRecording = step === "recording";
const isReviewing = step === 'reviewing';
const isRecording = step === 'recording';
const isLoading = isRecording && !mediaStream;
const requestIntent = useComposeIntentStore((s) => s.request);
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",
'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 : {})}
>
@@ -165,15 +154,15 @@ export function RecordingOverlay({
{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..."}
{recordingMode === 'video'
? 'Starting camera...'
: 'Starting mic...'}
</span>
</div>
)}
{/* Camera preview (video mode, recording) */}
{isRecording && recordingMode === "video" && mediaStream && (
{isRecording && recordingMode === 'video' && mediaStream && (
<video
ref={videoRef}
muted
@@ -187,7 +176,7 @@ export function RecordingOverlay({
{isReviewing && reviewBlob && (
<ReviewPlayback
blob={reviewBlob}
isVideo={recordingMode === "video"}
isVideo={recordingMode === 'video'}
mirror={mirror}
objectFit={objectFit}
/>
@@ -221,28 +210,29 @@ export function RecordingOverlay({
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
<button
type="button"
onClick={() => requestIntent("stop")}
onClick={() => requestIntent('stop')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Finish recording (or release `)"
>
Release{" "}
Release{' '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
`
</kbd>{" "}
</kbd>{' '}
to review
</button>
<button
type="button"
onClick={() => requestIntent("cancel")}
onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard recording (or press Esc / Q)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" or "}
</kbd>
{' or '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{" "}
</kbd>{' '}
to cancel
</button>
</div>
@@ -262,27 +252,28 @@ export function RecordingOverlay({
<div className="flex items-center gap-4 text-sm text-white/50">
<button
type="button"
onClick={() => requestIntent("send")}
onClick={() => requestIntent('send')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Send (or press Enter)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{" "}
</kbd>{' '}
next
</button>
<button
type="button"
onClick={() => requestIntent("cancel")}
onClick={() => requestIntent('cancel')}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Discard (or press Esc / Q)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" or "}
</kbd>
{' or '}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{" "}
</kbd>{' '}
to cancel
</button>
<span>
@@ -299,17 +290,12 @@ export function RecordingOverlay({
attach
</Button>
</span>
</div>
</div>
)}
{/* Error state */}
{error && (
<div className="z-10 text-sm text-red-400">
{error}
</div>
)}
{error && <div className="z-10 text-sm text-red-400">{error}</div>}
</div>
);
}
@@ -1,5 +1,5 @@
import { TextEditor } from "@/features/compose/text-editor";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { TextEditor } from '@/features/compose/text-editor';
import type { PendingAttachment } from '@/features/compose/attachment-strip';
interface TextComposeStepProps {
textContent: string;
+27 -25
View File
@@ -1,12 +1,12 @@
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 { MarkdownEditor } from "@/features/compose/markdown-editor";
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 { MarkdownEditor } from '@/features/compose/markdown-editor';
export interface TextEditorAttachmentProps {
attachments: PendingAttachment[];
@@ -35,9 +35,9 @@ interface TextEditorProps {
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" };
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' };
}
export function TextEditor({
@@ -45,7 +45,7 @@ export function TextEditor({
onTextChange,
onSubmit,
onCancel,
submitHint = "next",
submitHint = 'next',
attachmentProps,
}: TextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -61,7 +61,9 @@ export function TextEditor({
const attachmentCount = attachmentProps?.attachments.length ?? 0;
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
const immersive =
textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
textContent.length < IMMERSIVE_CHAR_LIMIT &&
!hasEnrichments &&
!forceCardMode;
// Keep the immersive textarea focused with the caret at the end when we
// (re)enter it. The card-mode editor manages its own focus.
@@ -81,15 +83,15 @@ export function TextEditor({
// editor's own key handling (e.g. ⌘+Enter must submit, not insert a break).
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
if (textContent.trim()) onSubmit();
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
} else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
setForceCardMode(true);
@@ -112,20 +114,20 @@ export function TextEditor({
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
</kbd>{' '}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+Enter
</kbd>{" "}
</kbd>{' '}
{submitHint}
</span>
{immersive && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
{metaKey}+M
</kbd>{" "}
</kbd>{' '}
markdown
</span>
)}
@@ -156,8 +158,8 @@ export function TextEditor({
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",
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
isDragging && 'ring-2 ring-inset ring-white/30',
)}
{...dropZoneProps}
>
@@ -169,7 +171,7 @@ export function TextEditor({
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",
'w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none',
style.size,
style.weight,
)}
@@ -184,8 +186,8 @@ export function TextEditor({
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",
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
isDragging && 'ring-2 ring-inset ring-white/30',
)}
{...dropZoneProps}
onKeyDownCapture={handleKeyDown}
+15 -15
View File
@@ -1,13 +1,13 @@
import { useCallback, useEffect, useRef } from "react";
import type { RecordingMode } from "@/hooks/use-recording-mode";
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";
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") {
function getMediaMime(mode: 'video' | 'audio'): string {
if (mode === 'audio') {
return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME)
? AUDIO_PREFERRED_MIME
: AUDIO_FALLBACK_MIME;
@@ -36,7 +36,7 @@ function buildConstraints(
? { deviceId: { exact: micDeviceId } }
: true;
if (mode === "audio") return { audio };
if (mode === 'audio') return { audio };
const video: MediaTrackConstraints = cameraDeviceId
? { deviceId: { exact: cameraDeviceId }, aspectRatio: { ideal: 4 / 3 } }
@@ -57,13 +57,13 @@ async function getStreamWithFallback(
if (
hasDeviceId &&
err instanceof Error &&
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
(err.name === 'OverconstrainedError' || err.name === 'NotFoundError')
) {
const relaxed: MediaStreamConstraints = {
audio: typeof constraints.audio === "object" ? true : constraints.audio,
audio: typeof constraints.audio === 'object' ? true : constraints.audio,
...(constraints.video !== undefined && {
video:
typeof constraints.video === "object"
typeof constraints.video === 'object'
? { aspectRatio: { ideal: 4 / 3 } }
: constraints.video,
}),
@@ -143,13 +143,13 @@ export function useRecorder({
} catch (err) {
stopTracks();
onErrorRef.current(
err instanceof Error ? err.message : "Failed to start recording",
err instanceof Error ? err.message : 'Failed to start recording',
);
}
}, [mode, micDeviceId, cameraDeviceId, onStreamReady, stopTracks]);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
if (recorderRef.current?.state === 'recording') {
recorderRef.current.stop();
}
}, []);
@@ -158,7 +158,7 @@ export function useRecorder({
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") {
if (recorderRef.current.state === 'recording') {
recorderRef.current.stop();
}
}
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef } from "react";
import { platform } from "@/lib/platform";
import { requireDesktop } from "@/lib/platform/desktop-only";
import { useCallback, useEffect, useRef } from 'react';
import { platform } from '@/lib/platform';
import { requireDesktop } from '@/lib/platform/desktop-only';
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm";
const VIDEO_PREFERRED_MIME = 'video/webm;codecs=vp9,opus';
const VIDEO_FALLBACK_MIME = 'video/webm';
function getScreenMime(): string {
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
@@ -33,7 +33,7 @@ async function getMicStream(
if (
micDeviceId &&
err instanceof Error &&
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
(err.name === 'OverconstrainedError' || err.name === 'NotFoundError')
) {
return navigator.mediaDevices.getUserMedia({ audio: true });
}
@@ -77,14 +77,14 @@ export function useScreenRecorder({
const startRecording = useCallback(
async (sourceId: string) => {
if (!requireDesktop("Screen recording")) return;
if (!requireDesktop('Screen recording')) return;
try {
// 1. Screen video
const screenStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
},
} as MediaTrackConstraints,
@@ -130,7 +130,7 @@ export function useScreenRecorder({
// 5. Listen for stop from floating window
cleanupIpcRef.current = platform.screenRecord.onStopRequested(() => {
if (recorderRef.current?.state === "recording") {
if (recorderRef.current?.state === 'recording') {
recorderRef.current.stop();
}
});
@@ -138,7 +138,9 @@ export function useScreenRecorder({
stopAllTracks();
platform.screenRecord.cancel();
onErrorRef.current(
err instanceof Error ? err.message : "Failed to start screen recording",
err instanceof Error
? err.message
: 'Failed to start screen recording',
);
}
},
@@ -146,7 +148,7 @@ export function useScreenRecorder({
);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
if (recorderRef.current?.state === 'recording') {
recorderRef.current.stop();
}
}, []);
@@ -155,7 +157,7 @@ export function useScreenRecorder({
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") {
if (recorderRef.current.state === 'recording') {
recorderRef.current.stop();
}
}