diff --git a/src/components/BlossomUploadDialog.tsx b/src/components/BlossomUploadDialog.tsx
index 9a6d461..e0af497 100644
--- a/src/components/BlossomUploadDialog.tsx
+++ b/src/components/BlossomUploadDialog.tsx
@@ -54,6 +54,8 @@ interface BlossomUploadDialogProps {
onError?: (error: Error) => void;
/** File types to accept (e.g., "image/*,video/*,audio/*") */
accept?: string;
+ /** Optional initial files to pre-select (e.g., from drag-and-drop) */
+ initialFiles?: File[];
}
/**
@@ -72,6 +74,7 @@ export function BlossomUploadDialog({
onCancel,
onError,
accept = "image/*,video/*,audio/*",
+ initialFiles,
}: BlossomUploadDialogProps) {
const eventStore = useEventStore();
const activeAccount = use$(accountManager.active$);
@@ -96,14 +99,29 @@ export function BlossomUploadDialog({
// Reset state when dialog opens
useEffect(() => {
if (open) {
- setSelectedFile(null);
- setPreviewUrl(null);
+ // If initial files provided, set the first one
+ if (initialFiles && initialFiles.length > 0) {
+ const file = initialFiles[0];
+ setSelectedFile(file);
+
+ // Create preview URL for images/video
+ if (file.type.startsWith("image/") || file.type.startsWith("video/")) {
+ const url = URL.createObjectURL(file);
+ setPreviewUrl(url);
+ } else {
+ setPreviewUrl(null);
+ }
+ } else {
+ setSelectedFile(null);
+ setPreviewUrl(null);
+ }
+
setUploadResults([]);
setUploadErrors([]);
setUploading(false);
setUsingFallback(false);
}
- }, [open]);
+ }, [open, initialFiles]);
// Helper to set fallback servers
const applyFallbackServers = useCallback(() => {
diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx
index 53696b5..3ae7102 100644
--- a/src/components/ChatViewer.tsx
+++ b/src/components/ChatViewer.tsx
@@ -1078,7 +1078,7 @@ export function ChatViewer({
variant="ghost"
size="icon"
className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground"
- onClick={openUpload}
+ onClick={() => openUpload()}
disabled={isSending}
>
@@ -1096,6 +1096,10 @@ export function ChatViewer({
searchEmojis={searchEmojis}
searchCommands={searchCommands}
onCommandExecute={handleCommandExecute}
+ onFileDrop={(files) => {
+ // Open upload dialog with dropped files
+ openUpload(files);
+ }}
onSubmit={(content, emojiTags, blobAttachments) => {
if (content.trim()) {
handleSend(content, replyTo, emojiTags, blobAttachments);
diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx
index 94db0df..559dd12 100644
--- a/src/components/editor/MentionEditor.tsx
+++ b/src/components/editor/MentionEditor.tsx
@@ -80,6 +80,7 @@ export interface MentionEditorProps {
searchEmojis?: (query: string) => Promise;
searchCommands?: (query: string) => Promise;
onCommandExecute?: (action: ChatAction) => Promise;
+ onFileDrop?: (files: File[]) => void;
autoFocus?: boolean;
className?: string;
}
@@ -487,6 +488,73 @@ const NostrPasteHandler = Extension.create({
},
});
+// File drop handler extension to intercept file drops and trigger upload
+const FileDropHandler = Extension.create({
+ name: "fileDropHandler",
+
+ addProseMirrorPlugins() {
+ const onFileDrop = this.options.onFileDrop;
+
+ return [
+ new Plugin({
+ key: new PluginKey("fileDropHandler"),
+
+ props: {
+ handleDrop: (_view, event) => {
+ // Check if this is a file drop
+ const files = event.dataTransfer?.files;
+ if (!files || files.length === 0) return false;
+
+ // Check if files are images, videos, or audio (same as upload dialog accepts)
+ const validFiles = Array.from(files).filter((file) =>
+ file.type.match(/^(image|video|audio)\//),
+ );
+
+ if (validFiles.length === 0) return false;
+
+ // Trigger the file drop callback
+ if (onFileDrop) {
+ onFileDrop(validFiles);
+ event.preventDefault();
+ return true; // Prevent default drop behavior
+ }
+
+ return false;
+ },
+
+ handlePaste: (_view, event) => {
+ // Also handle paste events with files (e.g., pasting images from clipboard)
+ const files = event.clipboardData?.files;
+ if (!files || files.length === 0) return false;
+
+ // Check if files are images, videos, or audio
+ const validFiles = Array.from(files).filter((file) =>
+ file.type.match(/^(image|video|audio)\//),
+ );
+
+ if (validFiles.length === 0) return false;
+
+ // Trigger the file drop callback
+ if (onFileDrop) {
+ onFileDrop(validFiles);
+ event.preventDefault();
+ return true; // Prevent default paste behavior
+ }
+
+ return false;
+ },
+ },
+ }),
+ ];
+ },
+
+ addOptions() {
+ return {
+ onFileDrop: undefined,
+ };
+ },
+});
+
export const MentionEditor = forwardRef<
MentionEditorHandle,
MentionEditorProps
@@ -499,6 +567,7 @@ export const MentionEditor = forwardRef<
searchEmojis,
searchCommands,
onCommandExecute,
+ onFileDrop,
autoFocus = false,
className = "",
},
@@ -968,6 +1037,10 @@ export const MentionEditor = forwardRef<
NostrEventPreview,
// Add paste handler to transform bech32 strings into previews
NostrPasteHandler,
+ // Add file drop handler for drag-and-drop file uploads
+ FileDropHandler.configure({
+ onFileDrop,
+ }),
];
// Add emoji extension if search is provided
@@ -1045,6 +1118,7 @@ export const MentionEditor = forwardRef<
emojiSuggestion,
slashCommandSuggestion,
onCommandExecute,
+ onFileDrop,
placeholder,
]);
diff --git a/src/hooks/useBlossomUpload.tsx b/src/hooks/useBlossomUpload.tsx
index aee53a5..a6b83c8 100644
--- a/src/hooks/useBlossomUpload.tsx
+++ b/src/hooks/useBlossomUpload.tsx
@@ -14,8 +14,8 @@ export interface UseBlossomUploadOptions {
}
export interface UseBlossomUploadReturn {
- /** Open the upload dialog */
- open: () => void;
+ /** Open the upload dialog, optionally with pre-selected files */
+ open: (files?: File[]) => void;
/** Close the upload dialog */
close: () => void;
/** Whether the dialog is currently open */
@@ -50,9 +50,20 @@ export function useBlossomUpload(
options: UseBlossomUploadOptions = {},
): UseBlossomUploadReturn {
const [isOpen, setIsOpen] = useState(false);
+ const [initialFiles, setInitialFiles] = useState(
+ undefined,
+ );
- const open = useCallback(() => setIsOpen(true), []);
- const close = useCallback(() => setIsOpen(false), []);
+ const open = useCallback((files?: File[]) => {
+ setInitialFiles(files);
+ setIsOpen(true);
+ }, []);
+
+ const close = useCallback(() => {
+ setIsOpen(false);
+ // Clear initial files when closing
+ setInitialFiles(undefined);
+ }, []);
const handleSuccess = useCallback(
(results: UploadResult[]) => {
@@ -84,9 +95,17 @@ export function useBlossomUpload(
onCancel={handleCancel}
onError={handleError}
accept={options.accept}
+ initialFiles={initialFiles}
/>
),
- [isOpen, handleSuccess, handleCancel, handleError, options.accept],
+ [
+ isOpen,
+ handleSuccess,
+ handleCancel,
+ handleError,
+ options.accept,
+ initialFiles,
+ ],
);
return { open, close, isOpen, dialog };