diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index e06e3737..24e5b579 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -11,7 +11,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useReply } from '@/providers/ReplyProvider' import postEditorCache from '@/services/post-editor-cache.service' import { TPollCreateData } from '@/types' -import { ImageUp, ListTodo, LoaderCircle, Settings, Smile } from 'lucide-react' +import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -41,6 +41,9 @@ export default function PostContent({ const [text, setText] = useState('') const textareaRef = useRef(null) const [posting, setPosting] = useState(false) + const [uploadProgress, setUploadProgress] = useState(null) + const [uploadFileName, setUploadFileName] = useState(null) + const cancelRef = useRef<(() => void) | null>(null) const [showMoreOptions, setShowMoreOptions] = useState(false) const [addClientTag, setAddClientTag] = useState(false) const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState(undefined) @@ -175,6 +178,17 @@ export default function PostContent({ parentEvent={parentEvent} onSubmit={() => post()} className={isPoll ? 'min-h-20' : 'min-h-52'} + onUploadStart={(file) => { + setUploadFileName(file.name) + setUploadProgress(0) + }} + onUploadProgress={(p) => setUploadProgress(p)} + onUploadEnd={() => { + setUploadProgress(null) + setUploadFileName(null) + cancelRef.current = null + }} + onProvideCancel={(cancel) => (cancelRef.current = cancel)} /> {isPoll && ( )} + {uploadProgress !== null && ( +
+
+
+ {uploadFileName ?? t('Uploading...')} +
+
+
+
+
+ +
+ )}
setUploadingFiles((prev) => (uploading ? prev + 1 : prev - 1)) } + onUploadStart={(file) => { + setUploadFileName(file.name) + setUploadProgress(0) + }} + onUploadEnd={() => { + setUploadProgress(null) + setUploadFileName(null) + cancelRef.current = null + }} + onProgress={(p) => setUploadProgress(p)} + onProvideCancel={(cancel) => (cancelRef.current = cancel)} accept="image/*,video/*,audio/*" > {/* I'm not sure why, but after triggering the virtual keyboard, diff --git a/src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts b/src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts index 79de33c4..523d89b3 100644 --- a/src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts +++ b/src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts @@ -1,6 +1,7 @@ import mediaUpload from '@/services/media-upload.service' import { Extension } from '@tiptap/core' import { EditorView } from '@tiptap/pm/view' +import { Slice } from '@tiptap/pm/model' import { Plugin, TextSelection } from 'prosemirror-state' const DRAGOVER_CLASS_LIST = [ @@ -15,6 +16,9 @@ export interface ClipboardAndDropHandlerOptions { onUploadStart?: (file: File) => void onUploadSuccess?: (file: File, result: any) => void onUploadError?: (file: File, error: any) => void + onUploadEnd?: () => void + onUploadProgress?: (file: File, progress: number) => void + onProvideCancel?: (cancel: () => void) => void } export const ClipboardAndDropHandler = Extension.create({ @@ -24,7 +28,10 @@ export const ClipboardAndDropHandler = Extension.create item.type.includes('image') || item.type.includes('video') - ) - if (!mediaFiles.length) return false - - uploadFile(view, mediaFiles, options) - return true } }, + handleDrop(view: EditorView, event: DragEvent, _slice: Slice, _moved: boolean) { + event.preventDefault() + event.stopPropagation() + view.dom.classList.remove(...DRAGOVER_CLASS_LIST) + + const items = Array.from(event.dataTransfer?.files ?? []) + const mediaFiles = items.filter( + (item) => item.type.includes('image') || item.type.includes('video') + ) + if (!mediaFiles.length) return false + + uploadFile(view, mediaFiles, options) + return true + }, handlePaste(view, event) { const items = Array.from(event.clipboardData?.items ?? []) let handled = false @@ -121,8 +133,14 @@ async function uploadFile( tr = tr.insert(tr.selection.from, hardBreakNode) view.dispatch(tr) + const abortController = new AbortController() + options.onProvideCancel?.(() => abortController.abort()) + mediaUpload - .upload(file) + .upload(file, { + onProgress: (p) => options.onUploadProgress?.(file, p), + signal: abortController.signal + }) .then((result) => { options.onUploadSuccess?.(file, result) const urlNode = view.state.schema.text(result.url) @@ -157,6 +175,7 @@ async function uploadFile( insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos))) view.dispatch(insertTr) } + options.onUploadEnd?.() }) .catch((error) => { console.error('Upload failed:', error) @@ -181,7 +200,7 @@ async function uploadFile( if (didReplace) { view.dispatch(tr) } - + options.onUploadEnd?.() throw error }) } diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index 3452f8ba..bc5aab41 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -33,8 +33,23 @@ const PostTextarea = forwardRef< parentEvent?: Event onSubmit?: () => void className?: string + onUploadStart?: (file: File) => void + onUploadProgress?: (progress: number, file: File) => void + onUploadEnd?: () => void + onProvideCancel?: (cancel: () => void) => void } ->(({ text = '', setText, defaultContent, parentEvent, onSubmit, className }, ref) => { +>(({ + text = '', + setText, + defaultContent, + parentEvent, + onSubmit, + className, + onUploadStart, + onUploadProgress, + onUploadEnd, + onProvideCancel +}, ref) => { const { t } = useTranslation() const { setUploadingFiles } = usePostEditor() const editor = useEditor({ @@ -51,9 +66,15 @@ const PostTextarea = forwardRef< suggestion }), ClipboardAndDropHandler.configure({ - onUploadStart: () => setUploadingFiles((prev) => prev + 1), + onUploadStart: (file) => { + setUploadingFiles((prev) => prev + 1) + onUploadStart?.(file) + }, onUploadSuccess: () => setUploadingFiles((prev) => prev - 1), - onUploadError: () => setUploadingFiles((prev) => prev - 1) + onUploadError: () => setUploadingFiles((prev) => prev - 1), + onUploadEnd: () => onUploadEnd?.(), + onUploadProgress: (file, p) => onUploadProgress?.(p, file), + onProvideCancel: (cancel) => onProvideCancel?.(cancel) }) ], editorProps: { diff --git a/src/components/PostEditor/Uploader.tsx b/src/components/PostEditor/Uploader.tsx index 4a731306..fdcb29d1 100644 --- a/src/components/PostEditor/Uploader.tsx +++ b/src/components/PostEditor/Uploader.tsx @@ -6,16 +6,25 @@ export default function Uploader({ children, onUploadSuccess, onUploadingChange, + onUploadStart, + onUploadEnd, + onProgress, + onProvideCancel, className, accept = 'image/*' }: { children: React.ReactNode onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void onUploadingChange?: (uploading: boolean) => void + onUploadStart?: (file: File) => void + onUploadEnd?: () => void + onProgress?: (progress: number, file: File) => void + onProvideCancel?: (cancel: () => void) => void className?: string accept?: string }) { const fileInputRef = useRef(null) + const abortControllerRef = useRef(null) const handleFileChange = async (event: React.ChangeEvent) => { if (!event.target.files) return @@ -23,15 +32,29 @@ export default function Uploader({ onUploadingChange?.(true) try { for (const file of event.target.files) { - const result = await mediaUpload.upload(file) + abortControllerRef.current = new AbortController() + const cancel = () => abortControllerRef.current?.abort() + onProvideCancel?.(cancel) + onUploadStart?.(file) + const result = await mediaUpload.upload(file, { + onProgress: (p) => onProgress?.(p, file), + signal: abortControllerRef.current.signal + }) onUploadSuccess(result) + abortControllerRef.current = null + onUploadEnd?.() } } catch (error) { console.error('Error uploading file', error) - toast.error(`Failed to upload file: ${(error as Error).message}`) + const message = (error as Error).message + if (message !== 'Upload aborted') { + toast.error(`Failed to upload file: ${message}`) + } if (fileInputRef.current) { fileInputRef.current.value = '' } + abortControllerRef.current = null + onUploadEnd?.() } finally { onUploadingChange?.(false) } @@ -45,8 +68,8 @@ export default function Uploader({ } return ( -
- {children} +
+
{children}
void + signal?: AbortSignal +} + class MediaUploadService { static instance: MediaUploadService @@ -23,12 +28,12 @@ class MediaUploadService { this.serviceConfig = config } - async upload(file: File) { + async upload(file: File, options?: UploadOptions) { let result: { url: string; tags: string[][] } if (this.serviceConfig.type === 'nip96') { - result = await this.uploadByNip96(this.serviceConfig.service, file) + result = await this.uploadByNip96(this.serviceConfig.service, file, options) } else { - result = await this.uploadByBlossom(file) + result = await this.uploadByBlossom(file, options) } if (result.tags.length > 0) { @@ -37,7 +42,7 @@ class MediaUploadService { return result } - private async uploadByBlossom(file: File) { + private async uploadByBlossom(file: File, options?: UploadOptions) { const pubkey = client.pubkey const signer = async (draft: TDraftEvent) => { if (!client.signer) { @@ -49,6 +54,34 @@ class MediaUploadService { throw new Error('You need to be logged in to upload media') } + if (options?.signal?.aborted) { + throw new Error('Upload aborted') + } + + options?.onProgress?.(0) + + // Pseudo-progress: advance gradually until main upload completes + let pseudoProgress = 1 + let pseudoTimer: number | undefined + const startPseudoProgress = () => { + if (pseudoTimer !== undefined) return + pseudoTimer = window.setInterval(() => { + // Cap pseudo progress to 90% until we get real completion + pseudoProgress = Math.min(pseudoProgress + 3, 90) + options?.onProgress?.(pseudoProgress) + if (pseudoProgress >= 90) { + stopPseudoProgress() + } + }, 300) + } + const stopPseudoProgress = () => { + if (pseudoTimer !== undefined) { + clearInterval(pseudoTimer) + pseudoTimer = undefined + } + } + startPseudoProgress() + const servers = await client.fetchBlossomServerList(pubkey) if (servers.length === 0) { throw new Error('No Blossom services available') @@ -61,6 +94,9 @@ class MediaUploadService { // first upload blob to main server const blob = await BlossomClient.uploadBlob(mainServer, file, { auth }) + // Main upload finished + stopPseudoProgress() + options?.onProgress?.(80) if (mirrorServers.length > 0) { await Promise.allSettled( @@ -74,10 +110,11 @@ class MediaUploadService { tags = parseResult.data } + options?.onProgress?.(100) return { url: blob.url, tags } } - private async uploadByNip96(service: string, file: File) { + private async uploadByNip96(service: string, file: File, options?: UploadOptions) { let uploadUrl = this.nip96ServiceUploadUrlMap.get(service) if (!uploadUrl) { const response = await fetch(`${service}/.well-known/nostr/nip96.json`) @@ -100,26 +137,58 @@ class MediaUploadService { formData.append('file', file) const auth = await client.signHttpAuth(uploadUrl, 'POST', 'Uploading media file') - const response = await fetch(uploadUrl, { - method: 'POST', - body: formData, - headers: { - Authorization: auth + + // Use XMLHttpRequest for upload progress support + const result = await new Promise<{ url: string; tags: string[][] }>((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', uploadUrl as string) + xhr.responseType = 'json' + xhr.setRequestHeader('Authorization', auth) + + const handleAbort = () => { + try { + xhr.abort() + } catch (_) { + // ignore + } + reject(new Error('Upload aborted')) } + if (options?.signal) { + if (options.signal.aborted) { + return handleAbort() + } + options.signal.addEventListener('abort', handleAbort, { once: true }) + } + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percent = Math.round((event.loaded / event.total) * 100) + options?.onProgress?.(percent) + } + } + xhr.onerror = () => reject(new Error('Network error')) + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + const data = xhr.response + try { + const tags = z.array(z.array(z.string())).parse(data?.nip94_event?.tags ?? []) + const url = tags.find(([tagName]: string[]) => tagName === 'url')?.[1] + if (url) { + resolve({ url, tags }) + } else { + reject(new Error('No url found')) + } + } catch (e) { + reject(e as Error) + } + } else { + reject(new Error(xhr.status.toString() + ' ' + xhr.statusText)) + } + } + xhr.send(formData) }) - if (!response.ok) { - throw new Error(response.status.toString() + ' ' + response.statusText) - } - - const data = await response.json() - const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? []) - const url = tags.find(([tagName]) => tagName === 'url')?.[1] - if (url) { - return { url, tags } - } else { - throw new Error('No url found') - } + return result } getImetaTagByUrl(url: string) {