feat: support file and image attachments (#98)
* upload & view attachments to particles * allow download of attachments
This commit was merged in pull request #98.
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 { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
|
||||
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 { RecordingOverlay } from "@/features/compose/recording-overlay";
|
||||
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 { 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";
|
||||
|
||||
@@ -41,6 +46,8 @@ export function ComposeOverlay({
|
||||
const [reviewDurationMs, setReviewDurationMs] = useState(0);
|
||||
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
|
||||
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const createParticle = useCreateParticle();
|
||||
@@ -60,6 +67,12 @@ export function ComposeOverlay({
|
||||
onActiveChange?.(step !== "idle");
|
||||
}, [step, onActiveChange]);
|
||||
|
||||
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
|
||||
for (const a of items) {
|
||||
if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setStepSync("idle");
|
||||
setError(null);
|
||||
@@ -68,7 +81,57 @@ export function ComposeOverlay({
|
||||
setReviewBlob(null);
|
||||
setReviewDurationMs(0);
|
||||
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({
|
||||
mode: recordingMode,
|
||||
@@ -111,11 +174,73 @@ export function ComposeOverlay({
|
||||
[networkId],
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const { object_id, upload_url, upload_headers } =
|
||||
await apiClient.prepareUpload({
|
||||
network_id: networkId,
|
||||
name: file.name,
|
||||
content_type: file.type || "application/octet-stream",
|
||||
content_length: file.size,
|
||||
});
|
||||
|
||||
await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
headers: upload_headers,
|
||||
body: file,
|
||||
});
|
||||
|
||||
await apiClient.confirmUpload(object_id);
|
||||
|
||||
return { object_id, size_bytes: file.size };
|
||||
},
|
||||
[networkId],
|
||||
);
|
||||
|
||||
const uploadAttachments = useCallback(
|
||||
async (parentPath: ParticlePath, parentId: string) => {
|
||||
if (attachments.length === 0 || !userId) return;
|
||||
|
||||
const { networkId: netId, segments } = parseParticlePath(parentPath);
|
||||
const childrenPath = particlePath(netId, [...segments, parentId]);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
attachments.map(async (attachment) => {
|
||||
setAttachments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === attachment.id ? { ...a, status: "uploading" as const } : a,
|
||||
),
|
||||
);
|
||||
|
||||
const { object_id } = await uploadFile(attachment.file);
|
||||
|
||||
await createParticle.mutateAsync({
|
||||
path: childrenPath,
|
||||
type: "file",
|
||||
properties: {
|
||||
object_id,
|
||||
filename: attachment.file.name,
|
||||
mime_type: attachment.file.type || "application/octet-stream",
|
||||
size_bytes: attachment.file.size,
|
||||
},
|
||||
createdByHumanId: userId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const failed = results.filter((r) => r.status === "rejected");
|
||||
if (failed.length > 0) {
|
||||
toast.error(`${failed.length} attachment${failed.length > 1 ? "s" : ""} failed to upload`);
|
||||
}
|
||||
},
|
||||
[attachments, userId, uploadFile, createParticle],
|
||||
);
|
||||
|
||||
const createChildParticle = useCallback(
|
||||
async (path: ParticlePath) => {
|
||||
if (!userId) return;
|
||||
|
||||
let particleId = '';
|
||||
let particleId: undefined | string;
|
||||
if (textContent.trim()) {
|
||||
particleId = await createParticle.mutateAsync({
|
||||
path,
|
||||
@@ -142,7 +267,10 @@ export function ComposeOverlay({
|
||||
});
|
||||
}
|
||||
|
||||
onParticleCreated?.(particleId);
|
||||
if (particleId) {
|
||||
await uploadAttachments(path, particleId);
|
||||
onParticleCreated?.(particleId);
|
||||
}
|
||||
},
|
||||
[
|
||||
userId,
|
||||
@@ -152,6 +280,7 @@ export function ComposeOverlay({
|
||||
reviewDurationMs,
|
||||
createParticle,
|
||||
uploadMedia,
|
||||
uploadAttachments,
|
||||
onParticleCreated
|
||||
],
|
||||
);
|
||||
@@ -293,6 +422,11 @@ export function ComposeOverlay({
|
||||
reviewBlob={reviewBlob}
|
||||
error={error}
|
||||
onClose={cancel}
|
||||
attachments={attachments}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onAddFiles={openFilePicker}
|
||||
isDragging={isDragging}
|
||||
dropZoneProps={dropZoneProps}
|
||||
/>
|
||||
)}
|
||||
{step === "typing" && (
|
||||
@@ -301,6 +435,11 @@ export function ComposeOverlay({
|
||||
onTextChange={setTextContent}
|
||||
onAdvance={handleTextAdvance}
|
||||
onCancel={cancel}
|
||||
attachments={attachments}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onAddFiles={openFilePicker}
|
||||
isDragging={isDragging}
|
||||
dropZoneProps={dropZoneProps}
|
||||
/>
|
||||
)}
|
||||
{!targetPath && step === "configuring" && (
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||
import { AttachmentStrip } from "@/features/compose/attachment-strip";
|
||||
import type { PendingAttachment } from "@/features/compose/attachment-strip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface RecordingOverlayProps {
|
||||
step: "recording" | "reviewing";
|
||||
@@ -10,6 +15,16 @@ interface RecordingOverlayProps {
|
||||
reviewBlob: Blob | null;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
attachments: PendingAttachment[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onAddFiles: () => void;
|
||||
isDragging: boolean;
|
||||
dropZoneProps: {
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
function RecordingTimer() {
|
||||
@@ -99,6 +114,11 @@ export function RecordingOverlay({
|
||||
reviewBlob,
|
||||
error,
|
||||
onClose,
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
onAddFiles,
|
||||
isDragging,
|
||||
dropZoneProps,
|
||||
}: RecordingOverlayProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const recordingAudioSource = useAudioSource(mediaStream ?? null);
|
||||
@@ -122,7 +142,13 @@ export function RecordingOverlay({
|
||||
const isLoading = isRecording && !mediaStream;
|
||||
|
||||
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 */}
|
||||
{isLoading && (
|
||||
<div className="z-10 flex flex-col items-center gap-2">
|
||||
@@ -191,19 +217,45 @@ export function RecordingOverlay({
|
||||
)}
|
||||
|
||||
{isReviewing && (
|
||||
<div className="absolute bottom-8 z-10 flex items-center gap-4 text-sm text-white/50">
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
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>
|
||||
<div className="absolute bottom-8 z-10 flex flex-col items-center gap-3">
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-4">
|
||||
<AttachmentStrip
|
||||
attachments={attachments}
|
||||
onRemove={onRemoveAttachment}
|
||||
onAddClick={onAddFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Enter
|
||||
</kbd>{" "}
|
||||
next
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFirstLinkMetadata } from "@/hooks/use-link-metadata";
|
||||
import {
|
||||
LinkPreviewCard,
|
||||
LinkPreviewCardSkeleton,
|
||||
} from "@/components/link-preview-card";
|
||||
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";
|
||||
|
||||
interface TextComposeStepProps {
|
||||
textContent: string;
|
||||
onTextChange: (text: string) => void;
|
||||
onAdvance: () => void;
|
||||
onCancel: () => void;
|
||||
attachments: PendingAttachment[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
onAddFiles: () => void;
|
||||
isDragging: boolean;
|
||||
dropZoneProps: {
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
@@ -26,6 +36,11 @@ export function TextComposeStep({
|
||||
onTextChange,
|
||||
onAdvance,
|
||||
onCancel,
|
||||
attachments,
|
||||
onRemoveAttachment,
|
||||
onAddFiles,
|
||||
isDragging,
|
||||
dropZoneProps,
|
||||
}: TextComposeStepProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -35,7 +50,7 @@ export function TextComposeStep({
|
||||
const t = setTimeout(() => setDebouncedText(textContent), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [textContent]);
|
||||
const { data: metadata, isLoading, url: firstUrl } = useFirstLinkMetadata(debouncedText);
|
||||
const linkPreviews = useAllLinkMetadata(debouncedText);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
@@ -54,14 +69,16 @@ export function TextComposeStep({
|
||||
[onCancel, onAdvance, textContent],
|
||||
);
|
||||
|
||||
const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT;
|
||||
const showPreview = firstUrl && (isLoading || metadata);
|
||||
const hasEnrichments = attachments.length > 0 || linkPreviews.length > 0;
|
||||
const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments;
|
||||
|
||||
const linkPreview = (
|
||||
<>
|
||||
{firstUrl && isLoading && <LinkPreviewCardSkeleton />}
|
||||
{firstUrl && metadata && <LinkPreviewCard metadata={metadata} compact />}
|
||||
</>
|
||||
const strip = (
|
||||
<AttachmentStrip
|
||||
attachments={attachments}
|
||||
onRemove={onRemoveAttachment}
|
||||
onAddClick={onAddFiles}
|
||||
linkPreviews={linkPreviews}
|
||||
/>
|
||||
);
|
||||
|
||||
const keyboardHints = (
|
||||
@@ -78,19 +95,34 @@ export function TextComposeStep({
|
||||
</kbd>{" "}
|
||||
next
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddFiles();
|
||||
}}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
attach
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (immersive) {
|
||||
const style = getImmersiveTextStyle(textContent.length);
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center gap-6 px-6",
|
||||
showPreview ? "flex-row" : "flex-col",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||
isDragging && "ring-2 ring-inset ring-white/30",
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center justify-center gap-6 px-6">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={textContent}
|
||||
@@ -98,14 +130,12 @@ export function TextComposeStep({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
className={cn(
|
||||
"flex-1 resize-none border-none bg-transparent p-8 text-white placeholder-white/40 outline-none",
|
||||
showPreview ? "text-left" : "text-center",
|
||||
"w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
rows={4}
|
||||
/>
|
||||
{showPreview && <div className="shrink-0">{linkPreview}</div>}
|
||||
</div>
|
||||
{keyboardHints}
|
||||
</div>
|
||||
@@ -113,8 +143,14 @@ export function TextComposeStep({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
||||
<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">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||
isDragging && "ring-2 ring-inset ring-white/30",
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
>
|
||||
<div className="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
|
||||
ref={textareaRef}
|
||||
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"
|
||||
rows={6}
|
||||
/>
|
||||
{showPreview && linkPreview}
|
||||
{strip}
|
||||
</div>
|
||||
{keyboardHints}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user