upload & view attachments to particles
This commit is contained in:
@@ -0,0 +1,165 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export interface PendingAttachment {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
status: "pending" | "uploading" | "uploaded" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentStripProps {
|
||||||
|
attachments: PendingAttachment[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onAddClick: () => void;
|
||||||
|
linkPreviews?: LinkPreviewEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentThumbnail({
|
||||||
|
attachment,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
attachment: PendingAttachment;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const isImage = attachment.file.type.startsWith("image/");
|
||||||
|
const isUploading = attachment.status === "uploading";
|
||||||
|
const isError = attachment.status === "error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10",
|
||||||
|
isError && "ring-1 ring-red-400/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isImage && attachment.thumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
src={attachment.thumbnailUrl}
|
||||||
|
alt={attachment.file.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-0.5 px-1">
|
||||||
|
<FileIcon className="size-5 text-white/60" />
|
||||||
|
<span className="max-w-full truncate text-[9px] text-white/50">
|
||||||
|
{attachment.file.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-white/40">
|
||||||
|
{formatFileSize(attachment.file.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUploading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
|
<Loader2 className="size-4 animate-spin text-white/70" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
className="absolute right-0.5 top-0.5 hidden rounded-full bg-black/70 p-0.5 text-white/70 hover:text-white group-hover:block"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
||||||
|
if (entry.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2">
|
||||||
|
<Skeleton className="h-2 w-16 bg-white/10" />
|
||||||
|
<Skeleton className="h-3 w-24 bg-white/10" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.metadata) return null;
|
||||||
|
|
||||||
|
const { metadata } = entry;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.electronLink.openExternal(metadata.url)}
|
||||||
|
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-white/40">
|
||||||
|
{metadata.favicon ? (
|
||||||
|
<img
|
||||||
|
src={metadata.favicon}
|
||||||
|
alt=""
|
||||||
|
className="size-3 rounded-sm"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Globe className="size-3" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{metadata.domain}</span>
|
||||||
|
</div>
|
||||||
|
{metadata.title && (
|
||||||
|
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
|
||||||
|
{metadata.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentStrip({
|
||||||
|
attachments,
|
||||||
|
onRemove,
|
||||||
|
onAddClick,
|
||||||
|
linkPreviews,
|
||||||
|
}: AttachmentStripProps) {
|
||||||
|
const hasLinks = linkPreviews && linkPreviews.length > 0;
|
||||||
|
if (attachments.length === 0 && !hasLinks) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="w-full">
|
||||||
|
<div className="flex items-center gap-2 py-2">
|
||||||
|
{attachments.map((a) => (
|
||||||
|
<AttachmentThumbnail
|
||||||
|
key={a.id}
|
||||||
|
attachment={a}
|
||||||
|
onRemove={() => onRemove(a.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{linkPreviews?.map((entry) => (
|
||||||
|
<LinkPreviewThumbnail key={entry.url} entry={entry} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddClick();
|
||||||
|
}}
|
||||||
|
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60"
|
||||||
|
>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||||
import { useRecorder } from "@/features/compose/use-recorder";
|
import { useRecorder } from "@/features/compose/use-recorder";
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath, parseParticlePath } from "@/lib/particle-path";
|
||||||
import type { ParticlePath } from "@/lib/particle-path";
|
import type { ParticlePath } from "@/lib/particle-path";
|
||||||
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
||||||
import { TextComposeStep } from "@/features/compose/text-compose-step";
|
import { TextComposeStep } from "@/features/compose/text-compose-step";
|
||||||
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||||
|
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";
|
||||||
|
|
||||||
type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
|
||||||
|
|
||||||
@@ -41,6 +46,8 @@ export function ComposeOverlay({
|
|||||||
const [reviewDurationMs, setReviewDurationMs] = useState(0);
|
const [reviewDurationMs, setReviewDurationMs] = useState(0);
|
||||||
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
|
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||||
|
|
||||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
const createParticle = useCreateParticle();
|
const createParticle = useCreateParticle();
|
||||||
@@ -60,6 +67,12 @@ export function ComposeOverlay({
|
|||||||
onActiveChange?.(step !== "idle");
|
onActiveChange?.(step !== "idle");
|
||||||
}, [step, onActiveChange]);
|
}, [step, onActiveChange]);
|
||||||
|
|
||||||
|
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
|
||||||
|
for (const a of items) {
|
||||||
|
if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
setStepSync("idle");
|
setStepSync("idle");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -68,7 +81,57 @@ export function ComposeOverlay({
|
|||||||
setReviewBlob(null);
|
setReviewBlob(null);
|
||||||
setReviewDurationMs(0);
|
setReviewDurationMs(0);
|
||||||
setReviewMimeType(null);
|
setReviewMimeType(null);
|
||||||
}, [setStepSync]);
|
setAttachments((prev) => {
|
||||||
|
revokeAttachmentThumbnails(prev);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}, [setStepSync, revokeAttachmentThumbnails]);
|
||||||
|
|
||||||
|
const addAttachments = useCallback(async (files: File[]) => {
|
||||||
|
const currentCount = attachments.length;
|
||||||
|
const available = MAX_ATTACHMENTS - currentCount;
|
||||||
|
if (available <= 0) {
|
||||||
|
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accepted = files.slice(0, available);
|
||||||
|
if (accepted.length < files.length) {
|
||||||
|
toast.error(`Maximum ${MAX_ATTACHMENTS} attachments — ${files.length - accepted.length} skipped`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAttachments: PendingAttachment[] = [];
|
||||||
|
for (const file of accepted) {
|
||||||
|
if (file.size > MAX_ATTACHMENT_SIZE_BYTES) {
|
||||||
|
toast.error(`${file.name} is too large (max 25 MB)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const thumbnailUrl = await createImageThumbnail(file);
|
||||||
|
newAttachments.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
thumbnailUrl,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAttachments.length > 0) {
|
||||||
|
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||||
|
}
|
||||||
|
}, [attachments.length]);
|
||||||
|
|
||||||
|
const removeAttachment = useCallback((id: string) => {
|
||||||
|
setAttachments((prev) => {
|
||||||
|
const removed = prev.find((a) => a.id === id);
|
||||||
|
if (removed?.thumbnailUrl) URL.revokeObjectURL(removed.thumbnailUrl);
|
||||||
|
return prev.filter((a) => a.id !== id);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { openFilePicker, isDragging, dropZoneProps } = useFileInput({
|
||||||
|
onFilesSelected: addAttachments,
|
||||||
|
enabled: step === "typing" || step === "reviewing",
|
||||||
|
});
|
||||||
|
|
||||||
const { startRecording, stopRecording, cancelRecording } = useRecorder({
|
const { startRecording, stopRecording, cancelRecording } = useRecorder({
|
||||||
mode: recordingMode,
|
mode: recordingMode,
|
||||||
@@ -111,11 +174,73 @@ export function ComposeOverlay({
|
|||||||
[networkId],
|
[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(
|
const createChildParticle = useCallback(
|
||||||
async (path: ParticlePath) => {
|
async (path: ParticlePath) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
let particleId = '';
|
let particleId: undefined | string;
|
||||||
if (textContent.trim()) {
|
if (textContent.trim()) {
|
||||||
particleId = await createParticle.mutateAsync({
|
particleId = await createParticle.mutateAsync({
|
||||||
path,
|
path,
|
||||||
@@ -142,7 +267,10 @@ export function ComposeOverlay({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onParticleCreated?.(particleId);
|
if (particleId) {
|
||||||
|
await uploadAttachments(path, particleId);
|
||||||
|
onParticleCreated?.(particleId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
userId,
|
userId,
|
||||||
@@ -152,6 +280,7 @@ export function ComposeOverlay({
|
|||||||
reviewDurationMs,
|
reviewDurationMs,
|
||||||
createParticle,
|
createParticle,
|
||||||
uploadMedia,
|
uploadMedia,
|
||||||
|
uploadAttachments,
|
||||||
onParticleCreated
|
onParticleCreated
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -293,6 +422,11 @@ export function ComposeOverlay({
|
|||||||
reviewBlob={reviewBlob}
|
reviewBlob={reviewBlob}
|
||||||
error={error}
|
error={error}
|
||||||
onClose={cancel}
|
onClose={cancel}
|
||||||
|
attachments={attachments}
|
||||||
|
onRemoveAttachment={removeAttachment}
|
||||||
|
onAddFiles={openFilePicker}
|
||||||
|
isDragging={isDragging}
|
||||||
|
dropZoneProps={dropZoneProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === "typing" && (
|
{step === "typing" && (
|
||||||
@@ -301,6 +435,11 @@ export function ComposeOverlay({
|
|||||||
onTextChange={setTextContent}
|
onTextChange={setTextContent}
|
||||||
onAdvance={handleTextAdvance}
|
onAdvance={handleTextAdvance}
|
||||||
onCancel={cancel}
|
onCancel={cancel}
|
||||||
|
attachments={attachments}
|
||||||
|
onRemoveAttachment={removeAttachment}
|
||||||
|
onAddFiles={openFilePicker}
|
||||||
|
isDragging={isDragging}
|
||||||
|
dropZoneProps={dropZoneProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!targetPath && step === "configuring" && (
|
{!targetPath && step === "configuring" && (
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Paperclip } from "lucide-react";
|
||||||
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
||||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||||
|
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
||||||
|
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface RecordingOverlayProps {
|
interface RecordingOverlayProps {
|
||||||
step: "recording" | "reviewing";
|
step: "recording" | "reviewing";
|
||||||
@@ -10,6 +15,16 @@ interface RecordingOverlayProps {
|
|||||||
reviewBlob: Blob | null;
|
reviewBlob: Blob | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
attachments: PendingAttachment[];
|
||||||
|
onRemoveAttachment: (id: string) => void;
|
||||||
|
onAddFiles: () => void;
|
||||||
|
isDragging: boolean;
|
||||||
|
dropZoneProps: {
|
||||||
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
|
onDragEnter: (e: React.DragEvent) => void;
|
||||||
|
onDragLeave: (e: React.DragEvent) => void;
|
||||||
|
onDrop: (e: React.DragEvent) => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordingTimer() {
|
function RecordingTimer() {
|
||||||
@@ -99,6 +114,11 @@ export function RecordingOverlay({
|
|||||||
reviewBlob,
|
reviewBlob,
|
||||||
error,
|
error,
|
||||||
onClose,
|
onClose,
|
||||||
|
attachments,
|
||||||
|
onRemoveAttachment,
|
||||||
|
onAddFiles,
|
||||||
|
isDragging,
|
||||||
|
dropZoneProps,
|
||||||
}: RecordingOverlayProps) {
|
}: RecordingOverlayProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const recordingAudioSource = useAudioSource(mediaStream ?? null);
|
const recordingAudioSource = useAudioSource(mediaStream ?? null);
|
||||||
@@ -122,7 +142,13 @@ export function RecordingOverlay({
|
|||||||
const isLoading = isRecording && !mediaStream;
|
const isLoading = isRecording && !mediaStream;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90",
|
||||||
|
isReviewing && isDragging && "ring-2 ring-inset ring-white/30",
|
||||||
|
)}
|
||||||
|
{...(isReviewing ? dropZoneProps : {})}
|
||||||
|
>
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="z-10 flex flex-col items-center gap-2">
|
<div className="z-10 flex flex-col items-center gap-2">
|
||||||
@@ -191,19 +217,45 @@ export function RecordingOverlay({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isReviewing && (
|
{isReviewing && (
|
||||||
<div className="absolute bottom-8 z-10 flex items-center gap-4 text-sm text-white/50">
|
<div className="absolute bottom-8 z-10 flex flex-col items-center gap-3">
|
||||||
<span>
|
{attachments.length > 0 && (
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<div className="px-4">
|
||||||
Enter
|
<AttachmentStrip
|
||||||
</kbd>{" "}
|
attachments={attachments}
|
||||||
next
|
onRemove={onRemoveAttachment}
|
||||||
</span>
|
onAddClick={onAddFiles}
|
||||||
<span>
|
/>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
</div>
|
||||||
Q
|
)}
|
||||||
</kbd>{" "}
|
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||||
cancel
|
<span>
|
||||||
</span>
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
Enter
|
||||||
|
</kbd>{" "}
|
||||||
|
next
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
Q
|
||||||
|
</kbd>{" "}
|
||||||
|
cancel
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddFiles();
|
||||||
|
}}
|
||||||
|
title="Attach files"
|
||||||
|
>
|
||||||
|
<Paperclip className="size-4" />
|
||||||
|
attach
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
|
import { Paperclip } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFirstLinkMetadata } from "@/hooks/use-link-metadata";
|
import { useAllLinkMetadata } from "@/hooks/use-link-metadata";
|
||||||
import {
|
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
||||||
LinkPreviewCard,
|
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||||
LinkPreviewCardSkeleton,
|
import { Button } from "@/components/ui/button";
|
||||||
} from "@/components/link-preview-card";
|
|
||||||
|
|
||||||
interface TextComposeStepProps {
|
interface TextComposeStepProps {
|
||||||
textContent: string;
|
textContent: string;
|
||||||
onTextChange: (text: string) => void;
|
onTextChange: (text: string) => void;
|
||||||
onAdvance: () => void;
|
onAdvance: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
attachments: PendingAttachment[];
|
||||||
|
onRemoveAttachment: (id: string) => void;
|
||||||
|
onAddFiles: () => void;
|
||||||
|
isDragging: boolean;
|
||||||
|
dropZoneProps: {
|
||||||
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
|
onDragEnter: (e: React.DragEvent) => void;
|
||||||
|
onDragLeave: (e: React.DragEvent) => void;
|
||||||
|
onDrop: (e: React.DragEvent) => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||||
@@ -26,6 +36,11 @@ export function TextComposeStep({
|
|||||||
onTextChange,
|
onTextChange,
|
||||||
onAdvance,
|
onAdvance,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
attachments,
|
||||||
|
onRemoveAttachment,
|
||||||
|
onAddFiles,
|
||||||
|
isDragging,
|
||||||
|
dropZoneProps,
|
||||||
}: TextComposeStepProps) {
|
}: TextComposeStepProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
@@ -35,7 +50,7 @@ export function TextComposeStep({
|
|||||||
const t = setTimeout(() => setDebouncedText(textContent), 500);
|
const t = setTimeout(() => setDebouncedText(textContent), 500);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [textContent]);
|
}, [textContent]);
|
||||||
const { data: metadata, isLoading, url: firstUrl } = useFirstLinkMetadata(debouncedText);
|
const linkPreviews = useAllLinkMetadata(debouncedText);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
@@ -54,14 +69,16 @@ export function TextComposeStep({
|
|||||||
[onCancel, onAdvance, textContent],
|
[onCancel, onAdvance, textContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT;
|
const hasEnrichments = attachments.length > 0 || linkPreviews.length > 0;
|
||||||
const showPreview = firstUrl && (isLoading || metadata);
|
const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments;
|
||||||
|
|
||||||
const linkPreview = (
|
const strip = (
|
||||||
<>
|
<AttachmentStrip
|
||||||
{firstUrl && isLoading && <LinkPreviewCardSkeleton />}
|
attachments={attachments}
|
||||||
{firstUrl && metadata && <LinkPreviewCard metadata={metadata} compact />}
|
onRemove={onRemoveAttachment}
|
||||||
</>
|
onAddClick={onAddFiles}
|
||||||
|
linkPreviews={linkPreviews}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyboardHints = (
|
const keyboardHints = (
|
||||||
@@ -78,19 +95,34 @@ export function TextComposeStep({
|
|||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
next
|
next
|
||||||
</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddFiles();
|
||||||
|
}}
|
||||||
|
title="Attach files"
|
||||||
|
>
|
||||||
|
<Paperclip className="size-4" />
|
||||||
|
attach
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (immersive) {
|
if (immersive) {
|
||||||
const style = getImmersiveTextStyle(textContent.length);
|
const style = getImmersiveTextStyle(textContent.length);
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
<div
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||||
"flex w-full items-center justify-center gap-6 px-6",
|
isDragging && "ring-2 ring-inset ring-white/30",
|
||||||
showPreview ? "flex-row" : "flex-col",
|
)}
|
||||||
)}
|
{...dropZoneProps}
|
||||||
>
|
>
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-6 px-6">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={textContent}
|
value={textContent}
|
||||||
@@ -98,14 +130,12 @@ export function TextComposeStep({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 resize-none border-none bg-transparent p-8 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",
|
||||||
showPreview ? "text-left" : "text-center",
|
|
||||||
style.size,
|
style.size,
|
||||||
style.weight,
|
style.weight,
|
||||||
)}
|
)}
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
{showPreview && <div className="shrink-0">{linkPreview}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
{keyboardHints}
|
{keyboardHints}
|
||||||
</div>
|
</div>
|
||||||
@@ -113,8 +143,14 @@ export function TextComposeStep({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
<div
|
||||||
<div className="mx-8 flex max-h-[calc(100%-6rem)] w-full flex-col gap-4 overflow-y-auto rounded-2xl bg-white/10 p-5 backdrop-blur-md">
|
className={cn(
|
||||||
|
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||||
|
isDragging && "ring-2 ring-inset ring-white/30",
|
||||||
|
)}
|
||||||
|
{...dropZoneProps}
|
||||||
|
>
|
||||||
|
<div className="mx-8 flex max-h-[calc(100%-6rem)] w-full flex-col gap-2 overflow-y-auto rounded-2xl bg-white/10 p-5 backdrop-blur-md">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={textContent}
|
value={textContent}
|
||||||
@@ -124,7 +160,7 @@ export function TextComposeStep({
|
|||||||
className="w-full resize-none border-none bg-transparent text-base leading-relaxed text-white placeholder-white/40 outline-none"
|
className="w-full resize-none border-none bg-transparent text-base leading-relaxed text-white placeholder-white/40 outline-none"
|
||||||
rows={6}
|
rows={6}
|
||||||
/>
|
/>
|
||||||
{showPreview && linkPreview}
|
{strip}
|
||||||
</div>
|
</div>
|
||||||
{keyboardHints}
|
{keyboardHints}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
|
import type { ParticlePath } from "@/lib/particle-path";
|
||||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||||
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback";
|
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback";
|
||||||
import { TranscriptOverlay } from "@/features/particles/transcript-overlay";
|
import { TranscriptOverlay } from "@/features/particles/transcript-overlay";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||||
|
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
||||||
|
import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
||||||
|
|
||||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
type MediaParticle = Extract<Particle, { type: "media" }>;
|
||||||
|
|
||||||
interface MediaParticleViewProps {
|
interface MediaParticleViewProps {
|
||||||
particle: MediaParticle;
|
particle: MediaParticle;
|
||||||
|
streamPath: ParticlePath;
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress?: (ratio: number) => void;
|
onProgress?: (ratio: number) => void;
|
||||||
@@ -18,11 +22,13 @@ interface MediaParticleViewProps {
|
|||||||
|
|
||||||
export function MediaParticleView({
|
export function MediaParticleView({
|
||||||
particle,
|
particle,
|
||||||
|
streamPath,
|
||||||
paused,
|
paused,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
}: MediaParticleViewProps) {
|
}: MediaParticleViewProps) {
|
||||||
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
|
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
|
||||||
|
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
@@ -70,6 +76,12 @@ export function MediaParticleView({
|
|||||||
if (duration > 0) onProgress?.(time / duration);
|
if (duration > 0) onProgress?.(time / duration);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const attachmentOverlay = attachments.length > 0 && (
|
||||||
|
<div className="absolute inset-x-0 bottom-16 z-10 px-4">
|
||||||
|
<ParticleAttachments attachments={attachments} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
||||||
@@ -99,6 +111,8 @@ export function MediaParticleView({
|
|||||||
centered
|
centered
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{attachmentOverlay}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -122,6 +136,8 @@ export function MediaParticleView({
|
|||||||
activeWordIndex={activeWordIndex}
|
activeWordIndex={activeWordIndex}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{attachmentOverlay}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Download, FileIcon } from "lucide-react";
|
||||||
|
import type { Particle } from "@/api/types";
|
||||||
|
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
|
type FileParticle = Extract<Particle, { type: "file" }>;
|
||||||
|
|
||||||
|
interface ParticleAttachmentsProps {
|
||||||
|
attachments: FileParticle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageAttachment({ particle }: { particle: FileParticle }) {
|
||||||
|
const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id);
|
||||||
|
|
||||||
|
if (isLoading || !url) {
|
||||||
|
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="group relative block h-20 w-20 shrink-0 overflow-hidden rounded-lg bg-white/10"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={particle.properties.filename}
|
||||||
|
className="h-full w-full object-cover transition-opacity group-hover:opacity-80"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Download className="size-5 text-white drop-shadow-md" />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileAttachment({ particle }: { particle: FileParticle }) {
|
||||||
|
const { data: url } = useDownloadUrl(particle.properties.object_id);
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<div className="flex h-20 shrink-0 items-center gap-2.5 rounded-lg bg-white/10 px-3 transition-colors hover:bg-white/15">
|
||||||
|
<FileIcon className="size-5 shrink-0 text-white/60" />
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90">
|
||||||
|
{particle.properties.filename}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-white/40">
|
||||||
|
{formatFileSize(particle.properties.size_bytes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Download className="size-3.5 shrink-0 text-white/30" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParticleAttachments({ attachments }: ParticleAttachmentsProps) {
|
||||||
|
if (attachments.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="w-full">
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
{attachments.map((attachment) => {
|
||||||
|
const isImage = attachment.properties.mime_type.startsWith("image/");
|
||||||
|
return isImage ? (
|
||||||
|
<ImageAttachment key={attachment.id} particle={attachment} />
|
||||||
|
) : (
|
||||||
|
<FileAttachment key={attachment.id} particle={attachment} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -222,6 +222,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
<MediaParticleView
|
<MediaParticleView
|
||||||
key={particle.id}
|
key={particle.id}
|
||||||
particle={particle}
|
particle={particle}
|
||||||
|
streamPath={path}
|
||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={next}
|
onEnded={next}
|
||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
@@ -232,6 +233,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
<TextParticleView
|
<TextParticleView
|
||||||
key={particle.id}
|
key={particle.id}
|
||||||
particle={particle}
|
particle={particle}
|
||||||
|
streamPath={path}
|
||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={next}
|
onEnded={next}
|
||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
|
import type { ParticlePath } from "@/lib/particle-path";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFirstLinkMetadata } from "@/hooks/use-link-metadata";
|
import { useAllLinkMetadata, type LinkPreviewEntry } from "@/hooks/use-link-metadata";
|
||||||
|
import { extractUrls } from "@/lib/link-metadata";
|
||||||
import {
|
import {
|
||||||
LinkPreviewCard,
|
LinkPreviewCard,
|
||||||
LinkPreviewCardSkeleton,
|
LinkPreviewCardSkeleton,
|
||||||
} from "@/components/link-preview-card";
|
} from "@/components/link-preview-card";
|
||||||
|
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
||||||
|
import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
||||||
|
|
||||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||||
|
|
||||||
interface TextParticleViewProps {
|
interface TextParticleViewProps {
|
||||||
particle: TextParticle;
|
particle: TextParticle;
|
||||||
|
streamPath: ParticlePath;
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress?: (ratio: number) => void;
|
onProgress?: (ratio: number) => void;
|
||||||
@@ -21,16 +26,20 @@ const CHARS_PER_MINUTE = 1000;
|
|||||||
const MIN_DURATION_S = 3;
|
const MIN_DURATION_S = 3;
|
||||||
const MAX_DURATION_S = 15;
|
const MAX_DURATION_S = 15;
|
||||||
const TICK_MS = 100;
|
const TICK_MS = 100;
|
||||||
const LINK_EXTRA_DURATION_S = 3;
|
const EXTRA_S_PER_LINK = 2;
|
||||||
|
const EXTRA_S_PER_ATTACHMENT = 2;
|
||||||
|
|
||||||
// Below this threshold: immersive centered display
|
// Below this threshold: immersive centered display
|
||||||
// Above: contained left-aligned card
|
|
||||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||||
|
|
||||||
function computeReadDuration(text: string, hasLink: boolean): number {
|
function computeReadDuration(
|
||||||
|
text: string,
|
||||||
|
linkCount: number,
|
||||||
|
attachmentCount: number,
|
||||||
|
): number {
|
||||||
const base = (text.length / CHARS_PER_MINUTE) * 60;
|
const base = (text.length / CHARS_PER_MINUTE) * 60;
|
||||||
const seconds = hasLink ? base + LINK_EXTRA_DURATION_S : base;
|
const extra = linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT;
|
||||||
return Math.min(Math.max(seconds, MIN_DURATION_S), MAX_DURATION_S);
|
return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImmersiveTextStyle(length: number) {
|
function getImmersiveTextStyle(length: number) {
|
||||||
@@ -39,17 +48,39 @@ function getImmersiveTextStyle(length: number) {
|
|||||||
return { size: "text-2xl", weight: "font-normal" };
|
return { size: "text-2xl", weight: "font-normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div key={entry.url} className="shrink-0">
|
||||||
|
{entry.isLoading ? (
|
||||||
|
<LinkPreviewCardSkeleton />
|
||||||
|
) : entry.metadata ? (
|
||||||
|
<LinkPreviewCard metadata={entry.metadata} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TextParticleView({
|
export function TextParticleView({
|
||||||
particle,
|
particle,
|
||||||
|
streamPath,
|
||||||
paused,
|
paused,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
}: TextParticleViewProps) {
|
}: TextParticleViewProps) {
|
||||||
const content = particle.properties.content;
|
const content = particle.properties.content;
|
||||||
const { data: metadata, isLoading, url: firstUrl } = useFirstLinkMetadata(content);
|
const linkPreviews = useAllLinkMetadata(content);
|
||||||
const hasLink = !!firstUrl;
|
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||||
const immersive = content.length < IMMERSIVE_CHAR_LIMIT;
|
const urls = extractUrls(content);
|
||||||
const durationS = computeReadDuration(content, hasLink);
|
|
||||||
|
const hasLinks = urls.length > 0;
|
||||||
|
const hasAttachments = attachments.length > 0;
|
||||||
|
const hasEnrichments = hasLinks || hasAttachments;
|
||||||
|
|
||||||
|
const durationS = computeReadDuration(content, urls.length, attachments.length);
|
||||||
const elapsedRef = useRef(0);
|
const elapsedRef = useRef(0);
|
||||||
|
|
||||||
// Reset elapsed when particle changes
|
// Reset elapsed when particle changes
|
||||||
@@ -74,25 +105,22 @@ export function TextParticleView({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [paused, durationS, onEnded, onProgress, particle.id]);
|
}, [paused, durationS, onEnded, onProgress, particle.id]);
|
||||||
|
|
||||||
const linkPreview = (
|
// Content is just bare URLs with no surrounding text
|
||||||
<>
|
const contentTrimmed = content.trim();
|
||||||
{isLoading && <LinkPreviewCardSkeleton />}
|
const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) &&
|
||||||
{metadata && <LinkPreviewCard metadata={metadata} />}
|
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, "").trim() === "";
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Content is just a bare URL with no surrounding text
|
// Mode 1: bare URLs only — show link cards centered
|
||||||
const linkOnly = hasLink && content.trim() === firstUrl;
|
if (linksOnly && !hasAttachments) {
|
||||||
|
|
||||||
if (linkOnly) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
||||||
{linkPreview}
|
<LinkPreviews entries={linkPreviews} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (immersive && !hasLink) {
|
// Mode 2: short text, no enrichments — immersive centered display
|
||||||
|
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments) {
|
||||||
const style = getImmersiveTextStyle(content.length);
|
const style = getImmersiveTextStyle(content.length);
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
<div className="flex h-full w-full flex-col items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
||||||
@@ -109,15 +137,17 @@ export function TextParticleView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mode 3: card layout with enrichments
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
||||||
<div className="flex max-h-full w-full items-center gap-6">
|
<div className="flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto rounded-2xl bg-white/10 p-5 backdrop-blur-md">
|
||||||
<div className="flex-1 overflow-y-auto rounded-2xl max-w-lg mx-auto bg-white/10 p-5 backdrop-blur-md">
|
<p className="break-words text-base leading-relaxed text-white select-text cursor-text">
|
||||||
<p className="break-words text-base leading-relaxed text-white select-text cursor-text">
|
{content}
|
||||||
{content}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
{hasLinks && <LinkPreviews entries={linkPreviews} />}
|
||||||
{hasLink && <div className="shrink-0">{linkPreview}</div>}
|
|
||||||
|
{hasAttachments && <ParticleAttachments attachments={attachments} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface UseFileInputOptions {
|
||||||
|
onFilesSelected: (files: File[]) => void;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const dragCountRef = useRef(0);
|
||||||
|
|
||||||
|
// Stable ref for the callback to avoid re-registering effects
|
||||||
|
const onFilesRef = useRef(onFilesSelected);
|
||||||
|
onFilesRef.current = onFilesSelected;
|
||||||
|
|
||||||
|
// Hidden file input element
|
||||||
|
useEffect(() => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.multiple = true;
|
||||||
|
input.style.display = "none";
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
if (input.files?.length) {
|
||||||
|
onFilesRef.current(Array.from(input.files));
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.body.appendChild(input);
|
||||||
|
inputRef.current = input;
|
||||||
|
return () => {
|
||||||
|
document.body.removeChild(input);
|
||||||
|
inputRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openFilePicker = useCallback(() => {
|
||||||
|
inputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clipboard paste
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
const files = Array.from(e.clipboardData?.files ?? []);
|
||||||
|
if (files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
onFilesRef.current(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("paste", handlePaste);
|
||||||
|
return () => window.removeEventListener("paste", handlePaste);
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
// Drag and drop handlers
|
||||||
|
const onDragOver = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
[enabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragEnter = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCountRef.current++;
|
||||||
|
if (dragCountRef.current === 1) setIsDragging(true);
|
||||||
|
},
|
||||||
|
[enabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragLeave = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCountRef.current--;
|
||||||
|
if (dragCountRef.current === 0) setIsDragging(false);
|
||||||
|
},
|
||||||
|
[enabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCountRef.current = 0;
|
||||||
|
setIsDragging(false);
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
onFilesRef.current(files);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
openFilePicker,
|
||||||
|
isDragging,
|
||||||
|
dropZoneProps: { onDragOver, onDragEnter, onDragLeave, onDrop },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||||
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
|
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
|
||||||
|
|
||||||
export function useLinkMetadata(url: string | null) {
|
export function useLinkMetadata(url: string | null) {
|
||||||
@@ -17,3 +17,29 @@ export function useFirstLinkMetadata(text: string) {
|
|||||||
const firstUrl = urls[0] ?? null;
|
const firstUrl = urls[0] ?? null;
|
||||||
return { ...useLinkMetadata(firstUrl), url: firstUrl };
|
return { ...useLinkMetadata(firstUrl), url: firstUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkPreviewEntry {
|
||||||
|
url: string;
|
||||||
|
metadata: LinkMetadata | null | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAllLinkMetadata(text: string): LinkPreviewEntry[] {
|
||||||
|
const urls = extractUrls(text);
|
||||||
|
|
||||||
|
const results = useQueries({
|
||||||
|
queries: urls.map((url) => ({
|
||||||
|
queryKey: ["link-metadata", url],
|
||||||
|
queryFn: () => window.electronLink.fetchMetadata(url),
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 30 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return urls.map((url, i) => ({
|
||||||
|
url,
|
||||||
|
metadata: results[i].data,
|
||||||
|
isLoading: results[i].isLoading,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { Particle } from "@/api/types";
|
||||||
|
import { useParticleChildren } from "@/hooks/use-particle";
|
||||||
|
import { particlePath, type ParticlePath, parseParticlePath } from "@/lib/particle-path";
|
||||||
|
|
||||||
|
type FileParticle = Extract<Particle, { type: "file" }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches file children (attachments) of a particle in a stream.
|
||||||
|
* Uses a one-shot query since attachments are immutable after creation.
|
||||||
|
*/
|
||||||
|
export function useParticleAttachments(
|
||||||
|
streamPath: ParticlePath,
|
||||||
|
particleId: string,
|
||||||
|
) {
|
||||||
|
const childrenPath = useMemo(() => {
|
||||||
|
const { networkId, segments } = parseParticlePath(streamPath);
|
||||||
|
return particlePath(networkId, [...segments, particleId]);
|
||||||
|
}, [streamPath, particleId]);
|
||||||
|
|
||||||
|
const { data: children, isLoading } = useParticleChildren(childrenPath);
|
||||||
|
|
||||||
|
const attachments = useMemo(
|
||||||
|
() => (children ?? []).filter((c): c is FileParticle => c.type === "file"),
|
||||||
|
[children],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { attachments, isLoading };
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
subscribeToParticleChildren,
|
subscribeToParticleChildren,
|
||||||
subscribeToLatestChild,
|
subscribeToLatestChild,
|
||||||
getParticle,
|
getParticle,
|
||||||
|
getParticleChildren,
|
||||||
} from "@/lib/firestore-particles";
|
} from "@/lib/firestore-particles";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import {
|
import {
|
||||||
@@ -143,3 +144,16 @@ export function useParticle(path?: ParticlePath) {
|
|||||||
enabled: !!path,
|
enabled: !!path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useParticleChildren(path?: ParticlePath) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["particle-children", path],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!path) return [];
|
||||||
|
const collectionPath = toFirestoreChildrenPath(path);
|
||||||
|
return getParticleChildren(collectionPath);
|
||||||
|
},
|
||||||
|
enabled: !!path,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 min — attachments don't change
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
/** How far back to look when filtering particles by recency. */
|
/** How far back to look when filtering particles by recency. */
|
||||||
export const RECENCY_WINDOW_HOURS = 24;
|
export const RECENCY_WINDOW_HOURS = 24;
|
||||||
|
|
||||||
|
/** Maximum file size for attachments (25 MB). */
|
||||||
|
export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Maximum number of file attachments per particle. */
|
||||||
|
export const MAX_ATTACHMENTS = 10;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
onSnapshot,
|
onSnapshot,
|
||||||
addDoc,
|
addDoc,
|
||||||
getDoc,
|
getDoc,
|
||||||
|
getDocs,
|
||||||
updateDoc,
|
updateDoc,
|
||||||
query,
|
query,
|
||||||
orderBy,
|
orderBy,
|
||||||
@@ -126,6 +127,19 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
|
|||||||
return doc.data();
|
return doc.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getParticleChildren(
|
||||||
|
collectionPath: string,
|
||||||
|
orderByField: string = "created_at",
|
||||||
|
orderDirection: "asc" | "desc" = "asc",
|
||||||
|
): Promise<Particle[]> {
|
||||||
|
const q = query(
|
||||||
|
typedCollection(collectionPath),
|
||||||
|
orderBy(orderByField, orderDirection),
|
||||||
|
);
|
||||||
|
const snap = await getDocs(q);
|
||||||
|
return snap.docs.map((d) => d.data());
|
||||||
|
}
|
||||||
|
|
||||||
export function subscribeToParticleChildren(
|
export function subscribeToParticleChildren(
|
||||||
collectionPath: string,
|
collectionPath: string,
|
||||||
onData: (children: Particle[]) => void,
|
onData: (children: Particle[]) => void,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Downscale an image file to a thumbnail and return an object URL.
|
||||||
|
* Returns undefined for non-image files.
|
||||||
|
* Caller is responsible for revoking the URL via URL.revokeObjectURL().
|
||||||
|
*/
|
||||||
|
export async function createImageThumbnail(
|
||||||
|
file: File,
|
||||||
|
maxDim = 200,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!file.type.startsWith("image/")) return undefined;
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
|
||||||
|
const w = Math.round(bitmap.width * scale);
|
||||||
|
const h = Math.round(bitmap.height * scale);
|
||||||
|
|
||||||
|
const canvas = new OffscreenCanvas(w, h);
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||||
|
bitmap.close();
|
||||||
|
|
||||||
|
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.7 });
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user