Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
5bf6f2a1fb
  1. 1358
      src/components/CacheRelaysSetting/index.tsx
  2. 88
      src/components/FavoriteRelaysFeedPicker/index.tsx
  3. 817
      src/components/InBrowserCacheSetting/index.tsx
  4. 79
      src/components/Note/SelectionHighlightTrigger.tsx
  5. 8
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  6. 6
      src/pages/secondary/CacheSettingsPage/index.tsx
  7. 8
      src/pages/secondary/RelaySettingsPage/index.tsx

1358
src/components/CacheRelaysSetting/index.tsx

File diff suppressed because it is too large Load Diff

88
src/components/FavoriteRelaysFeedPicker/index.tsx

@ -10,14 +10,16 @@ import { @@ -10,14 +10,16 @@ import {
SelectValue
} from '@/components/ui/select'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { toRelaySettings } from '@/lib/link'
import { normalizeUrl, simplifyUrl } from '@/lib/url'
import { normalizeAnyRelayUrl, normalizeUrl, simplifyUrl } from '@/lib/url'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { cn } from '@/lib/utils'
import { useContainerWidth } from '@/hooks/useContainerWidth'
import { useSecondaryPage } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider'
import { SquarePen } from 'lucide-react'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
@ -36,7 +38,7 @@ function selectValueToRelaySetId(v: string) { @@ -36,7 +38,7 @@ function selectValueToRelaySetId(v: string) {
return decodeURIComponent(v.slice(3))
}
/** Top-of-feed control: all favorites, Wisp trending (nostrarchives), relay sets, then single relays. */
/** Top-of-feed control: all favorites, Wisp trending (nostrarchives), relay sets, single relays, HTTP index relays. */
export default function FavoriteRelaysFeedPicker() {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
@ -47,6 +49,7 @@ export default function FavoriteRelaysFeedPicker() { @@ -47,6 +49,7 @@ export default function FavoriteRelaysFeedPicker() {
const { push } = useSecondaryPage()
const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays()
const { feedInfo, switchFeed } = useFeed()
const { httpRelayListEvent } = useNostr()
const openFavoriteRelaySettings = () => {
push(toRelaySettings('favorite-relays'))
@ -59,6 +62,22 @@ export default function FavoriteRelaysFeedPicker() { @@ -59,6 +62,22 @@ export default function FavoriteRelaysFeedPicker() {
[favoriteRelays, blockedRelays]
)
/** HTTP index relay URLs from kind 10243, deduped, excluding any already in favorites. */
const httpRelayUrls = useMemo(() => {
if (!httpRelayListEvent) return []
const list = getHttpRelayListFromEvent(httpRelayListEvent)
const favKeys = new Set(urls.map((u) => normalizeAnyRelayUrl(u) || u))
const seen = new Set<string>()
const out: string[] = []
for (const u of [...list.httpRead, ...list.httpWrite]) {
const k = normalizeAnyRelayUrl(u) || u
if (!k || seen.has(k) || favKeys.has(k)) continue
seen.add(k)
out.push(k)
}
return out
}, [httpRelayListEvent, urls])
const wispTrendingRelayUrl = useMemo(() => buildWispTrendingNotesRelayUrl(), [])
const wispTrendingRelayKey = useMemo(
() => normalizeUrl(wispTrendingRelayUrl) || wispTrendingRelayUrl,
@ -69,8 +88,11 @@ export default function FavoriteRelaysFeedPicker() { @@ -69,8 +88,11 @@ export default function FavoriteRelaysFeedPicker() {
[urls, wispTrendingRelayKey]
)
// Use normalizeAnyRelayUrl so HTTP relay IDs are matched correctly (normalizeUrl converts http→ws).
const currentRelayKey =
feedInfo.feedType === 'relay' && feedInfo.id ? normalizeUrl(feedInfo.id) || feedInfo.id : null
feedInfo.feedType === 'relay' && feedInfo.id
? normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id
: null
const allActive = feedInfo.feedType === 'all-favorites'
@ -103,7 +125,10 @@ export default function FavoriteRelaysFeedPicker() { @@ -103,7 +125,10 @@ export default function FavoriteRelaysFeedPicker() {
items.push({ value: relaySetToSelectValue(orphanRelaySetId) })
}
for (const url of urls) {
items.push({ value: normalizeUrl(url) || url })
items.push({ value: normalizeAnyRelayUrl(url) || url })
}
for (const url of httpRelayUrls) {
items.push({ value: normalizeAnyRelayUrl(url) || url })
}
if (
!allActive &&
@ -111,11 +136,12 @@ export default function FavoriteRelaysFeedPicker() { @@ -111,11 +136,12 @@ export default function FavoriteRelaysFeedPicker() {
feedInfo.id &&
!items.some((i) => i.value === currentRelayKey)
) {
items.push({ value: normalizeUrl(feedInfo.id) || feedInfo.id })
items.push({ value: normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id })
}
return items
}, [
urls,
httpRelayUrls,
allActive,
feedInfo.feedType,
feedInfo.id,
@ -132,8 +158,11 @@ export default function FavoriteRelaysFeedPicker() { @@ -132,8 +158,11 @@ export default function FavoriteRelaysFeedPicker() {
const resolveRelayUrl = (value: string) => {
if (value === ALL_FAVORITES_VALUE) return null
const fromList = urls.find((u) => (normalizeUrl(u) || u) === value)
return fromList ?? value
const fromFav = urls.find((u) => (normalizeAnyRelayUrl(u) || u) === value)
if (fromFav) return fromFav
const fromHttp = httpRelayUrls.find((u) => (normalizeAnyRelayUrl(u) || u) === value)
if (fromHttp) return fromHttp
return value
}
const onPickValue = (v: string) => {
@ -154,7 +183,7 @@ export default function FavoriteRelaysFeedPicker() { @@ -154,7 +183,7 @@ export default function FavoriteRelaysFeedPicker() {
if (relay) void switchFeed('relay', { relay })
}
if (urls.length === 0 && relaySets.length === 0) return null
if (urls.length === 0 && httpRelayUrls.length === 0 && relaySets.length === 0) return null
const editSettingsButton = (
<Button
@ -223,7 +252,7 @@ export default function FavoriteRelaysFeedPicker() { @@ -223,7 +252,7 @@ export default function FavoriteRelaysFeedPicker() {
<>
{relaySets.length > 0 || orphanRelaySetId ? <SelectSeparator /> : null}
{urls.map((url) => {
const v = normalizeUrl(url) || url
const v = normalizeAnyRelayUrl(url) || url
return (
<SelectItem key={v} value={v} className="font-mono text-xs" title={url}>
{simplifyUrl(url)}
@ -232,6 +261,22 @@ export default function FavoriteRelaysFeedPicker() { @@ -232,6 +261,22 @@ export default function FavoriteRelaysFeedPicker() {
})}
</>
) : null}
{httpRelayUrls.length > 0 ? (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel className="pl-2">{t('HTTP relays')}</SelectLabel>
{httpRelayUrls.map((url) => {
const v = normalizeAnyRelayUrl(url) || url
return (
<SelectItem key={v} value={v} className="font-mono text-xs" title={url}>
{simplifyUrl(url)}
</SelectItem>
)
})}
</SelectGroup>
</>
) : null}
</SelectContent>
</Select>
</div>
@ -314,7 +359,30 @@ export default function FavoriteRelaysFeedPicker() { @@ -314,7 +359,30 @@ export default function FavoriteRelaysFeedPicker() {
<div className="mx-0.5 shrink-0 self-stretch border-l border-border/80" aria-hidden />
)}
{urls.map((url) => {
const key = normalizeUrl(url) || url
const key = normalizeAnyRelayUrl(url) || url
const active = feedInfo.feedType === 'relay' && currentRelayKey === key
return (
<button
key={key}
type="button"
className={cn(
'max-w-[11rem] shrink-0 truncate rounded-full border px-3 py-1 font-mono text-xs font-semibold transition-colors',
active
? 'border-primary bg-primary/15 text-foreground'
: 'border-border bg-muted/40 text-muted-foreground hover:bg-accent'
)}
title={url}
onClick={() => void switchFeed('relay', { relay: url })}
>
{simplifyUrl(url)}
</button>
)
})}
{httpRelayUrls.length > 0 && (
<div className="mx-0.5 shrink-0 self-stretch border-l border-border/80" aria-hidden />
)}
{httpRelayUrls.map((url) => {
const key = normalizeAnyRelayUrl(url) || url
const active = feedInfo.feedType === 'relay' && currentRelayKey === key
return (
<button

817
src/components/InBrowserCacheSetting/index.tsx

@ -0,0 +1,817 @@ @@ -0,0 +1,817 @@
import { Button } from '@/components/ui/button'
import logger from '@/lib/logger'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Terminal, XCircle } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import client from '@/services/client.service'
import indexedDb, { StoreNames } from '@/services/indexed-db.service'
import postEditorCache from '@/services/post-editor-cache.service'
import { StorageKey } from '@/constants'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { toast } from 'sonner'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { Event } from 'nostr-tools'
export default function InBrowserCacheSetting() {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const {
pubkey,
relayList,
requestAccountNetworkHydrate
} = useNostr()
const [cacheInfo, setCacheInfo] = useState<Record<string, number>>({})
const [browsingCache, setBrowsingCache] = useState(false)
const [selectedStore, setSelectedStore] = useState<string | null>(null)
const [storeItems, setStoreItems] = useState<any[]>([])
const [loadingItems, setLoadingItems] = useState(false)
const [wordWrapEnabled, setWordWrapEnabled] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [consoleLogs, setConsoleLogs] = useState<Array<{ type: string; message: string; formattedParts?: Array<{ text: string; style?: string }>; timestamp: number }>>([])
const [showConsoleLogs, setShowConsoleLogs] = useState(false)
const [consoleLogSearch, setConsoleLogSearch] = useState('')
const [consoleLogLevel, setConsoleLogLevel] = useState<'errors-warnings' | 'all'>('all')
const [cacheRefreshBusy, setCacheRefreshBusy] = useState(false)
const consoleLogRef = useRef<Array<{ type: string; message: string; formattedParts?: Array<{ text: string; style?: string }>; timestamp: number }>>([])
useEffect(() => {
loadCacheInfo()
}, [])
const loadCacheInfo = async () => {
try {
const info = await indexedDb.getStoreInfo()
setCacheInfo(info)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Failed to load cache info', { error: message })
}
}
const handleClearCache = async () => {
if (!confirm(t('Are you sure you want to clear all cached data? This will delete all stored events and settings from your browser.'))) {
return
}
try {
await indexedDb.clearAllCache()
await indexedDb.clearPiperTtsCache()
const cacheKeys = Object.values(StorageKey).filter(key =>
key.includes('CACHE') || key.includes('EVENT') || key.includes('FEED') || key.includes('NOTIFICATION')
)
cacheKeys.forEach(key => {
try {
window.localStorage.removeItem(key)
} catch (e) {
logger.warn(`Failed to remove ${key} from localStorage`, e as Error)
}
})
if ('caches' in window) {
try {
const cacheNames = await caches.keys()
const currentOrigin = window.location.origin
const appCacheNames = [
'nostr-images',
'satellite-images',
'external-images'
]
const appCaches = cacheNames.filter(name => {
if (appCacheNames.includes(name)) return true
if (name.startsWith('workbox-') || name.startsWith('precache-')) return true
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 })
}
}
postEditorCache.clearAllPostCaches()
client.clearInMemoryCaches()
await loadCacheInfo()
toast.success(t('Cache cleared successfully'))
setTimeout(() => window.location.reload(), 1500)
} catch (error) {
logger.error('Failed to clear cache', { error })
toast.error(t('Failed to clear cache'))
}
}
const handleRefreshCache = async () => {
try {
setCacheRefreshBusy(true)
await indexedDb.forceDatabaseUpgrade()
await loadCacheInfo()
if (pubkey) {
await requestAccountNetworkHydrate()
await syncUserDeletionTombstones(pubkey, relayList)
}
toast.success(t('Cache refreshed successfully'))
} catch (error) {
logger.error('Failed to refresh cache', { error })
toast.error(t('Failed to refresh cache'))
} finally {
setCacheRefreshBusy(false)
}
}
const handleBrowseCache = () => {
setBrowsingCache(true)
setSelectedStore(null)
setStoreItems([])
setSearchQuery('')
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
if (window.isSecureContext && 'serviceWorker' in navigator) {
let registrations: readonly ServiceWorkerRegistration[] = []
try {
registrations = await navigator.serviceWorker.getRegistrations()
} catch (error) {
logger.warn('Failed to get service worker registrations', { error })
}
if (registrations.length > 0) {
const unregisterPromises = registrations.map(async (registration) => {
try {
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)
}
}
if ('caches' in window) {
try {
const cacheNames = await caches.keys()
const appCacheNames = [
'nostr-images',
'satellite-images',
'external-images'
]
const appCaches = cacheNames.filter(name => {
if (appCacheNames.includes(name)) return true
if (name.startsWith('workbox-') || name.startsWith('precache-')) return true
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)
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)))
}
}
useEffect(() => {
const originalLog = console.log
const originalError = console.error
const originalWarn = console.warn
const originalInfo = console.info
const captureLog = (type: string, ...args: any[]) => {
let message = ''
let formattedParts: Array<{ text: string; style?: string }> = []
if (args.length > 0 && typeof args[0] === 'string' && args[0].includes('%c')) {
const formatString = args[0]
const parts = formatString.split(/%c/g)
formattedParts = []
for (let i = 0; i < parts.length; i++) {
const text = parts[i]
const style = i < args.length - 1 && typeof args[i + 1] === 'string' ? args[i + 1] : undefined
formattedParts.push({ text, style })
}
const remainingArgs = args.slice(parts.length)
if (remainingArgs.length > 0) {
const remainingText = remainingArgs.map(arg => {
if (typeof arg === 'object') {
try { return JSON.stringify(arg, null, 2) } catch { return String(arg) }
}
return String(arg)
}).join(' ')
if (formattedParts.length > 0) {
formattedParts[formattedParts.length - 1].text += ' ' + remainingText
} else {
formattedParts.push({ text: remainingText })
}
}
message = formattedParts.map(p => p.text).join('')
} else {
message = args.map(arg => {
if (typeof arg === 'object') {
try { return JSON.stringify(arg, null, 2) } catch { return String(arg) }
}
return String(arg)
}).join(' ')
formattedParts = [{ text: message }]
}
const logEntry = { type, message, formattedParts, timestamp: Date.now() }
consoleLogRef.current.push(logEntry)
if (consoleLogRef.current.length > 1000) {
consoleLogRef.current = consoleLogRef.current.slice(-1000)
}
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)
setConsoleLogSearch('')
setConsoleLogLevel('all')
}
const handleClearConsoleLogs = () => {
consoleLogRef.current = []
setConsoleLogs([])
toast.success(t('Console logs cleared'))
}
const filteredConsoleLogs = useMemo(() => {
let filtered = [...consoleLogs]
if (consoleLogLevel === 'errors-warnings') {
filtered = filtered.filter(log => log.type === 'error' || log.type === 'warn')
}
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('')
setLoadingItems(true)
try {
const items = storeName === 'publicationEvents'
? await indexedDb.getPublicationStoreItems(storeName)
: await indexedDb.getStoreItems(storeName)
setStoreItems(items)
} catch (error) {
logger.error('Failed to load store items', { error })
toast.error(t('Failed to load store items'))
setStoreItems([])
} finally {
setLoadingItems(false)
}
}
const filteredStoreItems = useMemo(() => {
if (!searchQuery.trim()) return storeItems
const query = searchQuery.toLowerCase().trim()
return storeItems.filter(item => {
if (item.key?.toLowerCase().includes(query)) return true
try {
if (JSON.stringify(item.value).toLowerCase().includes(query)) return true
} catch (e) { /* skip */ }
if (new Date(item.addedAt).toLocaleString().toLowerCase().includes(query)) return true
return false
})
}, [storeItems, searchQuery])
const handleDeleteItem = async (key: string) => {
if (!selectedStore) return
try {
if (selectedStore === 'publicationEvents') {
const parts = key.split(':')
const pubkey = parts[0]
const d = parts[1] || undefined
const result = await indexedDb.deletePublicationAndNestedEvents(pubkey, d)
toast.success(t('Deleted {{count}} event(s)', { count: result.deleted }))
} else {
await indexedDb.deleteStoreItem(selectedStore, key)
toast.success(t('Item deleted successfully'))
}
const items = selectedStore === 'publicationEvents'
? await indexedDb.getPublicationStoreItems(selectedStore)
: await indexedDb.getStoreItems(selectedStore)
setStoreItems(items)
loadCacheInfo()
} catch (error) {
logger.error('Failed to delete item', { error })
toast.error(t('Failed to delete item'))
}
}
const handleDeleteAllItems = async () => {
if (!selectedStore) return
if (!confirm(t('Are you sure you want to delete all items from this store?'))) return
try {
await indexedDb.clearStore(selectedStore)
setStoreItems([])
loadCacheInfo()
toast.success(t('All items deleted successfully'))
} catch (error) {
logger.error('Failed to delete all items', { error })
toast.error(t('Failed to delete all items'))
}
}
const handleCleanupDuplicates = async () => {
if (!selectedStore) return
if (!confirm(t('Clean up duplicate replaceable events? This will keep only the newest version of each event.'))) return
setLoadingItems(true)
try {
const result = await indexedDb.cleanupDuplicateReplaceableEvents(selectedStore)
const items = await indexedDb.getStoreItems(selectedStore)
setStoreItems(items)
setSearchQuery('')
loadCacheInfo()
const itemsAfterCleanup = await indexedDb.getStoreItems(selectedStore)
const actualCount = itemsAfterCleanup.length
if (actualCount !== result.kept) {
toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}} (total items after cleanup: {{total}})', {
deleted: result.deleted, kept: result.kept, total: actualCount
}))
} else {
toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}}', { deleted: result.deleted, kept: result.kept }))
}
} catch (error) {
logger.error('Failed to cleanup duplicates', { error })
if (error instanceof Error && error.message === 'Not a replaceable event store') {
toast.error(t('This store does not contain replaceable events'))
} else {
toast.error(t('Failed to cleanup duplicates'))
}
} finally {
setLoadingItems(false)
}
}
const isInvalidEvent = useCallback((item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => {
if (!item) return true
if (storeName === 'rssFeedItems') {
return !(item.value || (item as any).item)
}
if (storeName === StoreNames.PIPER_TTS_CACHE) {
const v = item.value as { blob?: unknown; mimeType?: string } | null
return !(v && typeof v.mimeType === 'string' && v.blob instanceof Blob)
}
if (!item.value) return true
const event = item.value as Event
if (!event.pubkey || !event.kind || typeof event.created_at !== 'number') return true
if (!event.tags || !Array.isArray(event.tags)) return true
if (!event.id || !event.sig) return true
return false
}, [])
const getInvalidEventExplanation = useCallback((item: { key: string; value: any; addedAt: number }): string => {
if (!item || !item.value) return t('Event has no value data')
const event = item.value as Event
const missing: string[] = []
if (!event.pubkey) missing.push(t('pubkey'))
if (!event.kind) missing.push(t('kind'))
if (typeof event.created_at !== 'number') missing.push(t('created_at'))
if (!event.tags || !Array.isArray(event.tags)) missing.push(t('tags'))
if (!event.id) missing.push(t('id'))
if (!event.sig) missing.push(t('sig'))
if (missing.length > 0) return t('Event is missing required fields: {{fields}}', { fields: missing.join(', ') })
return t('Event appears to be invalid or corrupted')
}, [t])
const renderStoreListView = () =>
Object.keys(cacheInfo).length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No cached data found.')}</div>
) : (
Object.entries(cacheInfo).map(([storeName, count]) => (
<div
key={storeName}
className="border rounded-lg p-3 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleStoreClick(storeName)}
>
<div className="font-semibold text-sm break-words">{storeName}</div>
<div className="text-xs text-muted-foreground mt-1">{count} {t('items')}</div>
</div>
))
)
const renderStoreItemsView = () =>
loadingItems ? (
<div className="space-y-2 py-6" role="status" aria-busy="true">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div>
) : (
<>
<div className="relative py-1">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t('Search items...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
{storeItems.length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No items in this store.')}</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-muted-foreground">
{filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')}
{searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCleanupDuplicates} className="h-7 text-xs">
<RefreshCw className="h-3 w-3 mr-1" />
{t('Cleanup Duplicates')}
</Button>
<Button variant="destructive" size="sm" onClick={handleDeleteAllItems} className="h-7 text-xs">
<Trash2 className="h-3 w-3 mr-1" />
{t('Delete All')}
</Button>
</div>
</div>
{filteredStoreItems.length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No items match your search.')}</div>
) : (
filteredStoreItems.map((item, index) => {
const nestedCount = (item as any).nestedCount
const invalid = isInvalidEvent(item, selectedStore)
const invalidExplanation = invalid ? getInvalidEventExplanation(item) : ''
return (
<div key={item.key || index} className="border rounded-lg p-3 break-words relative">
<div className="absolute top-2 right-2 flex items-center gap-1">
{invalid && (
<HoverCard>
<HoverCardTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-amber-600 dark:text-amber-500 hover:text-amber-700 dark:hover:text-amber-400"
title={invalidExplanation}
>
<TriangleAlert className="h-3 w-3" />
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="space-y-2">
<div className="font-semibold text-sm flex items-center gap-2">
<TriangleAlert className="h-4 w-4 text-amber-600 dark:text-amber-500" />
{t('Invalid Event')}
</div>
<div className="text-sm text-muted-foreground">{invalidExplanation}</div>
</div>
</HoverCardContent>
</HoverCard>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteItem(item.key)}
className="h-6 w-6 p-0"
title={t('Delete item')}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className={`font-semibold text-xs mb-2 break-all ${invalid ? 'pr-16' : 'pr-8'}`}>
{item.key}
{typeof nestedCount === 'number' && nestedCount > 0 && (
<span className="ml-2 text-muted-foreground">
({nestedCount} {t('nested events')})
</span>
)}
</div>
<div className="text-xs text-muted-foreground mb-2">
{t('Added at')}: {new Date(item.addedAt).toLocaleString()}
</div>
<pre className={`text-xs bg-muted p-2 rounded overflow-auto max-h-96 select-text ${wordWrapEnabled ? 'overflow-x-hidden whitespace-pre-wrap break-words' : 'overflow-x-auto whitespace-pre'}`}>
{JSON.stringify(item.value, null, 2)}
</pre>
</div>
)
})
)}
</div>
)}
</>
)
const renderConsoleLogList = () =>
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.formattedParts ? (
log.formattedParts.map((part, i) => {
if (part.style) {
const styleObj: Record<string, string> = {}
part.style.split(';').forEach(rule => {
const trimmed = rule.trim()
if (trimmed) {
const colonIndex = trimmed.indexOf(':')
if (colonIndex > 0) {
const key = trimmed.substring(0, colonIndex).trim()
const value = trimmed.substring(colonIndex + 1).trim()
const camelKey = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
styleObj[camelKey] = value
}
}
})
return <span key={i} style={styleObj}>{part.text}</span>
}
return <span key={i}>{part.text}</span>
})
) : (
log.message
)}
</pre>
</div>
</div>
))
)
const consoleLogFilters = (
<div className="flex min-w-0 flex-wrap gap-2">
<Input
placeholder={t('Search logs...')}
value={consoleLogSearch}
onChange={(e) => setConsoleLogSearch(e.target.value)}
className="min-w-0 flex-1 basis-[min(100%,12rem)]"
/>
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant={consoleLogLevel === 'errors-warnings' ? 'secondary' : 'outline'}
size="sm"
onClick={() => setConsoleLogLevel('errors-warnings')}
>
{t('Errors & warnings')}
</Button>
<Button
type="button"
variant={consoleLogLevel === 'all' ? 'secondary' : 'outline'}
size="sm"
onClick={() => setConsoleLogLevel('all')}
>
{t('All')}
</Button>
</div>
</div>
)
const browseCacheHeader = (
<div className="flex items-center justify-between">
<div className="flex-1">
{selectedStore ? (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedStore(null); setStoreItems([]) }}
>
{t('Back')}
</Button>
{selectedStore}
</div>
) : (
t('Browse Cache')
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setWordWrapEnabled(!wordWrapEnabled)}
title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')}
>
<WrapText className={`h-4 w-4 ${wordWrapEnabled ? '' : 'opacity-50'}`} />
</Button>
</div>
)
const browseCacheDescription = selectedStore
? t('View cached items in this store.')
: t('View details about cached data in IndexedDB stores. Click on a store to view its items.')
return (
<div className="space-y-4">
<div className="text-xs text-muted-foreground space-y-1">
<div>{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}</div>
<div>
{t('refreshCacheButtonExplainer', {
defaultValue:
'Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.'
})}
</div>
</div>
<div className="flex min-w-0 flex-wrap gap-2">
<Button variant="outline" className="shrink-0" onClick={handleClearCache}>
<Trash2 className="mr-2 h-4 w-4" />
{t('Clear Cache')}
</Button>
<Button
variant="outline"
className="shrink-0"
onClick={handleRefreshCache}
disabled={cacheRefreshBusy}
>
<RefreshCw className={`mr-2 h-4 w-4 ${cacheRefreshBusy ? 'animate-spin' : ''}`} />
{t('Refresh Cache')}
</Button>
<Button variant="outline" className="shrink-0" onClick={handleBrowseCache}>
<Database className="mr-2 h-4 w-4" />
{t('Browse Cache')}
</Button>
<Button variant="outline" className="shrink-0" onClick={handleClearServiceWorker}>
<XCircle className="mr-2 h-4 w-4" />
{t('Clear Service Worker')}
</Button>
<Button variant="outline" className="shrink-0" onClick={handleShowConsoleLogs}>
<Terminal className="mr-2 h-4 w-4" />
{t('View Console Logs')} ({consoleLogRef.current.length})
</Button>
</div>
{isSmallScreen ? (
<Drawer open={browsingCache} onOpenChange={setBrowsingCache}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader>
{browseCacheHeader}
<DrawerTitle className="sr-only">{t('Browse Cache')}</DrawerTitle>
<DrawerDescription>{browseCacheDescription}</DrawerDescription>
</DrawerHeader>
<div className={`px-4 pb-4 space-y-4 overflow-y-auto ${wordWrapEnabled ? 'overflow-x-hidden break-words' : 'overflow-x-auto'}`}>
{!selectedStore ? renderStoreListView() : renderStoreItemsView()}
</div>
</DrawerContent>
</Drawer>
) : (
<Dialog open={browsingCache} onOpenChange={setBrowsingCache}>
<DialogContent className="max-w-[1000px] max-h-[1000px] overflow-y-auto overflow-x-hidden">
<DialogHeader>
{browseCacheHeader}
<DialogTitle className="sr-only">{t('Browse Cache')}</DialogTitle>
<DialogDescription>{browseCacheDescription}</DialogDescription>
</DialogHeader>
<div className={`space-y-4 ${wordWrapEnabled ? 'overflow-x-hidden break-words' : 'overflow-x-auto'}`}>
{!selectedStore ? renderStoreListView() : renderStoreItemsView()}
</div>
</DialogContent>
</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="space-y-2 px-4 pb-2">{consoleLogFilters}</div>
<div className="flex-1 overflow-auto px-4 pb-4">
<div className="space-y-1 font-mono text-xs">{renderConsoleLogList()}</div>
</div>
</DrawerContent>
</Drawer>
) : (
<Dialog open={showConsoleLogs} onOpenChange={setShowConsoleLogs}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col" withoutClose>
<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="space-y-2 px-6 pb-4">{consoleLogFilters}</div>
<div className="flex-1 overflow-auto px-6 pb-4">
<div className="space-y-1 font-mono text-xs">{renderConsoleLogList()}</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
)
}

79
src/components/Note/SelectionHighlightTrigger.tsx

@ -2,7 +2,7 @@ import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data' @@ -2,7 +2,7 @@ import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data'
import { useCreateHighlight } from './CreateHighlightContext'
import { Event } from 'nostr-tools'
import { Highlighter } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@ -36,8 +36,11 @@ export default function SelectionHighlightTrigger({ @@ -36,8 +36,11 @@ export default function SelectionHighlightTrigger({
top: number
left: number
} | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// True while a touch is physically in contact with the screen.
const isTouchActiveRef = useRef(false)
const handleMouseUp = useCallback(() => {
const evaluateSelection = useCallback(() => {
if (!openHighlight || !containerRef.current) return
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
@ -54,15 +57,64 @@ export default function SelectionHighlightTrigger({ @@ -54,15 +57,64 @@ export default function SelectionHighlightTrigger({
setToolbar(null)
return
}
const rect = range.getBoundingClientRect()
setToolbar({
selectedText,
paragraphContext: getParagraphContextFromRange(range),
top: rect.top - 44,
left: rect.left + rect.width / 2 - 80
})
const toolbarHeight = 44
const margin = 8
// Prefer above the selection; fall back to below if too close to top of viewport.
const top =
rect.top - toolbarHeight < margin ? rect.bottom + margin : rect.top - toolbarHeight
const rawLeft = rect.left + rect.width / 2 - 80
const left = Math.max(margin, Math.min(rawLeft, window.innerWidth - 176 - margin))
setToolbar({ selectedText, paragraphContext: getParagraphContextFromRange(range), top, left })
}, [openHighlight])
// Desktop: mouseup fires reliably after text selection by mouse.
const handleMouseUp = useCallback(() => {
evaluateSelection()
}, [evaluateSelection])
useEffect(() => {
if (!openHighlight) return
const schedule = (delayMs: number) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(evaluateSelection, delayMs)
}
// Mobile: finger touches screen — mark active so selectionchange is suppressed during
// the gesture itself (avoids positioning the toolbar mid-drag).
const onTouchStart = () => {
isTouchActiveRef.current = true
}
// Mobile: finger lifts — wait for the browser to settle the selection, then evaluate.
// The 600 ms matches the delay used in RssFeedItem for the same reason.
const onTouchEnd = () => {
isTouchActiveRef.current = false
schedule(600)
}
// Both: covers keyboard selection (Shift+Arrow) on desktop and selection-handle
// dragging on mobile (which may not generate touch events in our DOM).
const onSelectionChange = () => {
if (isTouchActiveRef.current) return
schedule(80)
}
document.addEventListener('touchstart', onTouchStart, { passive: true })
document.addEventListener('touchend', onTouchEnd, { passive: true })
document.addEventListener('selectionchange', onSelectionChange)
return () => {
document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('selectionchange', onSelectionChange)
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [openHighlight, evaluateSelection])
const handleCreateHighlight = useCallback(() => {
if (!toolbar || !openHighlight) return
const highlightData = buildHighlightDataFromEvent(event, toolbar.paragraphContext)
@ -84,10 +136,7 @@ export default function SelectionHighlightTrigger({ @@ -84,10 +136,7 @@ export default function SelectionHighlightTrigger({
<>
<div
className="fixed z-[150] flex items-center gap-1 rounded-md border bg-background px-2 py-1.5 shadow-lg"
style={{
top: toolbar.top,
left: Math.max(8, Math.min(toolbar.left, typeof window !== 'undefined' ? window.innerWidth - 176 : toolbar.left))
}}
style={{ top: toolbar.top, left: toolbar.left }}
>
<Button
type="button"
@ -103,11 +152,7 @@ export default function SelectionHighlightTrigger({ @@ -103,11 +152,7 @@ export default function SelectionHighlightTrigger({
{t('Cancel')}
</Button>
</div>
<div
className="fixed inset-0 z-[149]"
aria-hidden
onClick={handleDismiss}
/>
<div className="fixed inset-0 z-[149]" aria-hidden onClick={handleDismiss} />
</>
)}
</div>

8
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -19,7 +19,7 @@ import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds' @@ -19,7 +19,7 @@ import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds'
import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays'
import { normalizeTopic } from '@/lib/discussion-topics'
import { userIdToPubkey } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url'
import { normalizeAnyRelayUrl } from '@/lib/url'
import type { TFeedSubRequest } from '@/types'
import { type Event, type Filter } from 'nostr-tools'
@ -78,17 +78,17 @@ const INTERESTS_MAX_TOPIC_TAG_VALUES = INTERESTS_MAX_TOPICS * 4 @@ -78,17 +78,17 @@ const INTERESTS_MAX_TOPIC_TAG_VALUES = INTERESTS_MAX_TOPICS * 4
* filled the cap.
*/
export function appendCuratedReadOnlyRelays(curated: string[], blockedRelays: string[]): string[] {
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
const blocked = new Set(blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b))
const seen = new Set<string>()
const out: string[] = []
for (const u of READ_ONLY_RELAY_URLS) {
const k = normalizeUrl(u) || u
const k = normalizeAnyRelayUrl(u) || u
if (!k || blocked.has(k) || seen.has(k)) continue
seen.add(k)
out.push(k)
}
for (const u of curated) {
const k = normalizeUrl(u) || u
const k = normalizeAnyRelayUrl(u) || u
if (!k || seen.has(k)) continue
seen.add(k)
out.push(k)

6
src/pages/secondary/CacheSettingsPage/index.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import CacheRelaysSetting from '@/components/CacheRelaysSetting'
import InBrowserCacheSetting from '@/components/InBrowserCacheSetting'
import EventArchiveCacheSettings from '@/components/EventArchiveCacheSettings'
import { RefreshButton } from '@/components/RefreshButton'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
@ -29,8 +29,8 @@ const CacheSettingsPage = forwardRef( @@ -29,8 +29,8 @@ const CacheSettingsPage = forwardRef(
title={hideTitlebar ? undefined : t('Cache & offline storage')}
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
>
<div key={contentKey} className="px-4 py-3">
<CacheRelaysSetting />
<div key={contentKey} className="px-4 py-3 space-y-6">
<InBrowserCacheSetting />
<EventArchiveCacheSettings />
</div>
</SecondaryPageLayout>

8
src/pages/secondary/RelaySettingsPage/index.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import CacheRelaysSetting from '@/components/CacheRelaysSetting'
import HttpRelaysSetting from '@/components/HttpRelaysSetting'
import JsonViewDialog from '@/components/JsonViewDialog'
import MailboxSetting from '@/components/MailboxSetting'
@ -70,6 +71,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -70,6 +71,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
case '#favorite-relays':
setTabValue('favorite-relays')
break
case '#cache-relays':
setTabValue('cache-relays')
break
}
}, [])
@ -115,6 +119,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -115,6 +119,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<TabsTrigger value="mailbox" className="w-full sm:w-auto">{t('Read & Write Relays')}</TabsTrigger>
<TabsTrigger value="http-relays" className="w-full sm:w-auto">{t('HTTP relays')}</TabsTrigger>
<TabsTrigger value="session-relays" className="w-full sm:w-auto">{t('Session relays')}</TabsTrigger>
<TabsTrigger value="cache-relays" className="w-full sm:w-auto">{t('Cache Relays')}</TabsTrigger>
</TabsList>
<TabsContent value="favorite-relays">
<FavoriteRelaysSetting />
@ -128,6 +133,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -128,6 +133,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<TabsContent value="session-relays">
<SessionRelaysTab />
</TabsContent>
<TabsContent value="cache-relays">
<CacheRelaysSetting />
</TabsContent>
</Tabs>
</SecondaryPageLayout>
)

Loading…
Cancel
Save