From 752108fa65f9fa6dedd60ba558f7b156610d09c0 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 15 Nov 2025 13:37:48 +0100 Subject: [PATCH] fixed image upload --- src/components/PostEditor/PostContent.tsx | 354 ++++++++++++++---- .../PostEditor/PostTextarea/index.tsx | 7 + src/components/PostEditor/Uploader.tsx | 176 ++++++--- src/services/media-upload.service.ts | 30 +- 4 files changed, 442 insertions(+), 125 deletions(-) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 5805e50..2f5d8b9 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -38,7 +38,7 @@ import logger from '@/lib/logger' import postEditorCache from '@/services/post-editor-cache.service' import storage from '@/services/local-storage.service' import { TPollCreateData } from '@/types' -import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic } from 'lucide-react' +import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic, Music, Video } from 'lucide-react' import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { hasPrivateRelays, getPrivateRelayUrls, hasCacheRelays, getCacheRelayUrls } from '@/lib/private-relays' import mediaUpload from '@/services/media-upload.service' @@ -49,6 +49,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import EmojiPickerDialog from '../EmojiPickerDialog' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import Mentions, { extractMentions } from './Mentions' import PollEditor from './PollEditor' import PostOptions from './PostOptions' @@ -117,6 +118,8 @@ export default function PostContent({ const [hasPrivateRelaysAvailable, setHasPrivateRelaysAvailable] = useState(false) const [hasCacheRelaysAvailable, setHasCacheRelaysAvailable] = useState(false) const [useCacheOnlyForPrivateNotes, setUseCacheOnlyForPrivateNotes] = useState(true) // Default ON + const [showMediaKindDialog, setShowMediaKindDialog] = useState(false) + const [pendingMediaUpload, setPendingMediaUpload] = useState<{ url: string; tags: string[][]; file: File } | null>(null) const uploadedMediaFileMap = useRef>(new Map()) const isFirstRender = useRef(true) const canPost = useMemo(() => { @@ -1115,6 +1118,192 @@ export default function PostContent({ // Keep file in map until upload success is called } + // Helper function to check if a file could be either audio or video + const isAmbiguousMediaFile = (file: File): boolean => { + if (parentEvent) { + // For replies, we don't show the dialog - audio button only accepts audio/* + return false + } + + const fileType = file.type + const fileName = file.name.toLowerCase() + + // Check if it's a webm or mp4 file that could be either audio or video + const isWebm = /\.webm$/i.test(fileName) + const isMp4 = /\.mp4$/i.test(fileName) + + if (isWebm || isMp4) { + // If MIME type is missing, it's ambiguous + if (!fileType || fileType === 'application/octet-stream') { + return true + } + + const isAudioMime = fileType.startsWith('audio/') + const isVideoMime = fileType.startsWith('video/') + + // If MIME type doesn't clearly indicate one or the other, it's ambiguous + // Some browsers report video/webm for audio-only webm files, so we show the dialog + // to let the user choose + if (isWebm) { + // WebM files are often misreported, so show dialog + return true + } + + if (isMp4) { + // MP4 files can be audio or video - if MIME type is video/mp4 but could be audio, + // or if it's unclear, show dialog + // Only show if MIME type suggests it could be either + if (!isAudioMime && !isVideoMime) { + return true + } + // If it's video/mp4, it could still be audio-only, so show dialog + if (isVideoMime) { + return true + } + } + } + + return false + } + + const handleMediaKindSelection = (selectedKind: number) => { + if (!pendingMediaUpload) return + + const { url, tags, file } = pendingMediaUpload + setShowMediaKindDialog(false) + setPendingMediaUpload(null) + + // Process the upload with the selected kind + processMediaUpload(url, tags, file, selectedKind) + } + + const processMediaUpload = async (url: string, tags: string[][], uploadingFile: File, selectedKind?: number) => { + try { + let kind: number + + if (selectedKind !== undefined) { + // Use the selected kind + kind = selectedKind + } else { + // Auto-detect the kind + kind = await getMediaKindFromFile(uploadingFile, false) + } + + setMediaNoteKind(kind) + + // For picture notes, support multiple images by accumulating imeta tags + if (kind === ExtendedKind.PICTURE) { + // Get imeta tag from media upload service + const imetaTag = mediaUpload.getImetaTagByUrl(url) + let newImetaTag: string[] + if (imetaTag) { + newImetaTag = imetaTag + } else if (tags && tags.length > 0 && tags[0]) { + newImetaTag = tags[0] + } else { + // Create a basic imeta tag if none exists + newImetaTag = ['imeta', `url ${url}`] + if (uploadingFile.type) { + newImetaTag.push(`m ${uploadingFile.type}`) + } + } + + // Accumulate multiple imeta tags for picture notes + setMediaImetaTags(prev => { + // Check if this URL already exists in the tags + const urlExists = prev.some(tag => { + const urlItem = tag.find(item => item.startsWith('url ')) + return urlItem && urlItem.slice(4) === url + }) + if (urlExists) { + return prev // Don't add duplicate + } + return [...prev, newImetaTag] + }) + + // Set the first URL as the primary mediaUrl (for backwards compatibility) + if (!mediaUrl) { + setMediaUrl(url) + } + + // Insert the URL into the editor content so it shows in the edit pane + // Use setTimeout to ensure the state has updated and editor is ready + setTimeout(() => { + if (textareaRef.current) { + // Check the actual editor content, not the state variable (which might be stale) + const currentText = textareaRef.current.getText() + if (!currentText.includes(url)) { + textareaRef.current.appendText(url, true) + } + } + }, 100) + } else { + // For non-picture media, replace the existing tags (single media) + setMediaUrl(url) + const imetaTag = mediaUpload.getImetaTagByUrl(url) + if (imetaTag) { + setMediaImetaTags([imetaTag]) + } else if (tags && tags.length > 0) { + setMediaImetaTags(tags) + } else { + const basicImetaTag: string[] = ['imeta', `url ${url}`] + // Update MIME type based on selected kind + let mimeType = uploadingFile.type + if (selectedKind === ExtendedKind.VOICE || selectedKind === ExtendedKind.VOICE_COMMENT) { + // Ensure audio MIME type + const fileName = uploadingFile.name.toLowerCase() + if (/\.webm$/i.test(fileName)) { + mimeType = 'audio/webm' + } else if (/\.mp4$/i.test(fileName)) { + mimeType = 'audio/mp4' + } + } else if (selectedKind === ExtendedKind.VIDEO || selectedKind === ExtendedKind.SHORT_VIDEO) { + // Ensure video MIME type + const fileName = uploadingFile.name.toLowerCase() + if (/\.webm$/i.test(fileName)) { + mimeType = 'video/webm' + } else if (/\.mp4$/i.test(fileName)) { + mimeType = 'video/mp4' + } + } + if (mimeType) { + basicImetaTag.push(`m ${mimeType}`) + } + setMediaImetaTags([basicImetaTag]) + } + + // Insert the URL into the editor content so it shows in the edit pane + // Use setTimeout to ensure the state has updated and editor is ready + setTimeout(() => { + if (textareaRef.current) { + // Check the actual editor content, not the state variable (which might be stale) + const currentText = textareaRef.current.getText() + if (!currentText.includes(url)) { + textareaRef.current.appendText(url, true) + } + } + }, 100) + } + } catch (error) { + logger.error('Error processing media upload', { error, file: uploadingFile.name }) + // Fallback to picture if processing fails + setMediaNoteKind(ExtendedKind.PICTURE) + const imetaTag = mediaUpload.getImetaTagByUrl(url) + if (imetaTag) { + setMediaImetaTags(prev => [...prev, imetaTag]) + } else { + const basicImetaTag: string[] = ['imeta', `url ${url}`] + if (uploadingFile.type) { + basicImetaTag.push(`m ${uploadingFile.type}`) + } + setMediaImetaTags(prev => [...prev, basicImetaTag]) + } + if (!mediaUrl) { + setMediaUrl(url) + } + } + } + const handleMediaUploadSuccess = async ({ url, tags }: { url: string; tags: string[][] }) => { try { // Find the file from the map - try to match by URL or get the most recent @@ -1236,79 +1425,16 @@ export default function PostContent({ return // Don't set media note kind for non-audio in replies } } else { - // For new posts, use the detected kind (which handles audio > 60s → video) - try { - const kind = await getMediaKindFromFile(uploadingFile, false) - setMediaNoteKind(kind) - - // For picture notes, support multiple images by accumulating imeta tags - if (kind === ExtendedKind.PICTURE) { - // Get imeta tag from media upload service - const imetaTag = mediaUpload.getImetaTagByUrl(url) - let newImetaTag: string[] - if (imetaTag) { - newImetaTag = imetaTag - } else if (tags && tags.length > 0 && tags[0]) { - newImetaTag = tags[0] - } else { - // Create a basic imeta tag if none exists - newImetaTag = ['imeta', `url ${url}`] - if (uploadingFile.type) { - newImetaTag.push(`m ${uploadingFile.type}`) - } - } - - // Accumulate multiple imeta tags for picture notes - setMediaImetaTags(prev => { - // Check if this URL already exists in the tags - const urlExists = prev.some(tag => { - const urlItem = tag.find(item => item.startsWith('url ')) - return urlItem && urlItem.slice(4) === url - }) - if (urlExists) { - return prev // Don't add duplicate - } - return [...prev, newImetaTag] - }) - - // Set the first URL as the primary mediaUrl (for backwards compatibility) - if (!mediaUrl) { - setMediaUrl(url) - } - } else { - // For non-picture media, replace the existing tags (single media) - setMediaUrl(url) - const imetaTag = mediaUpload.getImetaTagByUrl(url) - if (imetaTag) { - setMediaImetaTags([imetaTag]) - } else if (tags && tags.length > 0) { - setMediaImetaTags(tags) - } else { - const basicImetaTag: string[] = ['imeta', `url ${url}`] - if (uploadingFile.type) { - basicImetaTag.push(`m ${uploadingFile.type}`) - } - setMediaImetaTags([basicImetaTag]) - } - } - } catch (error) { - logger.error('Error detecting media kind', { error, file: uploadingFile.name }) - // Fallback to picture if detection fails - setMediaNoteKind(ExtendedKind.PICTURE) - const imetaTag = mediaUpload.getImetaTagByUrl(url) - if (imetaTag) { - setMediaImetaTags(prev => [...prev, imetaTag]) - } else { - const basicImetaTag: string[] = ['imeta', `url ${url}`] - if (uploadingFile.type) { - basicImetaTag.push(`m ${uploadingFile.type}`) - } - setMediaImetaTags(prev => [...prev, basicImetaTag]) - } - if (!mediaUrl) { - setMediaUrl(url) - } + // For new posts, check if file is ambiguous (could be audio or video) + if (isAmbiguousMediaFile(uploadingFile)) { + // Show dialog to let user choose + setPendingMediaUpload({ url, tags, file: uploadingFile }) + setShowMediaKindDialog(true) + return } + + // Not ambiguous, auto-detect and process + await processMediaUpload(url, tags, uploadingFile) } } catch (error) { logger.error('Error in handleMediaUploadSuccess', { error }) @@ -1815,6 +1941,88 @@ export default function PostContent({ {parentEvent ? t('Reply') : t('Post')} + + {/* Media Kind Selection Dialog */} + + + + {t('Select Media Type')} + + {pendingMediaUpload && ( + <> + {t('This file could be either audio or video. Please select the correct type:')} +
+ + {pendingMediaUpload.file.name} + + + )} +
+
+
+ + +
+
+
) } diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index b5fed86..c83daf6 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -29,6 +29,7 @@ export type TPostTextareaHandle = { insertText: (text: string) => void insertEmoji: (emoji: string | TEmoji) => void clear: () => void + getText: () => string } const PostTextarea = forwardRef< @@ -202,6 +203,12 @@ const PostTextarea = forwardRef< postEditorCache.setPostContentCache({ defaultContent, parentEvent }, editor.getJSON()) setText('') } + }, + getText: () => { + if (editor) { + return editor.getText() + } + return '' } })) diff --git a/src/components/PostEditor/Uploader.tsx b/src/components/PostEditor/Uploader.tsx index da01f6e..aba2dd8 100644 --- a/src/components/PostEditor/Uploader.tsx +++ b/src/components/PostEditor/Uploader.tsx @@ -23,75 +23,155 @@ export default function Uploader({ const fileInputRef = useRef(null) const handleFileChange = async (event: React.ChangeEvent) => { - if (!event.target.files) return + // Stop propagation to prevent event bubbling + event.stopPropagation() + + if (!event.target.files) { + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + return + } 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 { - logger.debug('Starting file upload', { fileName: file.name, fileType: file.type, fileSize: file.size }) - const abortController = abortControllerMap.get(file) - const result = await mediaUpload.upload(file, { - onProgress: (p) => { - logger.debug('Upload progress', { fileName: file.name, progress: p }) - onProgress?.(file, p) - }, - signal: abortController?.signal - }) - logger.debug('File upload successful', { fileName: file.name, url: result.url }) - onUploadSuccess(result) - onUploadEnd?.(file) - } catch (error) { - logger.error('Error uploading file', { - error, - fileName: file.name, - fileType: file.type, - fileSize: file.size, - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined - }) - const message = (error as Error).message - if (message !== UPLOAD_ABORTED_ERROR_MSG) { - toast.error(`Failed to upload file: ${message}`) + // Wrap in try-catch to handle any synchronous errors + try { + for (const file of event.target.files) { + const abortController = new AbortController() + abortControllerMap.set(file, abortController) + try { + onUploadStart?.(file, () => abortController.abort()) + } catch (error) { + logger.error('Error in onUploadStart callback', { error, fileName: file.name }) } - if (fileInputRef.current) { - fileInputRef.current.value = '' + } + + for (const file of event.target.files) { + try { + logger.debug('Starting file upload', { fileName: file.name, fileType: file.type, fileSize: file.size }) + const abortController = abortControllerMap.get(file) + const result = await mediaUpload.upload(file, { + onProgress: (p) => { + try { + logger.debug('Upload progress', { fileName: file.name, progress: p }) + onProgress?.(file, p) + } catch (error) { + logger.error('Error in onProgress callback', { error, fileName: file.name }) + } + }, + signal: abortController?.signal + }) + logger.debug('File upload successful', { fileName: file.name, url: result.url }) + try { + onUploadSuccess(result) + } catch (error) { + logger.error('Error in onUploadSuccess callback', { error, fileName: file.name }) + toast.error('Failed to process uploaded file') + } + try { + onUploadEnd?.(file) + } catch (error) { + logger.error('Error in onUploadEnd callback', { error, fileName: file.name }) + } + } catch (error) { + logger.error('Error uploading file', { + error, + fileName: file.name, + fileType: file.type, + fileSize: file.size, + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined + }) + const message = (error as Error).message + if (message !== UPLOAD_ABORTED_ERROR_MSG) { + toast.error(`Failed to upload file: ${message}`) + } + try { + onUploadEnd?.(file) + } catch (endError) { + logger.error('Error in onUploadEnd callback during error handling', { error: endError }) + } } - onUploadEnd?.(file) + } + } catch (error) { + // Catch any unexpected errors in the outer try-catch + logger.error('Unexpected error in handleFileChange', { + error, + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined + }) + toast.error('An unexpected error occurred during file upload') + } finally { + // Always reset the file input value + if (fileInputRef.current) { + fileInputRef.current.value = '' } } } - const handleUploadClick = (e: React.MouseEvent) => { + const handleUploadClick = (e: React.MouseEvent | React.KeyboardEvent) => { e.preventDefault() e.stopPropagation() - e.nativeEvent.stopImmediatePropagation() - if (fileInputRef.current) { - fileInputRef.current.value = '' // clear the value so that the same file can be uploaded again - fileInputRef.current.click() + if (e.nativeEvent) { + e.nativeEvent.stopImmediatePropagation() + } + + // Prevent any form submission + if ('currentTarget' in e && e.currentTarget instanceof HTMLElement) { + const form = e.currentTarget.closest('form') + if (form) { + e.preventDefault() + e.stopPropagation() + } + } + + try { + if (fileInputRef.current) { + fileInputRef.current.value = '' // clear the value so that the same file can be uploaded again + fileInputRef.current.click() + } + } catch (error) { + logger.error('Error triggering file input click', { error }) + toast.error('Failed to open file picker') } } return ( -
e.stopPropagation()}> -
{ - if (e.key === 'Enter' || e.key === ' ') { +
{ + // Only stop propagation, don't prevent default to avoid interfering with file input + e.stopPropagation() + }} + > +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + handleUploadClick(e) + } + }} + onMouseDown={(e) => { + // Prevent any default behavior on mouse down e.preventDefault() - e.stopPropagation() - handleUploadClick(e as any) - } - }}>{children}
+ }} + > + {children} +
{ + // Prevent event bubbling + e.stopPropagation() + }} accept={accept} multiple /> diff --git a/src/services/media-upload.service.ts b/src/services/media-upload.service.ts index 077ef36..f4c3575 100644 --- a/src/services/media-upload.service.ts +++ b/src/services/media-upload.service.ts @@ -148,17 +148,35 @@ class MediaUploadService { // Check if service worker might be interfering const hasServiceWorker = 'serviceWorker' in navigator && navigator.serviceWorker.controller + const isFirefoxMobile = /Firefox/i.test(navigator.userAgent) && /Mobile/i.test(navigator.userAgent) + if (hasServiceWorker) { - console.warn('⚠️ Service worker is active - this may interfere with uploads on mobile', { uploadUrl }) + console.warn('⚠️ Service worker is active - this may interfere with uploads on mobile', { uploadUrl, isFirefoxMobile }) + } + + // For Firefox mobile, add a cache-busting parameter to help bypass service worker + // Also add a timestamp to ensure the request is unique + let finalUploadUrl = uploadUrl as string + if (isFirefoxMobile && hasServiceWorker) { + const separator = finalUploadUrl.includes('?') ? '&' : '?' + finalUploadUrl = `${finalUploadUrl}${separator}_nocache=${Date.now()}&_bypass_sw=1` + console.log('🔧 Firefox mobile: Added cache-busting parameters to upload URL', { finalUploadUrl }) } // Use XMLHttpRequest for upload progress support - // Note: XMLHttpRequest should bypass service workers, but on mobile this isn't always reliable + // Note: XMLHttpRequest should bypass service workers, but on mobile Firefox this isn't always reliable + // We add cache-busting parameters for Firefox mobile to help bypass service worker const result = await new Promise<{ url: string; tags: string[][] }>((resolve, reject) => { const xhr = new XMLHttpRequest() - xhr.open('POST', uploadUrl as string) + xhr.open('POST', finalUploadUrl, true) // async=true to ensure it's not cached xhr.responseType = 'json' xhr.setRequestHeader('Authorization', auth) + // Add headers to prevent caching on Firefox mobile + if (isFirefoxMobile) { + xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + xhr.setRequestHeader('Pragma', 'no-cache') + xhr.setRequestHeader('Expires', '0') + } // Log upload start for debugging console.log('📤 Starting upload', { @@ -222,7 +240,11 @@ class MediaUploadService { let errorMessage = 'Network error' if (xhr.status === 0) { // On mobile, status 0 often means CORS or service worker issue, not necessarily connection failure - errorMessage = 'Upload failed - this may be due to a service worker or CORS issue. Please try refreshing the page or clearing your browser cache.' + if (isFirefoxMobile) { + errorMessage = 'Upload failed on Firefox mobile - this is often due to a service worker issue. Try: 1) Refreshing the page, 2) Clearing browser cache, or 3) Disabling service workers in Firefox settings.' + } else { + errorMessage = 'Upload failed - this may be due to a service worker or CORS issue. Please try refreshing the page or clearing your browser cache.' + } } else if (xhr.status >= 400) { errorMessage = `Upload failed with status ${xhr.status}: ${xhr.statusText || 'Unknown error'}` }