Add Blossom upload dialog with chat integration

- Create BlossomUploadDialog component with file picker, server selection, and preview
- Create useBlossomUpload hook for easy integration in any component
- Add insertText method to MentionEditor for programmatic text insertion
- Integrate upload button (paperclip icon) in chat composer
- Supports image, video, and audio uploads with drag-and-drop
This commit is contained in:
Claude
2026-01-13 13:34:13 +00:00
parent 9e3788e71d
commit 71eb7d814a
4 changed files with 650 additions and 4 deletions

View File

@@ -0,0 +1,505 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { toast } from "sonner";
import {
Upload,
Loader2,
HardDrive,
Image as ImageIcon,
Film,
Music,
FileIcon,
FileText,
Archive,
CheckCircle,
XCircle,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useEventStore } from "applesauce-react/hooks";
import { use$ } from "applesauce-react/hooks";
import accountManager from "@/services/accounts";
import { addressLoader } from "@/services/loaders";
import {
USER_SERVER_LIST_KIND,
getServersFromEvent,
uploadBlobToServers,
type UploadResult,
} from "@/services/blossom";
import type { Subscription } from "rxjs";
interface BlossomUploadDialogProps {
/** Whether the dialog is open */
open: boolean;
/** Callback when dialog open state changes */
onOpenChange: (open: boolean) => void;
/** Called when upload completes successfully */
onSuccess: (results: UploadResult[]) => void;
/** Called when upload is cancelled */
onCancel?: () => void;
/** Called when upload fails */
onError?: (error: Error) => void;
/** File types to accept (e.g., "image/*,video/*,audio/*") */
accept?: string;
}
/**
* BlossomUploadDialog - Modal dialog for uploading files to Blossom servers
*
* Features:
* - File selection with drag & drop support
* - Server selection from user's kind 10063 list
* - Upload progress and results
* - Preview for images/video/audio
*/
export function BlossomUploadDialog({
open,
onOpenChange,
onSuccess,
onCancel,
onError,
accept = "image/*,video/*,audio/*",
}: BlossomUploadDialogProps) {
const eventStore = useEventStore();
const activeAccount = use$(accountManager.active$);
const pubkey = activeAccount?.pubkey;
const [servers, setServers] = useState<string[]>([]);
const [selectedServers, setSelectedServers] = useState<Set<string>>(
new Set(),
);
const [loadingServers, setLoadingServers] = useState(true);
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploadResults, setUploadResults] = useState<UploadResult[]>([]);
const [uploadErrors, setUploadErrors] = useState<
{ server: string; error: string }[]
>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setSelectedFile(null);
setPreviewUrl(null);
setUploadResults([]);
setUploadErrors([]);
setUploading(false);
}
}, [open]);
// Fetch servers when dialog opens
useEffect(() => {
if (!open || !pubkey) {
setLoadingServers(false);
return;
}
setLoadingServers(true);
let subscription: Subscription | null = null;
// Check existing event first
const event = eventStore.getReplaceable(USER_SERVER_LIST_KIND, pubkey, "");
if (event) {
const s = getServersFromEvent(event);
setServers(s);
setSelectedServers(new Set(s)); // Select all by default
setLoadingServers(false);
}
// Also fetch from network
subscription = addressLoader({
kind: USER_SERVER_LIST_KIND,
pubkey,
identifier: "",
}).subscribe({
next: () => {
const e = eventStore.getReplaceable(USER_SERVER_LIST_KIND, pubkey, "");
if (e) {
const s = getServersFromEvent(e);
setServers(s);
setSelectedServers((prev) => (prev.size === 0 ? new Set(s) : prev));
}
setLoadingServers(false);
},
error: () => setLoadingServers(false),
});
const timeout = setTimeout(() => setLoadingServers(false), 3000);
return () => {
subscription?.unsubscribe();
clearTimeout(timeout);
};
}, [open, pubkey, eventStore]);
// Create preview URL for selected file
useEffect(() => {
if (selectedFile && selectedFile.type.startsWith("image/")) {
const url = URL.createObjectURL(selectedFile);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
} else {
setPreviewUrl(null);
}
}, [selectedFile]);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
setSelectedFile(files[0]);
setUploadResults([]);
setUploadErrors([]);
}
},
[],
);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer.files;
if (files && files.length > 0) {
setSelectedFile(files[0]);
setUploadResults([]);
setUploadErrors([]);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const toggleServer = useCallback((server: string) => {
setSelectedServers((prev) => {
const newSet = new Set(prev);
if (newSet.has(server)) {
newSet.delete(server);
} else {
newSet.add(server);
}
return newSet;
});
}, []);
const selectAll = useCallback(
() => setSelectedServers(new Set(servers)),
[servers],
);
const selectNone = useCallback(() => setSelectedServers(new Set()), []);
const handleUpload = async () => {
if (!selectedFile) {
toast.error("No file selected");
return;
}
if (selectedServers.size === 0) {
toast.error("Select at least one server");
return;
}
setUploading(true);
setUploadResults([]);
setUploadErrors([]);
try {
const { results, errors } = await uploadBlobToServers(
selectedFile,
Array.from(selectedServers),
);
setUploadResults(results);
setUploadErrors(errors);
if (results.length > 0) {
toast.success(
`Uploaded to ${results.length}/${selectedServers.size} servers`,
);
// Call success callback with results
onSuccess(results);
} else {
const error = new Error("Upload failed on all servers");
toast.error(error.message);
onError?.(error);
}
} catch (error) {
const err = error instanceof Error ? error : new Error("Upload failed");
toast.error(err.message);
onError?.(err);
} finally {
setUploading(false);
}
};
const handleClose = () => {
if (!uploading) {
onCancel?.();
onOpenChange(false);
}
};
// No account logged in
if (!pubkey) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Upload to Blossom</DialogTitle>
<DialogDescription>
Sign in to upload files to your Blossom servers.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center justify-center py-8 gap-4">
<Upload className="size-12 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Account required to upload files
</p>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="size-5" />
Upload to Blossom
</DialogTitle>
<DialogDescription>
Select a file and choose which servers to upload to.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* File Selection / Drop Zone */}
<div
ref={dropZoneRef}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="border-2 border-dashed rounded-lg p-4 text-center transition-colors hover:border-primary/50 cursor-pointer"
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
disabled={uploading}
/>
{selectedFile ? (
<div className="flex flex-col items-center gap-2">
{previewUrl ? (
<img
src={previewUrl}
alt="Preview"
className="max-h-32 max-w-full rounded object-contain"
/>
) : (
getFileIcon(
selectedFile.type,
"size-12 text-muted-foreground",
)
)}
<p className="font-medium text-sm truncate max-w-full">
{selectedFile.name}
</p>
<p className="text-xs text-muted-foreground">
{formatSize(selectedFile.size)} {" "}
{selectedFile.type || "Unknown"}
</p>
</div>
) : (
<div className="flex flex-col items-center gap-2 py-4">
<Upload className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Click or drop a file here
</p>
<p className="text-xs text-muted-foreground">
Images, videos, or audio
</p>
</div>
)}
</div>
{/* Server Selection */}
{loadingServers ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : servers.length > 0 ? (
<div className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
Servers ({selectedServers.size}/{servers.length})
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={selectAll}
disabled={uploading}
>
All
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={selectNone}
disabled={uploading}
>
None
</Button>
</div>
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{servers.map((server) => (
<label
key={server}
className="flex items-center gap-2 p-1.5 rounded hover:bg-muted/50 cursor-pointer"
>
<Checkbox
checked={selectedServers.has(server)}
onCheckedChange={() => toggleServer(server)}
disabled={uploading}
/>
<HardDrive className="size-3.5 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs truncate flex-1">
{server}
</span>
</label>
))}
</div>
</div>
) : (
<div className="border rounded-lg p-4 text-center">
<HardDrive className="size-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
No Blossom servers configured
</p>
<p className="text-xs text-muted-foreground mt-1">
Publish a kind 10063 server list to use this feature
</p>
</div>
)}
{/* Upload Results */}
{uploadResults.length > 0 && (
<div className="border rounded-lg p-3 bg-green-50 dark:bg-green-950/30">
<div className="flex items-center gap-2 text-green-600 mb-2">
<CheckCircle className="size-4" />
<span className="text-sm font-medium">
Uploaded ({uploadResults.length})
</span>
</div>
<div className="space-y-1">
{uploadResults.map((result) => (
<code
key={result.server}
className="text-xs block truncate text-green-700 dark:text-green-400"
>
{result.blob.url}
</code>
))}
</div>
</div>
)}
{/* Upload Errors */}
{uploadErrors.length > 0 && (
<div className="border rounded-lg p-3 bg-red-50 dark:bg-red-950/30">
<div className="flex items-center gap-2 text-red-600 mb-2">
<XCircle className="size-4" />
<span className="text-sm font-medium">
Failed ({uploadErrors.length})
</span>
</div>
<div className="space-y-1">
{uploadErrors.map((error) => (
<div
key={error.server}
className="text-xs text-red-700 dark:text-red-400"
>
{new URL(error.server).hostname}: {error.error}
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={handleClose}
disabled={uploading}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleUpload}
disabled={
uploading || !selectedFile || selectedServers.size === 0
}
>
{uploading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Uploading...
</>
) : (
<>
<Upload className="size-4 mr-2" />
Upload
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
/**
* Get icon for file type
*/
function getFileIcon(mimeType?: string, className = "size-4") {
if (!mimeType) return <FileIcon className={className} />;
if (mimeType.startsWith("image/")) return <ImageIcon className={className} />;
if (mimeType.startsWith("video/")) return <Film className={className} />;
if (mimeType.startsWith("audio/")) return <Music className={className} />;
if (mimeType.startsWith("text/")) return <FileText className={className} />;
if (mimeType.includes("zip") || mimeType.includes("archive"))
return <Archive className={className} />;
return <FileIcon className={className} />;
}
/**
* Format file size
*/
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

View File

@@ -2,7 +2,14 @@ import { useMemo, useState, memo, useCallback, useRef, useEffect } from "react";
import { use$ } from "applesauce-react/hooks";
import { from, catchError, of, map } from "rxjs";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { Loader2, Reply, Zap, AlertTriangle, RefreshCw } from "lucide-react";
import {
Loader2,
Reply,
Zap,
AlertTriangle,
RefreshCw,
Paperclip,
} from "lucide-react";
import { getZapRequest } from "applesauce-common/helpers/zap";
import { toast } from "sonner";
import accountManager from "@/services/accounts";
@@ -43,6 +50,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "./ui/tooltip";
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
interface ChatViewerProps {
protocol: ChatProtocol;
@@ -333,6 +341,22 @@ export function ChatViewer({
// Emoji search for custom emoji autocomplete
const { searchEmojis } = useEmojiSearch();
// Ref to MentionEditor for programmatic submission
const editorRef = useRef<MentionEditorHandle>(null);
// Blossom upload hook for file attachments
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
accept: "image/*,video/*,audio/*",
onSuccess: (results) => {
if (results.length > 0 && editorRef.current) {
// Insert the first successful upload URL into the editor
const url = results[0].blob.url;
editorRef.current.insertText(url);
editorRef.current.focus();
}
},
});
// Get the appropriate adapter for this protocol
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
@@ -444,9 +468,6 @@ export function ChatViewer({
// Ref to Virtuoso for programmatic scrolling
const virtuosoRef = useRef<VirtuosoHandle>(null);
// Ref to MentionEditor for programmatic submission
const editorRef = useRef<MentionEditorHandle>(null);
// State for send in progress (prevents double-sends)
const [isSending, setIsSending] = useState(false);
@@ -846,6 +867,25 @@ export function ChatViewer({
/>
)}
<div className="flex gap-1.5 items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground"
onClick={openUpload}
disabled={isSending}
>
<Paperclip className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Attach media</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<MentionEditor
ref={editorRef}
placeholder="Type a message..."
@@ -873,6 +913,7 @@ export function ChatViewer({
{isSending ? <Loader2 className="size-3 animate-spin" /> : "Send"}
</Button>
</div>
{uploadDialog}
</div>
) : (
<div className="border-t px-3 py-2 text-center text-sm text-muted-foreground">

View File

@@ -68,6 +68,8 @@ export interface MentionEditorHandle {
getSerializedContent: () => SerializedContent;
isEmpty: () => boolean;
submit: () => void;
/** Insert text at the current cursor position */
insertText: (text: string) => void;
}
// Create emoji extension by extending Mention with a different name and custom node view
@@ -682,6 +684,11 @@ export const MentionEditor = forwardRef<
handleSubmit(editor);
}
},
insertText: (text: string) => {
if (editor) {
editor.chain().focus().insertContent(text).run();
}
},
}),
[editor, serializeContent, handleSubmit],
);

View File

@@ -0,0 +1,93 @@
import { useState, useCallback, useMemo } from "react";
import { BlossomUploadDialog } from "@/components/BlossomUploadDialog";
import type { UploadResult } from "@/services/blossom";
export interface UseBlossomUploadOptions {
/** Called when upload completes successfully */
onSuccess?: (results: UploadResult[]) => void;
/** Called when upload is cancelled */
onCancel?: () => void;
/** Called when upload fails */
onError?: (error: Error) => void;
/** File types to accept (e.g., "image/*,video/*,audio/*") */
accept?: string;
}
export interface UseBlossomUploadReturn {
/** Open the upload dialog */
open: () => void;
/** Close the upload dialog */
close: () => void;
/** Whether the dialog is currently open */
isOpen: boolean;
/** The dialog component to render */
dialog: React.ReactNode;
}
/**
* Hook for managing Blossom file uploads with a dialog
*
* @example
* ```tsx
* function MyComponent() {
* const { open, dialog } = useBlossomUpload({
* onSuccess: (results) => {
* const url = results[0].blob.url;
* insertIntoEditor(url);
* }
* });
*
* return (
* <>
* <button onClick={open}>Upload</button>
* {dialog}
* </>
* );
* }
* ```
*/
export function useBlossomUpload(
options: UseBlossomUploadOptions = {},
): UseBlossomUploadReturn {
const [isOpen, setIsOpen] = useState(false);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const handleSuccess = useCallback(
(results: UploadResult[]) => {
options.onSuccess?.(results);
close();
},
[options.onSuccess, close],
);
const handleCancel = useCallback(() => {
options.onCancel?.();
close();
}, [options.onCancel, close]);
const handleError = useCallback(
(error: Error) => {
options.onError?.(error);
// Don't close on error - let user retry
},
[options.onError],
);
const dialog = useMemo(
() => (
<BlossomUploadDialog
open={isOpen}
onOpenChange={setIsOpen}
onSuccess={handleSuccess}
onCancel={handleCancel}
onError={handleError}
accept={options.accept}
/>
),
[isOpen, handleSuccess, handleCancel, handleError, options.accept],
);
return { open, close, isOpen, dialog };
}