Browse Source

bug-fixing

imwald
Silberengel 4 months ago
parent
commit
fa262cd906
  1. 454
      src/components/CacheRelaysSetting/index.tsx
  2. 927
      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. 109
      src/services/media-upload.service.ts
  14. 19
      vite.config.ts

454
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) {
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))
)
try {
const cacheNames = await caches.keys()
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>
)
}

927
src/components/PostEditor/PostContent.tsx

File diff suppressed because it is too large Load Diff

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)
}

109
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,39 +193,98 @@ class MediaUploadService { @@ -161,39 +193,98 @@ class MediaUploadService {
}
reject(new Error(UPLOAD_ABORTED_ERROR_MSG))
}
if (options?.signal) {
if (options.signal.aborted) {
return handleAbort()
}
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) {
const data = xhr.response
try {
const tags = z.array(z.array(z.string())).parse(data?.nip94_event?.tags ?? [])
const data = xhr.response
// Handle case where response might be a string that needs parsing
let parsedData = data
if (typeof data === 'string') {
try {
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'}`)
}
}
xhr.send(formData)
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