Browse Source

bug-fixing

imwald
Silberengel 4 months ago
parent
commit
fa262cd906
  1. 452
      src/components/CacheRelaysSetting/index.tsx
  2. 785
      src/components/PostEditor/PostContent.tsx
  3. 27
      src/components/PostEditor/PostTextarea/Preview.tsx
  4. 71
      src/components/PostEditor/PostTextarea/index.tsx
  5. 31
      src/components/PostEditor/Uploader.tsx
  6. 6
      src/components/Profile/ProfileFeed.tsx
  7. 4
      src/components/Profile/ProfileMedia.tsx
  8. 51
      src/lib/kind-description.ts
  9. 24
      src/lib/media-kind-detection.ts
  10. 4
      src/lib/url.ts
  11. 31
      src/pages/secondary/PostSettingsPage/CacheRelayOnlySetting.tsx
  12. 19
      src/services/indexed-db.service.ts
  13. 101
      src/services/media-upload.service.ts
  14. 19
      vite.config.ts

452
src/components/CacheRelaysSetting/index.tsx

@ -29,8 +29,9 @@ import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays' @@ -29,8 +29,9 @@ import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays'
import { createCacheRelaysDraftEvent } from '@/lib/draft-event'
import { getRelayListFromEvent } from '@/lib/event-metadata'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import { CloudUpload, Loader, Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert } from 'lucide-react'
import { CloudUpload, Loader, Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Terminal, XCircle } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import indexedDb from '@/services/indexed-db.service'
import postEditorCache from '@/services/post-editor-cache.service'
import { StorageKey } from '@/constants'
@ -56,6 +57,11 @@ export default function CacheRelaysSetting() { @@ -56,6 +57,11 @@ export default function CacheRelaysSetting() {
const [loadingItems, setLoadingItems] = useState(false)
const [wordWrapEnabled, setWordWrapEnabled] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [consoleLogs, setConsoleLogs] = useState<Array<{ type: string; message: string; timestamp: number }>>([])
const [showConsoleLogs, setShowConsoleLogs] = useState(false)
const [consoleLogSearch, setConsoleLogSearch] = useState('')
const [consoleLogLevel, setConsoleLogLevel] = useState<'error' | 'warn' | 'info' | 'log' | 'all'>('error')
const consoleLogRef = useRef<Array<{ type: string; message: string; timestamp: number }>>([])
const sensors = useSensors(
useSensor(PointerSensor, {
@ -214,14 +220,43 @@ export default function CacheRelaysSetting() { @@ -214,14 +220,43 @@ export default function CacheRelaysSetting() {
}
})
// Clear service worker caches
// Clear only this app's service worker caches
if ('caches' in window) {
try {
const cacheNames = await caches.keys()
await Promise.all(
cacheNames
.filter(name => name.includes('nostr') || name.includes('satellite') || name.includes('external'))
.map(name => caches.delete(name))
)
const currentOrigin = window.location.origin
// App-specific cache names (from vite.config.ts)
const appCacheNames = [
'nostr-images',
'satellite-images',
'external-images'
]
// Workbox precache caches (typically start with 'workbox-' or 'precache-')
// and any cache that might be from this app
const appCaches = cacheNames.filter(name => {
// Check if it's one of our named caches
if (appCacheNames.includes(name)) {
return true
}
// Check if it's a workbox precache cache
if (name.startsWith('workbox-') || name.startsWith('precache-')) {
return true
}
// Check if it's a workbox runtime cache (might have our origin in the name)
if (name.includes(currentOrigin.replace(/https?:\/\//, '').split('/')[0])) {
return true
}
return false
})
await Promise.all(appCaches.map(name => caches.delete(name).catch(error => {
logger.warn(`Failed to delete cache: ${name}`, { error })
})))
} catch (error) {
logger.warn('Failed to clear some service worker caches', { error })
}
}
// Clear post editor cache
@ -260,6 +295,212 @@ export default function CacheRelaysSetting() { @@ -260,6 +295,212 @@ export default function CacheRelaysSetting() {
loadCacheInfo()
}
const handleClearServiceWorker = async () => {
if (!confirm(t('Are you sure you want to unregister the service worker? This will clear this app\'s service worker caches and you will need to reload the page.'))) {
return
}
try {
const currentOrigin = window.location.origin
let unregisteredCount = 0
let cacheClearedCount = 0
// Check for service worker support
if ('serviceWorker' in navigator) {
// Get all service worker registrations
let registrations: readonly ServiceWorkerRegistration[] = []
try {
registrations = await navigator.serviceWorker.getRegistrations()
} catch (error) {
logger.warn('Failed to get service worker registrations', { error })
}
// Only unregister service workers for this origin/app
if (registrations.length > 0) {
const unregisterPromises = registrations.map(async (registration) => {
try {
// Check if this service worker is for this origin
const scope = registration.scope
if (scope.startsWith(currentOrigin)) {
const result = await registration.unregister()
if (result) {
unregisteredCount++
}
return result
}
return false
} catch (error) {
logger.warn('Failed to unregister a service worker', { error })
return false
}
})
await Promise.all(unregisterPromises)
}
}
// Clear only this app's caches
if ('caches' in window) {
try {
const cacheNames = await caches.keys()
// App-specific cache names (from vite.config.ts)
const appCacheNames = [
'nostr-images',
'satellite-images',
'external-images'
]
// Workbox precache caches (typically start with 'workbox-' or 'precache-')
// and any cache that might be from this app
const appCaches = cacheNames.filter(name => {
// Check if it's one of our named caches
if (appCacheNames.includes(name)) {
return true
}
// Check if it's a workbox precache cache
if (name.startsWith('workbox-') || name.startsWith('precache-')) {
return true
}
// Check if it's a workbox runtime cache (might have our origin in the name)
if (name.includes(currentOrigin.replace(/https?:\/\//, '').split('/')[0])) {
return true
}
return false
})
await Promise.all(appCaches.map(name => {
cacheClearedCount++
return caches.delete(name).catch(error => {
logger.warn(`Failed to delete cache: ${name}`, { error })
cacheClearedCount--
})
}))
} catch (error) {
logger.warn('Failed to clear some caches', { error })
}
}
if (unregisteredCount > 0 || cacheClearedCount > 0) {
const message = unregisteredCount > 0 && cacheClearedCount > 0
? t('Service worker unregistered and caches cleared. Please reload the page.')
: unregisteredCount > 0
? t('Service worker unregistered. Please reload the page.')
: t('Service worker caches cleared. Please reload the page.')
toast.success(message)
// Reload after a short delay
setTimeout(() => {
window.location.reload()
}, 1000)
} else {
toast.info(t('No service workers or caches found for this app'))
}
} catch (error) {
logger.error('Failed to unregister service worker', { error })
toast.error(t('Failed to unregister service worker: ') + (error instanceof Error ? error.message : String(error)))
}
}
// Capture console logs - start capturing immediately when component mounts
useEffect(() => {
const originalLog = console.log
const originalError = console.error
const originalWarn = console.warn
const originalInfo = console.info
const captureLog = (type: string, ...args: any[]) => {
const message = args.map(arg => {
if (typeof arg === 'object') {
try {
return JSON.stringify(arg, null, 2)
} catch {
return String(arg)
}
}
return String(arg)
}).join(' ')
const logEntry = {
type,
message,
timestamp: Date.now()
}
consoleLogRef.current.push(logEntry)
// Keep only last 1000 logs
if (consoleLogRef.current.length > 1000) {
consoleLogRef.current = consoleLogRef.current.slice(-1000)
}
// Update state if dialog is open
if (showConsoleLogs) {
setConsoleLogs([...consoleLogRef.current])
}
}
console.log = (...args: any[]) => {
captureLog('log', ...args)
originalLog.apply(console, args)
}
console.error = (...args: any[]) => {
captureLog('error', ...args)
originalError.apply(console, args)
}
console.warn = (...args: any[]) => {
captureLog('warn', ...args)
originalWarn.apply(console, args)
}
console.info = (...args: any[]) => {
captureLog('info', ...args)
originalInfo.apply(console, args)
}
return () => {
console.log = originalLog
console.error = originalError
console.warn = originalWarn
console.info = originalInfo
}
}, [showConsoleLogs])
const handleShowConsoleLogs = () => {
setConsoleLogs([...consoleLogRef.current])
setShowConsoleLogs(true)
// Reset filters when opening
setConsoleLogSearch('')
setConsoleLogLevel('error')
}
const handleClearConsoleLogs = () => {
consoleLogRef.current = []
setConsoleLogs([])
toast.success(t('Console logs cleared'))
}
// Filter console logs based on search query and log level
const filteredConsoleLogs = useMemo(() => {
let filtered = [...consoleLogs]
// Filter by log level
if (consoleLogLevel !== 'all') {
filtered = filtered.filter(log => log.type === consoleLogLevel)
}
// Filter by search query
if (consoleLogSearch.trim()) {
const query = consoleLogSearch.toLowerCase().trim()
filtered = filtered.filter(log =>
log.message.toLowerCase().includes(query) ||
log.type.toLowerCase().includes(query)
)
}
return filtered
}, [consoleLogs, consoleLogSearch, consoleLogLevel])
const handleStoreClick = async (storeName: string) => {
setSelectedStore(storeName)
setSearchQuery('')
@ -578,6 +819,22 @@ export default function CacheRelaysSetting() { @@ -578,6 +819,22 @@ export default function CacheRelaysSetting() {
<Database className="h-4 w-4 mr-2" />
{t('Browse Cache')}
</Button>
<Button
variant="outline"
className="flex-1 w-full sm:w-auto"
onClick={handleClearServiceWorker}
>
<XCircle className="h-4 w-4 mr-2" />
{t('Clear Service Worker')}
</Button>
<Button
variant="outline"
className="flex-1 w-full sm:w-auto"
onClick={handleShowConsoleLogs}
>
<Terminal className="h-4 w-4 mr-2" />
{t('View Console Logs')} ({consoleLogRef.current.length})
</Button>
</div>
{Object.keys(cacheInfo).length > 0 && (
<div className="text-xs text-muted-foreground space-y-1 mt-2">
@ -952,6 +1209,187 @@ export default function CacheRelaysSetting() { @@ -952,6 +1209,187 @@ export default function CacheRelaysSetting() {
</DialogContent>
</Dialog>
)}
{/* Console Logs Dialog */}
{isSmallScreen ? (
<Drawer open={showConsoleLogs} onOpenChange={setShowConsoleLogs}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader>
<div className="flex items-center justify-between">
<div className="flex-1">
<DrawerTitle>{t('Console Logs')}</DrawerTitle>
<DrawerDescription>
{t('View recent console logs for debugging')} ({filteredConsoleLogs.length} / {consoleLogs.length} {t('entries')})
</DrawerDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleClearConsoleLogs}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('Clear')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowConsoleLogs(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</DrawerHeader>
<div className="px-4 pb-2 space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<Input
placeholder={t('Search logs...')}
value={consoleLogSearch}
onChange={(e) => setConsoleLogSearch(e.target.value)}
className="flex-1"
/>
<Select value={consoleLogLevel} onValueChange={(value: 'error' | 'warn' | 'info' | 'log' | 'all') => setConsoleLogLevel(value)}>
<SelectTrigger className="w-full sm:w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="error">{t('Error')}</SelectItem>
<SelectItem value="warn">{t('Warning')}</SelectItem>
<SelectItem value="info">{t('Info')}</SelectItem>
<SelectItem value="log">{t('Log')}</SelectItem>
<SelectItem value="all">{t('All')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-auto px-4 pb-4">
<div className="space-y-1 font-mono text-xs">
{filteredConsoleLogs.length === 0 ? (
<div className="text-muted-foreground p-4 text-center">
{consoleLogs.length === 0
? t('No console logs captured yet')
: t('No logs match the current filters')
}
</div>
) : (
filteredConsoleLogs.map((log, index) => (
<div
key={index}
className={`p-2 rounded border ${
log.type === 'error' ? 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800' :
log.type === 'warn' ? 'bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800' :
'bg-background border-border'
}`}
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
[{log.type}]
</span>
<pre className="flex-1 overflow-x-auto whitespace-pre-wrap break-words">
{log.message}
</pre>
</div>
</div>
))
)}
</div>
</div>
</DrawerContent>
</Drawer>
) : (
<Dialog open={showConsoleLogs} onOpenChange={setShowConsoleLogs}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex-1">
<DialogTitle>{t('Console Logs')}</DialogTitle>
<DialogDescription>
{t('View recent console logs for debugging')} ({filteredConsoleLogs.length} / {consoleLogs.length} {t('entries')})
</DialogDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleClearConsoleLogs}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('Clear')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowConsoleLogs(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</DialogHeader>
<div className="px-6 pb-4 space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<Input
placeholder={t('Search logs...')}
value={consoleLogSearch}
onChange={(e) => setConsoleLogSearch(e.target.value)}
className="flex-1"
/>
<Select value={consoleLogLevel} onValueChange={(value: 'error' | 'warn' | 'info' | 'log' | 'all') => setConsoleLogLevel(value)}>
<SelectTrigger className="w-full sm:w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="error">{t('Error')}</SelectItem>
<SelectItem value="warn">{t('Warning')}</SelectItem>
<SelectItem value="info">{t('Info')}</SelectItem>
<SelectItem value="log">{t('Log')}</SelectItem>
<SelectItem value="all">{t('All')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-4">
<div className="space-y-1 font-mono text-xs">
{filteredConsoleLogs.length === 0 ? (
<div className="text-muted-foreground p-4 text-center">
{consoleLogs.length === 0
? t('No console logs captured yet')
: t('No logs match the current filters')
}
</div>
) : (
filteredConsoleLogs.map((log, index) => (
<div
key={index}
className={`p-2 rounded border ${
log.type === 'error' ? 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800' :
log.type === 'warn' ? 'bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800' :
'bg-background border-border'
}`}
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
[{log.type}]
</span>
<pre className="flex-1 overflow-x-auto whitespace-pre-wrap break-words">
{log.message}
</pre>
</div>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
)
}

785
src/components/PostEditor/PostContent.tsx

@ -38,7 +38,7 @@ import logger from '@/lib/logger' @@ -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 } from 'lucide-react'
import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic } 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'
@ -249,11 +249,380 @@ export default function PostContent({ @@ -249,11 +249,380 @@ export default function PostContent({
}, [pubkey])
// Load cache-only preference from localStorage
// Default depends on whether cache relays exist
useEffect(() => {
const updateCachePreference = async () => {
if (!pubkey) {
setUseCacheOnlyForPrivateNotes(false)
return
}
const hasCache = await hasCacheRelays(pubkey).catch(() => false)
if (hasCache) {
// If cache exists, load from localStorage or default to true (ON)
const stored = window.localStorage.getItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES)
// Default to true (ON) if not set
setUseCacheOnlyForPrivateNotes(stored === null ? true : stored === 'true')
}, [])
} else {
// If no cache, default to false (OFF) - use only outboxes
setUseCacheOnlyForPrivateNotes(false)
}
}
updateCachePreference()
}, [pubkey])
// Helper function to determine the kind that will be created
const getDeterminedKind = useMemo((): number => {
// For voice comments in replies, check mediaNoteKind even if mediaUrl is not set yet (for preview)
// Debug logging
console.log('🔍 getDeterminedKind: checking', {
parentEvent: !!parentEvent,
mediaNoteKind,
VOICE_COMMENT: ExtendedKind.VOICE_COMMENT,
match: parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT
})
if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) {
console.log('✅ getDeterminedKind: returning VOICE_COMMENT')
return ExtendedKind.VOICE_COMMENT
} else if (mediaNoteKind !== null && mediaUrl) {
return mediaNoteKind
} else if (isLongFormArticle) {
return kinds.LongFormArticle
} else if (isWikiArticle) {
return ExtendedKind.WIKI_ARTICLE
} else if (isWikiArticleMarkdown) {
return ExtendedKind.WIKI_ARTICLE_MARKDOWN
} else if (isPublicationContent) {
return ExtendedKind.PUBLICATION_CONTENT
} else if (isCitationInternal) {
return ExtendedKind.CITATION_INTERNAL
} else if (isCitationExternal) {
return ExtendedKind.CITATION_EXTERNAL
} else if (isCitationHardcopy) {
return ExtendedKind.CITATION_HARDCOPY
} else if (isCitationPrompt) {
return ExtendedKind.CITATION_PROMPT
} else if (isHighlight) {
return kinds.Highlights
} else if (isPublicMessage) {
return ExtendedKind.PUBLIC_MESSAGE
} else if (isPoll) {
return ExtendedKind.POLL
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
return ExtendedKind.PUBLIC_MESSAGE
} else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) {
console.log('⚠ getDeterminedKind: falling through to COMMENT', {
parentEvent: !!parentEvent,
parentEventKind: parentEvent?.kind,
mediaNoteKind,
mediaUrl
})
return ExtendedKind.COMMENT
} else {
return kinds.ShortTextNote
}
}, [
mediaNoteKind,
mediaUrl,
isLongFormArticle,
isWikiArticle,
isWikiArticleMarkdown,
isPublicationContent,
isCitationInternal,
isCitationExternal,
isCitationHardcopy,
isCitationPrompt,
isHighlight,
isPublicMessage,
isPoll,
parentEvent
])
// Function to generate draft event JSON for preview
const getDraftEventJson = useCallback(async (): Promise<string> => {
if (!pubkey) {
return JSON.stringify({ error: 'Not logged in' }, null, 2)
}
try {
// Clean tracking parameters from URLs in the post content
const cleanedText = text.replace(
/(https?:\/\/[^\s]+)/g,
(url) => {
try {
return cleanUrl(url)
} catch {
return url
}
}
)
// Get expiration and quiet settings
// Only add expiration tags to chatting kinds: 1, 1111, 1222, 1244
const isChattingKind = (kind: number) =>
kind === kinds.ShortTextNote ||
kind === ExtendedKind.COMMENT ||
kind === ExtendedKind.VOICE ||
kind === ExtendedKind.VOICE_COMMENT
const addExpirationTag = storage.getDefaultExpirationEnabled()
const expirationMonths = storage.getDefaultExpirationMonths()
const addQuietTag = storage.getDefaultQuietEnabled()
const quietDays = storage.getDefaultQuietDays()
// Determine if we should use protected event tag
// Only use it when replying to an OP event that also has the "-" tag
let shouldUseProtectedEvent = false
if (parentEvent) {
// Check if parent event is an OP (not a reply itself) and has the "-" tag
const isParentOP = !isReplyNoteEvent(parentEvent)
const parentHasProtectedTag = isEventProtected(parentEvent)
shouldUseProtectedEvent = isParentOP && parentHasProtectedTag
}
let draftEvent: any = null
// Check for voice comments first - even if mediaUrl is not set yet (for preview purposes)
console.log('🔍 getDraftEventJson: checking voice comment', {
parentEvent: !!parentEvent,
mediaNoteKind,
VOICE_COMMENT: ExtendedKind.VOICE_COMMENT,
match: parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT,
typeof_mediaNoteKind: typeof mediaNoteKind
})
if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) {
// Voice comment - use placeholder URL if mediaUrl not set yet
console.log('✅ getDraftEventJson: creating voice comment draft event')
const url = mediaUrl || 'placeholder://audio'
const tags = mediaImetaTags.length > 0 ? mediaImetaTags : [['imeta', `url ${url}`, 'm audio/mpeg']]
draftEvent = await createVoiceCommentDraftEvent(
cleanedText,
parentEvent,
url,
tags,
mentions,
{
addClientTag,
protectedEvent: shouldUseProtectedEvent,
isNsfw,
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE_COMMENT),
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (mediaNoteKind !== null && mediaUrl) {
// Media notes
if (mediaNoteKind === ExtendedKind.VOICE) {
// Voice note
draftEvent = await createVoiceDraftEvent(
cleanedText,
mediaUrl,
mediaImetaTags,
mentions,
{
addClientTag,
isNsfw,
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE),
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (mediaNoteKind === ExtendedKind.PICTURE) {
// Picture note
draftEvent = await createPictureDraftEvent(
cleanedText,
mediaImetaTags,
mentions,
{
addClientTag,
isNsfw,
addExpirationTag: false, // Picture notes are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (mediaNoteKind === ExtendedKind.VIDEO || mediaNoteKind === ExtendedKind.SHORT_VIDEO) {
// Video note
draftEvent = await createVideoDraftEvent(
cleanedText,
mediaImetaTags,
mentions,
mediaNoteKind,
{
addClientTag,
isNsfw,
addExpirationTag: false, // Video notes are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
}
)
}
} else if (isLongFormArticle) {
draftEvent = await createLongFormArticleDraftEvent(cleanedText, mentions, {
addClientTag,
isNsfw,
addExpirationTag: false, // Articles are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (isWikiArticle) {
draftEvent = await createWikiArticleDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content
addClientTag,
isNsfw,
addExpirationTag: false, // Wiki articles are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (isWikiArticleMarkdown) {
draftEvent = await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content
addClientTag,
isNsfw,
addExpirationTag: false, // Wiki articles are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (isPublicationContent) {
draftEvent = await createPublicationContentDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content
addClientTag,
isNsfw,
addExpirationTag: false, // Publication content is not a chatting kind
expirationMonths,
addQuietTag,
quietDays
})
} else if (isCitationInternal) {
// For now, use a simple format - in a real implementation, this would have a form
draftEvent = createCitationInternalDraftEvent(cleanedText, {
cTag: '', // Would need to be filled from a form
title: cleanedText.substring(0, 100)
})
} else if (isCitationExternal) {
draftEvent = createCitationExternalDraftEvent(cleanedText, {
url: '', // Would need to be filled from a form
accessedOn: new Date().toISOString(),
title: cleanedText.substring(0, 100)
})
} else if (isCitationHardcopy) {
draftEvent = createCitationHardcopyDraftEvent(cleanedText, {
accessedOn: new Date().toISOString(),
title: cleanedText.substring(0, 100)
})
} else if (isCitationPrompt) {
draftEvent = createCitationPromptDraftEvent(cleanedText, {
llm: '', // Would need to be filled from a form
accessedOn: new Date().toISOString()
})
} else if (isHighlight) {
// For highlights, pass the original sourceValue which contains the full identifier
// The createHighlightDraftEvent function will parse it correctly
draftEvent = await createHighlightDraftEvent(
cleanedText,
highlightData.sourceType,
highlightData.sourceValue,
highlightData.context,
undefined, // description parameter (not used)
{
addClientTag,
isNsfw,
addExpirationTag: false, // Highlights are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (isPublicMessage) {
draftEvent = await createPublicMessageDraftEvent(cleanedText, extractedMentions, {
addClientTag,
isNsfw,
addExpirationTag: false, // Public messages are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
draftEvent = await createPublicMessageReplyDraftEvent(cleanedText, parentEvent, mentions, {
addClientTag,
isNsfw,
addExpirationTag: false, // Public messages are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) {
draftEvent = await createCommentDraftEvent(cleanedText, parentEvent, mentions, {
addClientTag,
protectedEvent: shouldUseProtectedEvent,
isNsfw,
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.COMMENT),
expirationMonths,
addQuietTag,
quietDays
})
} else if (isPoll) {
draftEvent = await createPollDraftEvent(pubkey!, cleanedText, mentions, pollCreateData, {
addClientTag,
isNsfw,
addExpirationTag: false, // Polls are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else {
// For regular kind 1 note OPs (no parentEvent), never use protectedEvent
// protectedEvent should only be used when replying to an OP that has it
draftEvent = await createShortTextNoteDraftEvent(cleanedText, mentions, {
parentEvent,
addClientTag,
protectedEvent: shouldUseProtectedEvent,
isNsfw,
addExpirationTag: addExpirationTag && isChattingKind(kinds.ShortTextNote),
expirationMonths,
addQuietTag,
quietDays
})
}
// Return formatted JSON
return JSON.stringify(draftEvent, null, 2)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2)
}
}, [
text,
pubkey,
parentEvent,
mediaNoteKind,
mediaUrl,
mediaImetaTags,
mentions,
isLongFormArticle,
isWikiArticle,
isWikiArticleMarkdown,
isPublicationContent,
isCitationInternal,
isCitationExternal,
isCitationHardcopy,
isCitationPrompt,
isHighlight,
highlightData,
isPublicMessage,
extractedMentions,
isPoll,
pollCreateData,
addClientTag,
isNsfw
])
const post = async (e?: React.MouseEvent) => {
e?.stopPropagation()
@ -665,10 +1034,73 @@ export default function PostContent({ @@ -665,10 +1034,73 @@ export default function PostContent({
}
const handleUploadStart = (file: File, cancel: () => void) => {
console.log('🔍 handleUploadStart called', {
fileName: file.name,
fileType: file.type,
parentEvent: !!parentEvent
})
setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }])
// Track file for media upload
if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type.startsWith('video/')) {
uploadedMediaFileMap.current.set(file.name, file)
// For replies, if it's an audio file, set mediaNoteKind immediately for preview
if (parentEvent) {
const fileType = file.type
const fileName = file.name.toLowerCase()
// Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4
const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg'
const isAudioExt = /\.(mp3|m4a|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
// For replies, webm/ogg/mp3/m4a files should be treated as audio since the microphone button only accepts audio/*
// Even if the MIME type is incorrect, if it came through the audio uploader, it's audio
const isWebmFile = /\.webm$/i.test(fileName)
const isOggFile = /\.ogg$/i.test(fileName)
const isMp3File = /\.mp3$/i.test(fileName)
// m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this)
const isM4aFile = /\.m4a$/i.test(fileName)
const isMp4Audio = /\.mp4$/i.test(fileName) && isAudioMime
// For replies, treat webm/ogg/mp3/m4a as audio (since accept="audio/*" should filter out video files)
// m4a files are always audio, even if MIME type is wrong
const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmFile || isOggFile || isMp3File
console.log('🔍 handleUploadStart: audio detection', {
fileType,
fileName,
isAudioMime,
isAudioExt,
isMp4Audio,
isWebmFile,
isOggFile,
isMp3File,
isAudio
})
if (isAudio) {
console.log('✅ handleUploadStart: setting VOICE_COMMENT for reply', {
mediaNoteKind: ExtendedKind.VOICE_COMMENT,
fileType,
fileName
})
setMediaNoteKind(ExtendedKind.VOICE_COMMENT)
// Note: URL will be inserted when upload completes in handleMediaUploadSuccess
} else {
console.log('❌ handleUploadStart: file is not audio, not setting VOICE_COMMENT')
}
} else {
// For new posts, detect the kind from the file (async)
getMediaKindFromFile(file, false)
.then((kind) => {
console.log('✅ handleUploadStart: detected kind for new post', { kind, fileName: file.name })
setMediaNoteKind(kind)
})
.catch((error) => {
console.error('❌ Error detecting media kind in handleUploadStart', { error, file: file.name })
logger.error('Error detecting media kind in handleUploadStart', { error, file: file.name })
})
}
} else {
console.log('❌ handleUploadStart: file is not media type', { fileType: file.type })
}
}
@ -684,6 +1116,7 @@ export default function PostContent({ @@ -684,6 +1116,7 @@ export default function PostContent({
}
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
let uploadingFile: File | undefined
// Try to find by matching URL pattern or get the first available
@ -710,14 +1143,91 @@ export default function PostContent({ @@ -710,14 +1143,91 @@ export default function PostContent({
// For replies, only allow audio comments
const fileType = uploadingFile.type
const fileName = uploadingFile.name.toLowerCase()
const isAudio = fileType.startsWith('audio/') || /\.(mp3|m4a|ogg|wav|webm|opus|aac|flac)$/i.test(fileName)
// Check for audio files - including mp4/m4a/webm/ogg/mp3 which can be audio
// mp4/m4a/webm/ogg/mp3 files can be audio if MIME type is audio/*
// For replies, webm/ogg/mp3 files should be treated as audio since the microphone button only accepts audio/*
// Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4
const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg'
const isAudioExt = /\.(mp3|m4a|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
// m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this)
const isM4aFile = /\.m4a$/i.test(fileName)
const isMp4Audio = /\.mp4$/i.test(fileName) && isAudioMime
const isWebmFile = /\.webm$/i.test(fileName)
const isOggFile = /\.ogg$/i.test(fileName)
const isMp3File = /\.mp3$/i.test(fileName)
// For replies, treat webm/ogg/mp3/m4a as audio (since accept="audio/*" should filter out video files)
// m4a files are always audio, even if MIME type is wrong
const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmFile || isOggFile || isMp3File
console.log('🔍 handleMediaUploadSuccess: audio detection', {
fileType,
fileName,
isAudioMime,
isAudioExt,
isMp4Audio,
isWebmFile,
isOggFile,
isMp3File,
isAudio
})
if (isAudio) {
// For replies, always create voice comments, regardless of duration
// For replies, always create voice comments (kind 1244), regardless of duration
console.log('✅ handleMediaUploadSuccess: setting VOICE_COMMENT for reply', {
mediaNoteKind: ExtendedKind.VOICE_COMMENT,
url
})
setMediaNoteKind(ExtendedKind.VOICE_COMMENT)
setMediaUrl(url)
// Get imeta tag from media upload service
const imetaTag = mediaUpload.getImetaTagByUrl(url)
if (imetaTag) {
setMediaImetaTags([imetaTag])
} else if (tags && tags.length > 0) {
setMediaImetaTags(tags)
} else {
const basicImetaTag: string[] = ['imeta', `url ${url}`]
// For webm/ogg/mp3/m4a files uploaded via microphone, ensure MIME type is set to audio/*
// even if the browser reports video/webm or video/mp4 (mobile browsers sometimes do this)
let mimeType = uploadingFile.type
if (parentEvent) {
const fileName = uploadingFile.name.toLowerCase()
if (/\.m4a$/i.test(fileName)) {
// m4a files are always audio, use audio/mp4 or audio/x-m4a
mimeType = 'audio/mp4'
} else if (/\.webm$/i.test(fileName) && !mimeType.startsWith('audio/')) {
mimeType = 'audio/webm'
} else if (/\.ogg$/i.test(fileName) && !mimeType.startsWith('audio/')) {
mimeType = 'audio/ogg'
} else if (/\.mp3$/i.test(fileName) && !mimeType.startsWith('audio/')) {
mimeType = 'audio/mpeg'
}
}
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 if URL is already in the text
const currentText = text || ''
if (!currentText.includes(url)) {
textareaRef.current.appendText(url, true)
}
}
}, 100)
} else {
// Non-audio media in replies - don't set mediaNoteKind, will be handled as regular comment
// Clear any existing media note kind
console.log('❌ handleMediaUploadSuccess: file is not audio, clearing mediaNoteKind', {
fileType,
fileName,
isAudio
})
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
@ -727,28 +1237,83 @@ export default function PostContent({ @@ -727,28 +1237,83 @@ export default function PostContent({
}
} else {
// For new posts, use the detected kind (which handles audio > 60s → video)
try {
const kind = await getMediaKindFromFile(uploadingFile, false)
setMediaNoteKind(kind)
}
setMediaUrl(url)
// 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) {
// imetaTag is already a string[] like ['imeta', 'url https://...', 'm image/jpeg']
// We need it as string[][] for the draft event functions
setMediaImetaTags([imetaTag])
} else if (tags && tags.length > 0) {
// Use tags from upload result - they should already be in the right format
setMediaImetaTags(tags)
} else {
// Create a basic imeta tag if none exists
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)
}
}
}
} catch (error) {
logger.error('Error in handleMediaUploadSuccess', { error })
// Don't throw - just log the error so the upload doesn't fail completely
}
// Clear other note types when media is selected
setIsPoll(false)
@ -805,54 +1370,93 @@ export default function PostContent({ @@ -805,54 +1370,93 @@ export default function PostContent({
setIsPublicationContent(false)
}
const handleClear = () => {
// Clear the post editor cache
postEditorCache.clearPostCache({ defaultContent, parentEvent })
// Clear the editor content
textareaRef.current?.clear()
// Reset all state
setText('')
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
setMentions([])
setExtractedMentions([])
setIsPoll(false)
setIsPublicMessage(false)
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
setIsCitationPrompt(false)
setPollCreateData({
isMultipleChoice: false,
options: ['', ''],
endsAt: undefined,
relays: []
})
setHighlightData({
sourceType: 'nostr',
sourceValue: ''
})
uploadedMediaFileMap.current.clear()
setUploadProgresses([])
}
return (
<div className="space-y-2">
{/* Dynamic Title based on mode */}
<div className="text-lg font-semibold">
{parentEvent ? (
<div className="flex gap-2 items-center w-full">
<div className="shrink-0">
{parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE
? t('Reply to Public Message')
: mediaNoteKind === ExtendedKind.VOICE_COMMENT
? t('Voice Comment')
: t('Reply to')
{(() => {
const determinedKind = getDeterminedKind
if (parentEvent) {
if (parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
return t('Reply to Public Message')
} else if (determinedKind === ExtendedKind.VOICE_COMMENT) {
return t('Voice Comment')
} else {
return t('Reply to')
}
</div>
</div>
) : mediaNoteKind === ExtendedKind.VOICE ? (
t('Voice Note')
) : mediaNoteKind === ExtendedKind.PICTURE ? (
t('Picture Note')
) : mediaNoteKind === ExtendedKind.VIDEO ? (
t('Video Note')
) : mediaNoteKind === ExtendedKind.SHORT_VIDEO ? (
t('Short Video Note')
) : isPoll ? (
t('New Poll')
) : isPublicMessage ? (
t('New Public Message')
) : isHighlight ? (
t('New Highlight')
) : isLongFormArticle ? (
t('New Long-form Article')
) : isWikiArticle ? (
t('New Wiki Article')
) : isWikiArticleMarkdown ? (
t('New Wiki Article (Markdown)')
) : isPublicationContent ? (
t('Take a note')
) : isCitationInternal ? (
t('New Internal Citation')
) : isCitationExternal ? (
t('New External Citation')
) : isCitationHardcopy ? (
t('New Hardcopy Citation')
) : isCitationPrompt ? (
t('New Prompt Citation')
) : (
t('New Note')
)}
} else if (determinedKind === ExtendedKind.VOICE) {
return t('Voice Note')
} else if (determinedKind === ExtendedKind.PICTURE) {
return t('Picture Note')
} else if (determinedKind === ExtendedKind.VIDEO) {
return t('Video Note')
} else if (determinedKind === ExtendedKind.SHORT_VIDEO) {
return t('Short Video Note')
} else if (determinedKind === ExtendedKind.POLL) {
return t('New Poll')
} else if (determinedKind === ExtendedKind.PUBLIC_MESSAGE) {
return t('New Public Message')
} else if (determinedKind === kinds.Highlights) {
return t('New Highlight')
} else if (determinedKind === kinds.LongFormArticle) {
return t('New Long-form Article')
} else if (determinedKind === ExtendedKind.WIKI_ARTICLE) {
return t('New Wiki Article')
} else if (determinedKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
return t('New Wiki Article (Markdown)')
} else if (determinedKind === ExtendedKind.PUBLICATION_CONTENT) {
return t('Take a note')
} else if (determinedKind === ExtendedKind.CITATION_INTERNAL) {
return t('New Internal Citation')
} else if (determinedKind === ExtendedKind.CITATION_EXTERNAL) {
return t('New External Citation')
} else if (determinedKind === ExtendedKind.CITATION_HARDCOPY) {
return t('New Hardcopy Citation')
} else if (determinedKind === ExtendedKind.CITATION_PROMPT) {
return t('New Prompt Citation')
} else {
return t('New Note')
}
})()}
</div>
{parentEvent && (
@ -873,30 +1477,20 @@ export default function PostContent({ @@ -873,30 +1477,20 @@ export default function PostContent({
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd}
kind={
mediaNoteKind !== null
? mediaNoteKind
: isHighlight
? kinds.Highlights
: isPublicMessage
? ExtendedKind.PUBLIC_MESSAGE
: isPoll
? ExtendedKind.POLL
: isLongFormArticle
? kinds.LongFormArticle
: isWikiArticle
? ExtendedKind.WIKI_ARTICLE
: isWikiArticleMarkdown
? ExtendedKind.WIKI_ARTICLE_MARKDOWN
: isPublicationContent
? ExtendedKind.PUBLICATION_CONTENT
: kinds.ShortTextNote
}
kind={(() => {
const kind = getDeterminedKind
console.log('🔍 PostTextarea kind prop:', { kind, mediaNoteKind, parentEvent: !!parentEvent })
return kind
})()}
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
getDraftEventJson={getDraftEventJson}
mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl}
headerActions={
<>
{/* Media button */}
{/* Media button - show for new posts only (replies have audio button at bottom) */}
{!parentEvent && (
<Uploader
onUploadSuccess={handleMediaUploadSuccess}
onUploadStart={handleUploadStart}
@ -905,6 +1499,7 @@ export default function PostContent({ @@ -905,6 +1499,7 @@ export default function PostContent({
accept="image/*,audio/*,video/*"
>
<Button
type="button"
variant="ghost"
size="icon"
title={t('Upload Media')}
@ -913,6 +1508,7 @@ export default function PostContent({ @@ -913,6 +1508,7 @@ export default function PostContent({
<Upload className="h-4 w-4" />
</Button>
</Uploader>
)}
{/* Note creation buttons - only show when not replying */}
{!parentEvent && (
<>
@ -1092,6 +1688,26 @@ export default function PostContent({ @@ -1092,6 +1688,26 @@ export default function PostContent({
)}
<div className="flex items-center justify-between">
<div className="flex gap-2 items-center">
{/* Audio button for replies - placed before image button */}
{parentEvent && (
<Uploader
onUploadSuccess={handleMediaUploadSuccess}
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
accept="audio/*"
>
<Button
type="button"
variant="ghost"
size="icon"
title={t('Upload Audio Comment')}
className={mediaNoteKind === ExtendedKind.VOICE_COMMENT ? 'bg-accent' : ''}
>
<Mic className="h-4 w-4" />
</Button>
</Uploader>
)}
<Uploader
onUploadSuccess={({ url }) => {
textareaRef.current?.appendText(url, true)
@ -1101,7 +1717,7 @@ export default function PostContent({ @@ -1101,7 +1717,7 @@ export default function PostContent({
onProgress={handleUploadProgress}
accept="image/*"
>
<Button variant="ghost" size="icon" title={t('Upload Image')}>
<Button type="button" variant="ghost" size="icon" title={t('Upload Image')}>
<ImageUp />
</Button>
</Uploader>
@ -1137,6 +1753,15 @@ export default function PostContent({ @@ -1137,6 +1753,15 @@ export default function PostContent({
setMentions={setMentions}
/>
<div className="flex gap-2 items-center max-sm:hidden">
<Button
variant="outline"
onClick={(e) => {
e.stopPropagation()
handleClear()
}}
>
{t('Clear')}
</Button>
<Button
variant="secondary"
onClick={(e) => {
@ -1164,6 +1789,16 @@ export default function PostContent({ @@ -1164,6 +1789,16 @@ export default function PostContent({
setMinPow={setMinPow}
/>
<div className="flex gap-2 items-center justify-around sm:hidden">
<Button
className="w-full"
variant="outline"
onClick={(e) => {
e.stopPropagation()
handleClear()
}}
>
{t('Clear')}
</Button>
<Button
className="w-full"
variant="secondary"

27
src/components/PostEditor/PostTextarea/Preview.tsx

@ -19,13 +19,17 @@ export default function Preview({ @@ -19,13 +19,17 @@ export default function Preview({
className,
kind = 1,
highlightData,
pollCreateData
pollCreateData,
mediaImetaTags,
mediaUrl
}: {
content: string
className?: string
kind?: number
highlightData?: HighlightData
pollCreateData?: TPollCreateData
mediaImetaTags?: string[][]
mediaUrl?: string
}) {
const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo(
() => {
@ -103,18 +107,29 @@ export default function Preview({ @@ -103,18 +107,29 @@ export default function Preview({
[content, kind, highlightData, pollCreateData]
)
// Combine emoji tags, highlight tags, and poll tags
// Combine emoji tags, highlight tags, poll tags, and media imeta tags
const allTags = useMemo(() => {
return [...emojiTags, ...highlightTags, ...pollTags]
}, [emojiTags, highlightTags, pollTags])
const tags = [...emojiTags, ...highlightTags, ...pollTags]
// Add imeta tags for media (voice comments, etc.)
if (mediaImetaTags && mediaImetaTags.length > 0) {
tags.push(...mediaImetaTags)
}
return tags
}, [emojiTags, highlightTags, pollTags, mediaImetaTags])
const fakeEvent = useMemo(() => {
// For voice comments, include the media URL in content if not already there
let eventContent = processedContent
if ((kind === ExtendedKind.VOICE_COMMENT || kind === ExtendedKind.VOICE) && mediaUrl && !processedContent.includes(mediaUrl)) {
eventContent = mediaUrl + (processedContent ? '\n\n' + processedContent : '')
}
return createFakeEvent({
content: processedContent,
content: eventContent,
tags: allTags,
kind
})
}, [processedContent, allTags, kind])
}, [processedContent, allTags, kind, mediaUrl])
// For polls, use ContentPreview to show poll properly
if (kind === ExtendedKind.POLL) {

71
src/components/PostEditor/PostTextarea/index.tsx

@ -13,7 +13,7 @@ import Text from '@tiptap/extension-text' @@ -13,7 +13,7 @@ import Text from '@tiptap/extension-text'
import { TextSelection } from '@tiptap/pm/state'
import { EditorContent, useEditor } from '@tiptap/react'
import { Event } from 'nostr-tools'
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle, useState } from 'react'
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle, useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ClipboardAndDropHandler } from './ClipboardAndDropHandler'
import Emoji from './Emoji'
@ -22,11 +22,13 @@ import Mention from './Mention' @@ -22,11 +22,13 @@ import Mention from './Mention'
import mentionSuggestion from './Mention/suggestion'
import Preview from './Preview'
import { HighlightData } from '../HighlightEditor'
import { getKindDescription } from '@/lib/kind-description'
export type TPostTextareaHandle = {
appendText: (text: string, addNewline?: boolean) => void
insertText: (text: string) => void
insertEmoji: (emoji: string | TEmoji) => void
clear: () => void
}
const PostTextarea = forwardRef<
@ -45,6 +47,9 @@ const PostTextarea = forwardRef< @@ -45,6 +47,9 @@ const PostTextarea = forwardRef<
highlightData?: HighlightData
pollCreateData?: import('@/types').TPollCreateData
headerActions?: React.ReactNode
getDraftEventJson?: () => Promise<string>
mediaImetaTags?: string[][]
mediaUrl?: string
}
>(
(
@ -61,12 +66,40 @@ const PostTextarea = forwardRef< @@ -61,12 +66,40 @@ const PostTextarea = forwardRef<
kind = 1,
highlightData,
pollCreateData,
headerActions
headerActions,
getDraftEventJson,
mediaImetaTags,
mediaUrl
},
ref
) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState('edit')
const [draftEventJson, setDraftEventJson] = useState<string>('')
const [isLoadingJson, setIsLoadingJson] = useState(false)
const kindDescription = useMemo(() => {
console.log('🔍 kindDescription: recalculating', { kind })
return getKindDescription(kind)
}, [kind])
useEffect(() => {
if (activeTab === 'json' && getDraftEventJson) {
setIsLoadingJson(true)
getDraftEventJson()
.then((json) => {
setDraftEventJson(json)
setIsLoadingJson(false)
})
.catch((error) => {
setDraftEventJson(`Error generating JSON: ${error.message}`)
setIsLoadingJson(false)
})
} else if (activeTab === 'preview') {
// Clear JSON when switching away from JSON tab
setDraftEventJson('')
}
}, [activeTab, getDraftEventJson, kind])
const editor = useEditor({
extensions: [
Document,
@ -160,6 +193,15 @@ const PostTextarea = forwardRef< @@ -160,6 +193,15 @@ const PostTextarea = forwardRef<
editor.chain().insertContent(emojiNode).insertContent(' ').run()
}
}
},
clear: () => {
if (editor) {
// Clear the editor content and reset to empty document
editor.chain().clearContent().run()
// Also clear the cache
postEditorCache.setPostContentCache({ defaultContent, parentEvent }, editor.getJSON())
setText('')
}
}
}))
@ -169,13 +211,14 @@ const PostTextarea = forwardRef< @@ -169,13 +211,14 @@ const PostTextarea = forwardRef<
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2">
<div className="flex items-center justify-between">
<TabsList>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<TabsList className="w-auto justify-start">
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
<TabsTrigger value="json">{t('Json')}</TabsTrigger>
</TabsList>
{headerActions && (
<div className="flex gap-1 items-center">
<div className="flex gap-1 items-center flex-wrap">
{headerActions}
</div>
)}
@ -184,7 +227,23 @@ const PostTextarea = forwardRef< @@ -184,7 +227,23 @@ const PostTextarea = forwardRef<
<EditorContent className="tiptap" editor={editor} />
</TabsContent>
<TabsContent value="preview">
<Preview content={text} className={className} kind={kind} highlightData={highlightData} pollCreateData={pollCreateData} />
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
kind {kindDescription.number}: {kindDescription.description}
</div>
<Preview content={text} className={className} kind={kind} highlightData={highlightData} pollCreateData={pollCreateData} mediaImetaTags={mediaImetaTags} mediaUrl={mediaUrl} />
</div>
</TabsContent>
<TabsContent value="json">
<div className="border rounded-lg p-3 bg-muted/40 max-h-96 overflow-auto">
{isLoadingJson ? (
<div className="text-muted-foreground text-sm">{t('Loading...')}</div>
) : (
<pre className="text-xs whitespace-pre-wrap break-words font-mono">
{draftEventJson || t('No JSON available')}
</pre>
)}
</div>
</TabsContent>
</Tabs>
)

31
src/components/PostEditor/Uploader.tsx

@ -35,15 +35,27 @@ export default function Uploader({ @@ -35,15 +35,27 @@ export default function Uploader({
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) => onProgress?.(file, p),
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, file: file.name })
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}`)
@ -56,7 +68,10 @@ export default function Uploader({ @@ -56,7 +68,10 @@ export default function Uploader({
}
}
const handleUploadClick = () => {
const handleUploadClick = (e: React.MouseEvent) => {
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()
@ -64,8 +79,14 @@ export default function Uploader({ @@ -64,8 +79,14 @@ export default function Uploader({
}
return (
<div className={className}>
<div onClick={handleUploadClick}>{children}</div>
<div className={className} onClick={(e) => e.stopPropagation()}>
<div onClick={handleUploadClick} role="button" tabIndex={0} onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
handleUploadClick(e as any)
}
}}>{children}</div>
<input
type="file"
ref={fileInputRef}

6
src/components/Profile/ProfileFeed.tsx

@ -11,7 +11,9 @@ const POST_KIND_LIST = [ @@ -11,7 +11,9 @@ const POST_KIND_LIST = [
ExtendedKind.COMMENT,
ExtendedKind.DISCUSSION,
ExtendedKind.POLL,
ExtendedKind.ZAP_RECEIPT
ExtendedKind.ZAP_RECEIPT,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT
]
interface ProfileFeedProps {
@ -50,6 +52,8 @@ const ProfileFeed = forwardRef<{ refresh: () => void; getEvents?: () => Event[] @@ -50,6 +52,8 @@ const ProfileFeed = forwardRef<{ refresh: () => void; getEvents?: () => Event[]
if (kindNum === ExtendedKind.DISCUSSION) return 'discussions'
if (kindNum === ExtendedKind.POLL) return 'polls'
if (kindNum === ExtendedKind.ZAP_RECEIPT) return 'zaps'
if (kindNum === ExtendedKind.VOICE) return 'voice posts'
if (kindNum === ExtendedKind.VOICE_COMMENT) return 'voice comments'
return 'posts'
}

4
src/components/Profile/ProfileMedia.tsx

@ -6,9 +6,7 @@ import ProfileTimeline from './ProfileTimeline' @@ -6,9 +6,7 @@ import ProfileTimeline from './ProfileTimeline'
const MEDIA_KIND_LIST = [
ExtendedKind.PICTURE,
ExtendedKind.VIDEO,
ExtendedKind.SHORT_VIDEO,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT
ExtendedKind.SHORT_VIDEO
]
interface ProfileMediaProps {

51
src/lib/kind-description.ts

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
import { ExtendedKind } from '@/constants'
import { kinds } from 'nostr-tools'
/**
* Get the description for a given kind number
* @param kind - The kind number
* @returns An object with the kind number and description
*/
export function getKindDescription(kind: number): { number: number; description: string } {
switch (kind) {
case kinds.ShortTextNote:
return { number: 1, description: 'Short Text Note' }
case ExtendedKind.COMMENT:
return { number: 1111, description: 'Comment' }
case ExtendedKind.VOICE:
return { number: 1222, description: 'Voice Note' }
case ExtendedKind.VOICE_COMMENT:
return { number: 1244, description: 'Voice Comment' }
case ExtendedKind.PICTURE:
return { number: 20, description: 'Picture Note' }
case ExtendedKind.VIDEO:
return { number: 21, description: 'Video Note' }
case ExtendedKind.SHORT_VIDEO:
return { number: 22, description: 'Short Video Note' }
case kinds.LongFormArticle:
return { number: 30023, description: 'Long-form Article' }
case ExtendedKind.WIKI_ARTICLE:
return { number: 30818, description: 'Wiki Article (AsciiDoc)' }
case ExtendedKind.WIKI_ARTICLE_MARKDOWN:
return { number: 30817, description: 'Wiki Article (Markdown)' }
case ExtendedKind.PUBLICATION_CONTENT:
return { number: 30041, description: 'Publication Content' }
case ExtendedKind.CITATION_INTERNAL:
return { number: 30, description: 'Internal Citation' }
case ExtendedKind.CITATION_EXTERNAL:
return { number: 31, description: 'External Web Citation' }
case ExtendedKind.CITATION_HARDCOPY:
return { number: 32, description: 'Hardcopy Citation' }
case ExtendedKind.CITATION_PROMPT:
return { number: 33, description: 'Prompt Citation' }
case kinds.Highlights:
return { number: 9802, description: 'Highlight' }
case ExtendedKind.POLL:
return { number: 1068, description: 'Poll' }
case ExtendedKind.PUBLIC_MESSAGE:
return { number: 14, description: 'Public Message' }
default:
return { number: kind, description: `Event (kind ${kind})` }
}
}

24
src/lib/media-kind-detection.ts

@ -16,21 +16,35 @@ export async function getMediaKindFromFile(file: File, isReply: boolean = false) @@ -16,21 +16,35 @@ export async function getMediaKindFromFile(file: File, isReply: boolean = false)
}
// Check if it's audio or video
const isAudio = fileType.startsWith('audio/') || /\.(mp3|m4a|ogg|wav|webm|opus|aac|flac)$/i.test(fileName)
const isVideo = fileType.startsWith('video/') || /\.(mp4|webm|ogg|mov|avi|mkv|m4v)$/i.test(fileName)
// mp4, m4a, and webm files can be either audio or video, so check MIME type first
// Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4
const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg'
const isVideoMime = fileType.startsWith('video/')
const isAudioExt = /\.(mp3|m4a|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
const isVideoExt = /\.(mp4|ogg|mov|avi|mkv|m4v)$/i.test(fileName)
// m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this)
const isM4aFile = /\.m4a$/i.test(fileName)
// mp4 files: check MIME type to determine if audio or video
const isMp4Audio = /\.mp4$/i.test(fileName) && isAudioMime
const isWebmAudio = /\.webm$/i.test(fileName) && isAudioMime
const isWebmVideo = /\.webm$/i.test(fileName) && isVideoMime
const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmAudio
const isVideo = isVideoMime || (isVideoExt && !isM4aFile && !isMp4Audio) || isWebmVideo
if (isAudio || isVideo) {
// Get duration for audio/video files
const duration = await getMediaDuration(file)
if (isAudio) {
// Audio mp4 files longer than 60 seconds should be treated as video
if ((fileType === 'audio/mp4' || fileName.endsWith('.m4a')) && duration > 60) {
// Audio mp4/m4a files longer than 60 seconds should be treated as video (for new posts only)
if (!isReply && (fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileName.endsWith('.m4a') || fileName.endsWith('.mp4')) && duration > 60) {
// Determine if it should be long or short video based on duration
return duration > 600 ? ExtendedKind.VIDEO : ExtendedKind.SHORT_VIDEO
}
// Audio files <= 60 seconds
// Audio files <= 60 seconds, or any audio in replies
return isReply ? ExtendedKind.VOICE_COMMENT : ExtendedKind.VOICE
}

4
src/lib/url.ts

@ -239,7 +239,9 @@ export function isAudio(url: string) { @@ -239,7 +239,9 @@ export function isAudio(url: string) {
'.m4a',
'.opus',
'.wma',
'.ogg' // ogg can be audio
'.ogg', // ogg can be audio
'.webm', // webm can be audio (when uploaded via microphone button)
'.mp4' // mp4 can be audio (m4a files)
]
return audioExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {

31
src/pages/secondary/PostSettingsPage/CacheRelayOnlySetting.tsx

@ -9,21 +9,36 @@ import { useTranslation } from 'react-i18next' @@ -9,21 +9,36 @@ import { useTranslation } from 'react-i18next'
export default function CacheRelayOnlySetting() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const [enabled, setEnabled] = useState(true) // Default ON
const [enabled, setEnabled] = useState(true) // Default ON when cache exists
const [hasCacheRelaysAvailable, setHasCacheRelaysAvailable] = useState(false)
useEffect(() => {
// Load from localStorage
const stored = window.localStorage.getItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES)
setEnabled(stored === null ? true : stored === 'true') // Default to true if not set
// Check if user has cache relays
// Check if user has cache relays first
if (pubkey) {
hasCacheRelays(pubkey)
.then(setHasCacheRelaysAvailable)
.catch(() => setHasCacheRelaysAvailable(false))
.then((hasCache) => {
setHasCacheRelaysAvailable(hasCache)
if (hasCache) {
// If cache exists, load from localStorage or default to true (ON)
const stored = window.localStorage.getItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES)
setEnabled(stored === null ? true : stored === 'true')
} else {
// If no cache, set to false (OFF) and save it
setEnabled(false)
window.localStorage.setItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES, 'false')
}
})
.catch(() => {
setHasCacheRelaysAvailable(false)
// If check fails, assume no cache and set to false
setEnabled(false)
window.localStorage.setItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES, 'false')
})
} else {
setHasCacheRelaysAvailable(false)
setEnabled(false)
window.localStorage.setItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES, 'false')
}
}, [pubkey])

19
src/services/indexed-db.service.ts

@ -196,13 +196,6 @@ class IndexedDbService { @@ -196,13 +196,6 @@ class IndexedDbService {
created_at: cleanEvent.created_at,
fullEventId: cleanEvent.id
})
logger.info('[IndexedDB] Putting replaceable event', {
kind: cleanEvent.kind,
storeName,
eventId: cleanEvent.id?.substring(0, 8),
pubkey: cleanEvent.pubkey?.substring(0, 8),
created_at: cleanEvent.created_at
})
await this.initPromise
@ -252,13 +245,13 @@ class IndexedDbService { @@ -252,13 +245,13 @@ class IndexedDbService {
const store = transaction.objectStore(storeName)
const key = this.getReplaceableEventKeyFromEvent(cleanEvent)
logger.info('[IndexedDB] Getting existing event', { storeName, key, eventId: cleanEvent.id?.substring(0, 8) })
logger.debug('[IndexedDB] Getting existing event', { storeName, key, eventId: cleanEvent.id?.substring(0, 8) })
const getRequest = store.get(key)
getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined
if (oldValue?.value) {
logger.info('[IndexedDB] Found existing event', {
logger.debug('[IndexedDB] Found existing event', {
storeName,
key,
oldEventId: oldValue.value.id?.substring(0, 8),
@ -267,11 +260,11 @@ class IndexedDbService { @@ -267,11 +260,11 @@ class IndexedDbService {
willUpdate: cleanEvent.created_at > oldValue.value.created_at
})
} else {
logger.info('[IndexedDB] No existing event found', { storeName, key })
logger.debug('[IndexedDB] No existing event found', { storeName, key })
}
if (oldValue?.value && oldValue.value.created_at >= cleanEvent.created_at) {
logger.info('[IndexedDB] Keeping existing event (newer or same timestamp)', {
logger.debug('[IndexedDB] Keeping existing event (newer or same timestamp)', {
storeName,
key,
existingEventId: oldValue.value.id?.substring(0, 8)
@ -287,16 +280,14 @@ class IndexedDbService { @@ -287,16 +280,14 @@ class IndexedDbService {
fullEventId: cleanEvent.id,
content: cleanEvent.content?.substring(0, 50)
})
logger.info('[IndexedDB] Putting new event', { storeName, key, eventId: cleanEvent.id?.substring(0, 8) })
const putRequest = store.put(this.formatValue(key, cleanEvent))
putRequest.onsuccess = () => {
logger.debug('[IndexedDB] Successfully put event!', {
logger.debug('[IndexedDB] Successfully put event', {
storeName,
key,
eventId: cleanEvent.id?.substring(0, 8),
content: cleanEvent.content?.substring(0, 50)
})
logger.info('[IndexedDB] Successfully put event', { storeName, key, eventId: cleanEvent.id?.substring(0, 8) })
transaction.commit()
resolve(cleanEvent)
}

101
src/services/media-upload.service.ts

@ -146,14 +146,46 @@ class MediaUploadService { @@ -146,14 +146,46 @@ class MediaUploadService {
const auth = await client.signHttpAuth(uploadUrl, 'POST', 'Uploading media file')
// Check if service worker might be interfering
const hasServiceWorker = 'serviceWorker' in navigator && navigator.serviceWorker.controller
if (hasServiceWorker) {
console.warn('⚠ Service worker is active - this may interfere with uploads on mobile', { uploadUrl })
}
// Use XMLHttpRequest for upload progress support
// Note: XMLHttpRequest should bypass service workers, but on mobile this isn't always reliable
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)
// Log upload start for debugging
console.log('📤 Starting upload', {
uploadUrl,
fileSize: file.size,
fileType: file.type,
fileName: file.name,
hasServiceWorker,
userAgent: navigator.userAgent
})
// Set a timeout (60 seconds for uploads)
xhr.timeout = 60000
// Track if we've already handled the response to avoid double handling
let isHandled = false
const handleError = (error: Error | string) => {
if (isHandled) return
isHandled = true
const errorMessage = error instanceof Error ? error.message : error
reject(new Error(errorMessage))
}
const handleAbort = () => {
if (isHandled) return
isHandled = true
try {
xhr.abort()
} catch {
@ -161,6 +193,7 @@ class MediaUploadService { @@ -161,6 +193,7 @@ class MediaUploadService {
}
reject(new Error(UPLOAD_ABORTED_ERROR_MSG))
}
if (options?.signal) {
if (options.signal.aborted) {
return handleAbort()
@ -168,32 +201,90 @@ class MediaUploadService { @@ -168,32 +201,90 @@ class MediaUploadService {
options.signal.addEventListener('abort', handleAbort, { once: true })
}
// Handle timeout
xhr.ontimeout = () => {
console.error('⏱ Upload timeout', { uploadUrl, fileSize: file.size })
handleError('Upload timeout - the connection took too long. Please check your network connection and try again.')
}
// Handle abort
xhr.onabort = () => {
if (!isHandled) {
isHandled = true
reject(new Error(UPLOAD_ABORTED_ERROR_MSG))
}
}
// Handle network errors
xhr.onerror = () => {
// Try to get more details about the error
// Status 0 can mean: CORS failure, network error, service worker blocking, or connection refused
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.'
} else if (xhr.status >= 400) {
errorMessage = `Upload failed with status ${xhr.status}: ${xhr.statusText || 'Unknown error'}`
}
console.error('❌ Upload network error', {
uploadUrl,
status: xhr.status,
statusText: xhr.statusText,
readyState: xhr.readyState,
fileSize: file.size,
errorMessage
})
handleError(errorMessage)
}
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100)
console.log('📊 Upload progress', { percent, loaded: event.loaded, total: event.total })
options?.onProgress?.(percent)
}
}
xhr.onerror = () => reject(new Error('Network error'))
xhr.onload = () => {
if (isHandled) return
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = xhr.response
// Handle case where response might be a string that needs parsing
let parsedData = data
if (typeof data === 'string') {
try {
const tags = z.array(z.array(z.string())).parse(data?.nip94_event?.tags ?? [])
parsedData = JSON.parse(data)
} catch {
handleError('Invalid response format from upload server')
return
}
}
const tags = z.array(z.array(z.string())).parse(parsedData?.nip94_event?.tags ?? [])
const url = tags.find(([tagName]: string[]) => tagName === 'url')?.[1]
if (url) {
console.log('✅ Upload successful', { url, uploadUrl })
isHandled = true
resolve({ url, tags })
} else {
reject(new Error('No url found'))
console.error('❌ No URL in upload response', { parsedData, tags })
handleError('No url found in upload response')
}
} catch (e) {
reject(e as Error)
handleError(e instanceof Error ? e : new Error('Failed to parse upload response'))
}
} else {
reject(new Error(xhr.status.toString() + ' ' + xhr.statusText))
handleError(`Upload failed with status ${xhr.status}: ${xhr.statusText || 'Unknown error'}`)
}
}
try {
xhr.send(formData)
} catch (error) {
handleError(error instanceof Error ? error : new Error('Failed to send upload request'))
}
})
return result

19
vite.config.ts

@ -62,6 +62,25 @@ export default defineConfig({ @@ -62,6 +62,25 @@ export default defineConfig({
'**/workbox-*.js'
],
runtimeCaching: [
{
// Exclude upload endpoints from service worker handling - use NetworkOnly to bypass cache
// Match various upload URL patterns - comprehensive regex to catch all upload services
// This ensures uploads (POST) and discovery endpoints (GET) bypass the service worker
// Note: XMLHttpRequest should bypass service workers, but we add this as a safety measure
urlPattern: ({ url, request }) => {
const urlString = url.toString()
const method = request.method?.toUpperCase() || 'GET'
// Always bypass service worker for POST requests (uploads)
if (method === 'POST') {
return /(?:api\/v2\/nip96\/upload|\.well-known\/nostr\/nip96\.json|nostr\.build|nostrcheck\.me|void\.cat|\/upload|\/nip96\/)/i.test(urlString)
}
// Also bypass for GET requests to upload-related endpoints
return /(?:\.well-known\/nostr\/nip96\.json|api\/v2\/nip96\/upload)/i.test(urlString)
},
handler: 'NetworkOnly'
},
{
urlPattern: /^https:\/\/image\.nostr\.build\/.*/i,
handler: 'CacheFirst',

Loading…
Cancel
Save