diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 77cc26aa..19b4bab5 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -32,6 +32,14 @@ export default function Image({ const [imageUrl, setImageUrl] = useState(url) const [tried, setTried] = useState(new Set()) + useEffect(() => { + setImageUrl(url) + setIsLoading(true) + setHasError(false) + setDisplayBlurHash(true) + setTried(new Set()) + }, [url]) + useEffect(() => { if (blurHash) { const { numX, numY } = decodeBlurHashSize(blurHash) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 24e5b579..d046b8da 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -19,7 +19,6 @@ import { toast } from 'sonner' import EmojiPickerDialog from '../EmojiPickerDialog' import Mentions from './Mentions' import PollEditor from './PollEditor' -import { usePostEditor } from './PostEditorProvider' import PostOptions from './PostOptions' import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import SendOnlyToSwitch from './SendOnlyToSwitch' @@ -37,13 +36,12 @@ export default function PostContent({ const { t } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() const { addReplies } = useReply() - const { uploadingFiles, setUploadingFiles } = usePostEditor() 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 [uploadProgresses, setUploadProgresses] = useState< + { file: File; progress: number; cancel: () => void }[] + >([]) const [showMoreOptions, setShowMoreOptions] = useState(false) const [addClientTag, setAddClientTag] = useState(false) const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState(undefined) @@ -61,7 +59,7 @@ export default function PostContent({ !!pubkey && !!text && !posting && - !uploadingFiles && + !uploadProgresses.length && (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) useEffect(() => { @@ -161,6 +159,20 @@ export default function PostContent({ setIsPoll((prev) => !prev) } + const handleUploadStart = (file: File, cancel: () => void) => { + setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }]) + } + + const handleUploadProgress = (file: File, progress: number) => { + setUploadProgresses((prev) => + prev.map((item) => (item.file === file ? { ...item, progress } : item)) + ) + } + + const handleUploadEnd = (file: File) => { + setUploadProgresses((prev) => prev.filter((item) => item.file !== file)) + } + return (
{parentEvent && ( @@ -178,17 +190,9 @@ 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)} + onUploadStart={handleUploadStart} + onUploadProgress={handleUploadProgress} + onUploadEnd={handleUploadEnd} /> {isPoll && ( )} + {uploadProgresses.length > 0 && + uploadProgresses.map(({ file, progress, cancel }, index) => ( +
+
+
+ {file.name ?? t('Uploading...')} +
+
+
+
+
+ +
+ ))} {!isPoll && ( )} - {uploadProgress !== null && ( -
-
-
- {uploadFileName ?? t('Uploading...')} -
-
-
-
-
- -
- )}
{ textareaRef.current?.appendText(url, true) }} - onUploadingChange={(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)} + onUploadStart={handleUploadStart} + onUploadEnd={handleUploadEnd} + onProgress={handleUploadProgress} accept="image/*,video/*,audio/*" > - diff --git a/src/components/PostEditor/PostEditorProvider.tsx b/src/components/PostEditor/PostEditorProvider.tsx deleted file mode 100644 index 8781bdd5..00000000 --- a/src/components/PostEditor/PostEditorProvider.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { createContext, Dispatch, SetStateAction, useContext, useState } from 'react' - -type TPostEditorContext = { - uploadingFiles: number - setUploadingFiles: Dispatch> -} - -const PostEditorContext = createContext(undefined) - -export const usePostEditor = () => { - const context = useContext(PostEditorContext) - if (!context) { - throw new Error('usePostEditor must be used within a PostEditorProvider') - } - return context -} - -export function PostEditorProvider({ children }: { children: React.ReactNode }) { - const [uploadingFiles, setUploadingFiles] = useState(0) - - return ( - - {children} - - ) -} diff --git a/src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts b/src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts index 523d89b3..4d94519f 100644 --- a/src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts +++ b/src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts @@ -1,7 +1,6 @@ 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 = [ @@ -13,12 +12,9 @@ const DRAGOVER_CLASS_LIST = [ ] export interface ClipboardAndDropHandlerOptions { - onUploadStart?: (file: File) => void - onUploadSuccess?: (file: File, result: any) => void - onUploadError?: (file: File, error: any) => void - onUploadEnd?: () => void + onUploadStart?: (file: File, cancel: () => void) => void + onUploadEnd?: (file: File) => void onUploadProgress?: (file: File, progress: number) => void - onProvideCancel?: (cancel: () => void) => void } export const ClipboardAndDropHandler = Extension.create({ @@ -57,7 +53,7 @@ export const ClipboardAndDropHandler = Extension.create() + files.forEach((file) => { + const abortController = new AbortController() + abortControllers.set(file, abortController) + options.onUploadStart?.(file, () => abortController.abort()) + }) + await new Promise((resolve) => setTimeout(resolve, 10000)) + for (const file of files) { const name = file.name - options.onUploadStart?.(file) - const placeholder = `[Uploading "${name}"...]` const uploadingNode = view.state.schema.text(placeholder) const hardBreakNode = view.state.schema.nodes.hardBreak.create() @@ -133,16 +135,15 @@ async function uploadFile( tr = tr.insert(tr.selection.from, hardBreakNode) view.dispatch(tr) - const abortController = new AbortController() - options.onProvideCancel?.(() => abortController.abort()) + const abortController = abortControllers.get(file) mediaUpload .upload(file, { onProgress: (p) => options.onUploadProgress?.(file, p), - signal: abortController.signal + signal: abortController?.signal }) .then((result) => { - options.onUploadSuccess?.(file, result) + options.onUploadEnd?.(file) const urlNode = view.state.schema.text(result.url) const tr = view.state.tr @@ -175,11 +176,10 @@ async function uploadFile( insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos))) view.dispatch(insertTr) } - options.onUploadEnd?.() }) .catch((error) => { console.error('Upload failed:', error) - options.onUploadError?.(file, error) + options.onUploadEnd?.(file) const tr = view.state.tr let didReplace = false @@ -200,7 +200,6 @@ 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 bc5aab41..2f60f780 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -13,7 +13,6 @@ import { EditorContent, useEditor } from '@tiptap/react' import { Event } from 'nostr-tools' import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react' import { useTranslation } from 'react-i18next' -import { usePostEditor } from '../PostEditorProvider' import { ClipboardAndDropHandler } from './ClipboardAndDropHandler' import CustomMention from './CustomMention' import Preview from './Preview' @@ -33,124 +32,123 @@ 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 + onUploadStart?: (file: File, cancel: () => void) => void + onUploadProgress?: (file: File, progress: number) => void + onUploadEnd?: (file: File) => void } ->(({ - text = '', - setText, - defaultContent, - parentEvent, - onSubmit, - className, - onUploadStart, - onUploadProgress, - onUploadEnd, - onProvideCancel -}, ref) => { - const { t } = useTranslation() - const { setUploadingFiles } = usePostEditor() - const editor = useEditor({ - extensions: [ - Document, - Paragraph, - Text, - History, - HardBreak, - Placeholder.configure({ - placeholder: t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')' - }), - CustomMention.configure({ - suggestion - }), - ClipboardAndDropHandler.configure({ - onUploadStart: (file) => { - setUploadingFiles((prev) => prev + 1) - onUploadStart?.(file) +>( + ( + { + text = '', + setText, + defaultContent, + parentEvent, + onSubmit, + className, + onUploadStart, + onUploadProgress, + onUploadEnd + }, + ref + ) => { + const { t } = useTranslation() + const editor = useEditor({ + extensions: [ + Document, + Paragraph, + Text, + History, + HardBreak, + Placeholder.configure({ + placeholder: + t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')' + }), + CustomMention.configure({ + suggestion + }), + ClipboardAndDropHandler.configure({ + onUploadStart: (file, cancel) => { + onUploadStart?.(file, cancel) + }, + onUploadEnd: (file) => onUploadEnd?.(file), + onUploadProgress: (file, p) => onUploadProgress?.(file, p) + }) + ], + editorProps: { + attributes: { + class: cn( + 'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + className + ) }, - onUploadSuccess: () => setUploadingFiles((prev) => prev - 1), - onUploadError: () => setUploadingFiles((prev) => prev - 1), - onUploadEnd: () => onUploadEnd?.(), - onUploadProgress: (file, p) => onUploadProgress?.(p, file), - onProvideCancel: (cancel) => onProvideCancel?.(cancel) - }) - ], - editorProps: { - attributes: { - class: cn( - 'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', - className - ) - }, - handleKeyDown: (_view, event) => { - // Handle Ctrl+Enter or Cmd+Enter for submit - if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { - event.preventDefault() - onSubmit?.() - return true - } - return false - } - }, - content: postEditorCache.getPostContentCache({ defaultContent, parentEvent }), - onUpdate(props) { - setText(parseEditorJsonToText(props.editor.getJSON())) - postEditorCache.setPostContentCache({ defaultContent, parentEvent }, props.editor.getJSON()) - }, - onCreate(props) { - setText(parseEditorJsonToText(props.editor.getJSON())) - } - }) - - useImperativeHandle(ref, () => ({ - appendText: (text: string, addNewline = false) => { - if (editor) { - let chain = editor - .chain() - .focus() - .command(({ tr, dispatch }) => { - if (dispatch) { - const endPos = tr.doc.content.size - const selection = TextSelection.create(tr.doc, endPos) - tr.setSelection(selection) - dispatch(tr) - } + handleKeyDown: (_view, event) => { + // Handle Ctrl+Enter or Cmd+Enter for submit + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + event.preventDefault() + onSubmit?.() return true - }) - .insertContent(text) - if (addNewline) { - chain = chain.setHardBreak() + } + return false } - chain.run() + }, + content: postEditorCache.getPostContentCache({ defaultContent, parentEvent }), + onUpdate(props) { + setText(parseEditorJsonToText(props.editor.getJSON())) + postEditorCache.setPostContentCache({ defaultContent, parentEvent }, props.editor.getJSON()) + }, + onCreate(props) { + setText(parseEditorJsonToText(props.editor.getJSON())) } - }, - insertText: (text: string) => { - if (editor) { - editor.chain().focus().insertContent(text).run() + }) + + useImperativeHandle(ref, () => ({ + appendText: (text: string, addNewline = false) => { + if (editor) { + let chain = editor + .chain() + .focus() + .command(({ tr, dispatch }) => { + if (dispatch) { + const endPos = tr.doc.content.size + const selection = TextSelection.create(tr.doc, endPos) + tr.setSelection(selection) + dispatch(tr) + } + return true + }) + .insertContent(text) + if (addNewline) { + chain = chain.setHardBreak() + } + chain.run() + } + }, + insertText: (text: string) => { + if (editor) { + editor.chain().focus().insertContent(text).run() + } } + })) + + if (!editor) { + return null } - })) - if (!editor) { - return null + return ( + + + {t('Edit')} + {t('Preview')} + + + + + + + + + ) } - - return ( - - - {t('Edit')} - {t('Preview')} - - - - - - - - - ) -}) +) PostTextarea.displayName = 'PostTextarea' export default PostTextarea diff --git a/src/components/PostEditor/Uploader.tsx b/src/components/PostEditor/Uploader.tsx index fdcb29d1..2f66d61f 100644 --- a/src/components/PostEditor/Uploader.tsx +++ b/src/components/PostEditor/Uploader.tsx @@ -1,62 +1,57 @@ -import mediaUpload from '@/services/media-upload.service' +import mediaUpload, { UPLOAD_ABORTED_ERROR_MSG } from '@/services/media-upload.service' import { useRef } from 'react' import { toast } from 'sonner' 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 + onUploadStart?: (file: File, cancel: () => void) => void + onUploadEnd?: (file: File) => void + onProgress?: (file: File, progress: number) => void className?: string accept?: string }) { const fileInputRef = useRef(null) - const abortControllerRef = useRef(null) const handleFileChange = async (event: React.ChangeEvent) => { if (!event.target.files) return - onUploadingChange?.(true) - try { - for (const file of event.target.files) { - abortControllerRef.current = new AbortController() - const cancel = () => abortControllerRef.current?.abort() - onProvideCancel?.(cancel) - onUploadStart?.(file) + const abortControllerMap = new Map() + + for (const file of event.target.files) { + const abortController = new AbortController() + abortControllerMap.set(file, abortController) + onUploadStart?.(file, () => abortController.abort()) + } + + for (const file of event.target.files) { + try { + const abortController = abortControllerMap.get(file) const result = await mediaUpload.upload(file, { - onProgress: (p) => onProgress?.(p, file), - signal: abortControllerRef.current.signal + onProgress: (p) => onProgress?.(file, p), + signal: abortController?.signal }) onUploadSuccess(result) - abortControllerRef.current = null - onUploadEnd?.() + onUploadEnd?.(file) + } catch (error) { + console.error('Error uploading file', error) + const message = (error as Error).message + if (message !== UPLOAD_ABORTED_ERROR_MSG) { + toast.error(`Failed to upload file: ${message}`) + } + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + onUploadEnd?.(file) } - } catch (error) { - console.error('Error uploading file', error) - 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) } } diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index 9e976ce7..ba792db3 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -18,7 +18,6 @@ import postEditor from '@/services/post-editor.service' import { Event } from 'nostr-tools' import { Dispatch, useMemo } from 'react' import PostContent from './PostContent' -import { PostEditorProvider } from './PostEditorProvider' import Title from './Title' export default function PostEditor({ @@ -36,13 +35,11 @@ export default function PostEditor({ const content = useMemo(() => { return ( - - setOpen(false)} - /> - + setOpen(false)} + /> ) }, []) diff --git a/src/components/ProfileBanner/index.tsx b/src/components/ProfileBanner/index.tsx index 3e4ee1f6..5bf32d22 100644 --- a/src/components/ProfileBanner/index.tsx +++ b/src/components/ProfileBanner/index.tsx @@ -12,7 +12,7 @@ export default function ProfileBanner({ className?: string }) { const defaultBanner = useMemo(() => generateImageByPubkey(pubkey), [pubkey]) - const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner) + const [bannerUrl, setBannerUrl] = useState(banner ?? defaultBanner) useEffect(() => { if (banner) { diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index 1da08820..63e03b3e 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -128,7 +128,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setTimeout(() => setUploadingBanner(uploading), 50)} + onUploadStart={() => setUploadingBanner(true)} + onUploadEnd={() => setUploadingBanner(false)} className="w-full relative cursor-pointer" > { setTimeout(() => setUploadingAvatar(uploading), 50)} + onUploadStart={() => setUploadingAvatar(true)} + onUploadEnd={() => setUploadingAvatar(false)} className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full" > diff --git a/src/services/media-upload.service.ts b/src/services/media-upload.service.ts index a4aef81a..e39442ac 100644 --- a/src/services/media-upload.service.ts +++ b/src/services/media-upload.service.ts @@ -10,6 +10,8 @@ type UploadOptions = { signal?: AbortSignal } +export const UPLOAD_ABORTED_ERROR_MSG = 'Upload aborted' + class MediaUploadService { static instance: MediaUploadService @@ -55,7 +57,7 @@ class MediaUploadService { } if (options?.signal?.aborted) { - throw new Error('Upload aborted') + throw new Error(UPLOAD_ABORTED_ERROR_MSG) } options?.onProgress?.(0) @@ -115,6 +117,9 @@ class MediaUploadService { } private async uploadByNip96(service: string, file: File, options?: UploadOptions) { + if (options?.signal?.aborted) { + throw new Error(UPLOAD_ABORTED_ERROR_MSG) + } let uploadUrl = this.nip96ServiceUploadUrlMap.get(service) if (!uploadUrl) { const response = await fetch(`${service}/.well-known/nostr/nip96.json`) @@ -133,6 +138,9 @@ class MediaUploadService { this.nip96ServiceUploadUrlMap.set(service, uploadUrl) } + if (options?.signal?.aborted) { + throw new Error(UPLOAD_ABORTED_ERROR_MSG) + } const formData = new FormData() formData.append('file', file) @@ -148,10 +156,10 @@ class MediaUploadService { const handleAbort = () => { try { xhr.abort() - } catch (_) { + } catch { // ignore } - reject(new Error('Upload aborted')) + reject(new Error(UPLOAD_ABORTED_ERROR_MSG)) } if (options?.signal) { if (options.signal.aborted) {