Browse Source

fix cache relay

imwald
Silberengel 1 month ago
parent
commit
fb4ad32c1d
  1. 1
      package.json
  2. 18
      scripts/start-orly-cache-relay.sh
  3. 6
      src/PageManager.tsx
  4. 133
      src/components/CacheBrowser/CacheBrowserDialog.tsx
  5. 7
      src/components/Relay/index.tsx
  6. 9
      src/i18n/locales/cs.ts
  7. 9
      src/i18n/locales/de.ts
  8. 10
      src/i18n/locales/en.ts
  9. 9
      src/i18n/locales/es.ts
  10. 9
      src/i18n/locales/fr.ts
  11. 9
      src/i18n/locales/nl.ts
  12. 9
      src/i18n/locales/pl.ts
  13. 9
      src/i18n/locales/ru.ts
  14. 9
      src/i18n/locales/tr.ts
  15. 9
      src/i18n/locales/zh.ts
  16. 21
      src/lib/event-metadata.ts
  17. 18
      src/providers/NostrProvider/index.tsx
  18. 11
      src/services/client.service.ts
  19. 3
      src/services/indexed-db.service.ts
  20. 36
      vite.config.ts

1
package.json

@ -14,6 +14,7 @@
"homepage": "https://github.com/Silberengel/jumble", "homepage": "https://github.com/Silberengel/jumble",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"orly:relay": "bash scripts/start-orly-cache-relay.sh",
"dev:all": "bash scripts/dev-all-local.sh", "dev:all": "bash scripts/dev-all-local.sh",
"stack:remote": "bash scripts/stack-remote.sh", "stack:remote": "bash scripts/stack-remote.sh",
"dev:refresh": "rm -rf node_modules/.vite && vite --host", "dev:refresh": "rm -rf node_modules/.vite && vite --host",

18
scripts/start-orly-cache-relay.sh

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Start a local ORLY Nostr relay for Jumble dev/testing (sibling repo ../next.orly.dev).
# Default: ws://127.0.0.1:4869/ — add that URL as a cache relay in settings.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ORLY_ROOT="${ORLY_ROOT:-$ROOT/../next.orly.dev}"
export ORLY_PORT="${ORLY_PORT:-4869}"
export ORLY_ACL_MODE="${ORLY_ACL_MODE:-none}"
export ORLY_DATA_DIR="${ORLY_DATA_DIR:-${TMPDIR:-/tmp}/orly-jumble-relay-$ORLY_PORT}"
mkdir -p "$ORLY_DATA_DIR"
if [[ ! -d "$ORLY_ROOT/cmd/orly" ]]; then
echo "ORLY repo not found at: $ORLY_ROOT" >&2
echo "Set ORLY_ROOT to your next.orly.dev checkout." >&2
exit 1
fi
echo "Orly: ws://127.0.0.1:${ORLY_PORT}/ (ORLY_DATA_DIR=$ORLY_DATA_DIR)"
cd "$ORLY_ROOT"
exec go run ./cmd/orly

6
src/PageManager.tsx

@ -1936,6 +1936,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const pushSecondaryPage = (url: string, index?: number) => { const pushSecondaryPage = (url: string, index?: number) => {
logger.component('PageManager', 'pushSecondaryPage called', { url }) logger.component('PageManager', 'pushSecondaryPage called', { url })
// Small screens render either the primary overlay OR the secondary stack — not both.
// Clear overlays (e.g. full-screen note) so pushes from Seen-on, settings deep links, etc. show the target page.
if (isSmallScreen && primaryNoteView) {
setPrimaryNoteView(null)
}
// Save tab state before navigating // Save tab state before navigating
const currentTab = currentTabStateRef.current.get(currentPrimaryPage) const currentTab = currentTabStateRef.current.get(currentPrimaryPage)

133
src/components/CacheBrowser/CacheBrowserDialog.tsx

@ -2,10 +2,10 @@ import { Button } from '@/components/ui/button'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy } from 'lucide-react' import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy, Radio } from 'lucide-react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import indexedDb, { StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service' import indexedDb, { isLikelyCachedNostrEvent, StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
@ -13,6 +13,9 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { toastPublishPromise } from '@/lib/publishing-feedback'
const GLOBAL_CACHED_EVENTS_SEARCH_LIMIT = 400 const GLOBAL_CACHED_EVENTS_SEARCH_LIMIT = 400
@ -25,6 +28,8 @@ export default function CacheBrowserDialog({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey: accountPubkey } = useNostr()
const [broadcastingKey, setBroadcastingKey] = useState<string | null>(null)
const [cacheInfo, setCacheInfo] = useState<Record<string, number>>({}) const [cacheInfo, setCacheInfo] = useState<Record<string, number>>({})
const [selectedStore, setSelectedStore] = useState<string | null>(null) const [selectedStore, setSelectedStore] = useState<string | null>(null)
const [storeItems, setStoreItems] = useState<any[]>([]) const [storeItems, setStoreItems] = useState<any[]>([])
@ -228,6 +233,54 @@ export default function CacheBrowserDialog({
} }
} }
const handleBroadcastToMailboxStack = useCallback(
(event: Event, itemKey: string) => {
if (!accountPubkey) {
toast.error(t('Log in to broadcast'))
return
}
if (event.pubkey?.toLowerCase() !== accountPubkey.toLowerCase()) {
toast.error(t('Only the author can broadcast this event'))
return
}
setBroadcastingKey(itemKey)
const promise = (async () => {
try {
const urls = await client.getMailboxStackWriteUrlsForRepublish(accountPubkey)
if (!urls.length) {
throw new Error(t('No mailbox cache or HTTP write relays configured'))
}
const result = await client.publishEvent(urls, event, { skipOutboxRetry: true })
if (result.successCount < 1) {
throw new Error(t('No relay accepted the event'))
}
return result
} finally {
setBroadcastingKey(null)
}
})()
toastPublishPromise(promise, {
loading: t('Broadcasting to outbox HTTP and cache relays…'),
success: () => t('Broadcast to mailbox stack finished'),
error: (err) =>
t('Failed to broadcast: {{error}}', {
error: err instanceof Error ? err.message : String(err)
})
})
},
[accountPubkey, t]
)
/** Same predicate as full-text cache search — avoids hiding broadcast on valid rows that fail stricter checks. */
const rowLooksLikeNostrEventForBroadcast = useCallback((v: unknown, storeName?: string | null): v is Event => {
if (storeName === 'rssFeedItems' || storeName === StoreNames.PIPER_TTS_CACHE) return false
return isLikelyCachedNostrEvent(v)
}, [])
const nostrEventHasPublishableSig = useCallback((e: Event): boolean => {
return typeof e.sig === 'string' && e.sig.trim().length > 0
}, [])
const isInvalidEvent = useCallback( const isInvalidEvent = useCallback(
(item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => { (item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => {
if (!item) return true if (!item) return true
@ -240,9 +293,9 @@ export default function CacheBrowserDialog({
} }
if (!item.value) return true if (!item.value) return true
const event = item.value as Event const event = item.value as Event
if (!event.pubkey || !event.kind || typeof event.created_at !== 'number') return true if (!event.pubkey || typeof event.kind !== 'number' || typeof event.created_at !== 'number') return true
if (!event.tags || !Array.isArray(event.tags)) return true if (!event.tags || !Array.isArray(event.tags)) return true
if (!event.id || !event.sig) return true if (!event.id || typeof event.sig !== 'string' || !event.sig.trim()) return true
return false return false
}, },
[] []
@ -254,11 +307,11 @@ export default function CacheBrowserDialog({
const event = item.value as Event const event = item.value as Event
const missing: string[] = [] const missing: string[] = []
if (!event.pubkey) missing.push(t('pubkey')) if (!event.pubkey) missing.push(t('pubkey'))
if (!event.kind) missing.push(t('kind')) if (typeof event.kind !== 'number') missing.push(t('kind'))
if (typeof event.created_at !== 'number') missing.push(t('created_at')) if (typeof event.created_at !== 'number') missing.push(t('created_at'))
if (!event.tags || !Array.isArray(event.tags)) missing.push(t('tags')) if (!event.tags || !Array.isArray(event.tags)) missing.push(t('tags'))
if (!event.id) missing.push(t('id')) if (!event.id) missing.push(t('id'))
if (!event.sig) missing.push(t('sig')) if (typeof event.sig !== 'string' || !event.sig.trim()) missing.push(t('sig'))
if (missing.length > 0) return t('Event is missing required fields: {{fields}}', { fields: missing.join(', ') }) if (missing.length > 0) return t('Event is missing required fields: {{fields}}', { fields: missing.join(', ') })
return t('Event appears to be invalid or corrupted') return t('Event appears to be invalid or corrupted')
}, },
@ -292,9 +345,25 @@ export default function CacheBrowserDialog({
{t('Showing first {{count}} cached event matches.', { count: GLOBAL_CACHED_EVENTS_SEARCH_LIMIT })} {t('Showing first {{count}} cached event matches.', { count: GLOBAL_CACHED_EVENTS_SEARCH_LIMIT })}
</p> </p>
)} )}
{globalSearchHits.map((hit) => ( {globalSearchHits.map((hit) => {
const hitRowKey = `${hit.storeName}:${hit.key}:${hit.value.id}`
const hitEvent = hit.value as Event
const showBroadcast = rowLooksLikeNostrEventForBroadcast(hit.value, hit.storeName)
const hasSig = nostrEventHasPublishableSig(hitEvent)
const authorOk =
!!accountPubkey && hitEvent.pubkey?.toLowerCase() === accountPubkey.toLowerCase()
const broadcastDisabled =
!accountPubkey || !authorOk || broadcastingKey !== null || !hasSig
const broadcastTitle = !accountPubkey
? t('Log in to broadcast')
: !hasSig
? t('Event must be signed to broadcast')
: !authorOk
? t('Only the author can broadcast this event')
: t('Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays')
return (
<div <div
key={`${hit.storeName}:${hit.key}:${hit.value.id}`} key={hitRowKey}
className="space-y-2 rounded-lg border p-3 transition-colors hover:bg-muted/50" className="space-y-2 rounded-lg border p-3 transition-colors hover:bg-muted/50"
> >
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
@ -324,9 +393,24 @@ export default function CacheBrowserDialog({
<Copy className="mr-1 h-3 w-3" /> <Copy className="mr-1 h-3 w-3" />
{t('Copy event JSON')} {t('Copy event JSON')}
</Button> </Button>
{showBroadcast && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
type="button"
disabled={broadcastDisabled}
title={broadcastTitle}
onClick={() => handleBroadcastToMailboxStack(hitEvent, hitRowKey)}
>
<Radio className="mr-1 h-3 w-3" />
{t('Broadcast to mailbox stack')}
</Button>
)}
</div> </div>
</div> </div>
))} )
})}
</div> </div>
) )
} }
@ -396,6 +480,20 @@ export default function CacheBrowserDialog({
const nestedCount = (item as any).nestedCount const nestedCount = (item as any).nestedCount
const invalid = isInvalidEvent(item, selectedStore) const invalid = isInvalidEvent(item, selectedStore)
const invalidExplanation = invalid ? getInvalidEventExplanation(item) : '' const invalidExplanation = invalid ? getInvalidEventExplanation(item) : ''
const showBroadcast = rowLooksLikeNostrEventForBroadcast(item.value, selectedStore)
const rowEvent = item.value as Event
const hasSig = nostrEventHasPublishableSig(rowEvent)
const authorMatches =
!!accountPubkey && rowEvent.pubkey?.toLowerCase() === accountPubkey.toLowerCase()
const broadcastDisabled =
!accountPubkey || !authorMatches || broadcastingKey !== null || !hasSig
const broadcastTitle = !accountPubkey
? t('Log in to broadcast')
: !hasSig
? t('Event must be signed to broadcast')
: !authorMatches
? t('Only the author can broadcast this event')
: t('Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays')
return ( return (
<div <div
key={item.key || index} key={item.key || index}
@ -425,6 +523,19 @@ export default function CacheBrowserDialog({
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
)} )}
{showBroadcast && (
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleBroadcastToMailboxStack(rowEvent, item.key)}
disabled={broadcastDisabled}
className="h-6 w-6 p-0"
title={broadcastTitle}
>
<Radio className="h-3 w-3" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -445,7 +556,9 @@ export default function CacheBrowserDialog({
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>
<div className={`font-semibold text-xs mb-2 break-all ${invalid ? 'pr-24' : 'pr-20'}`}> <div
className={`font-semibold text-xs mb-2 break-all ${invalid || showBroadcast ? 'pr-28' : 'pr-20'}`}
>
{item.key} {item.key}
{typeof nestedCount === 'number' && nestedCount > 0 && ( {typeof nestedCount === 'number' && nestedCount > 0 && (
<span className="ml-2 text-muted-foreground"> <span className="ml-2 text-muted-foreground">

7
src/components/Relay/index.tsx

@ -5,7 +5,7 @@ import SearchInput from '@/components/SearchInput'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import { normalizeAnyRelayUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
@ -87,11 +87,14 @@ const Relay = forwardRef<
const shouldHideEventNotFromThisRelay = useCallback( const shouldHideEventNotFromThisRelay = useCallback(
(ev: Event) => { (ev: Event) => {
if (!relaySeenMatchKey) return false if (!relaySeenMatchKey) return false
// LAN/loopback: REQ already targets this relay; "seen on" often lists another URL first
// (favorites merge, localhost vs 127.0.0.1, etc.) — hiding would empty the relay-only feed.
if (normalizedUrl && isLocalNetworkUrl(normalizedUrl)) return false
const seen = client.getSeenEventRelayUrls(ev.id) const seen = client.getSeenEventRelayUrls(ev.id)
if (seen.length === 0) return false if (seen.length === 0) return false
return !seen.some((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase() === relaySeenMatchKey) return !seen.some((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase() === relaySeenMatchKey)
}, },
[relaySeenMatchKey] [relaySeenMatchKey, normalizedUrl]
) )
if (!normalizedUrl) { if (!normalizedUrl) {

9
src/i18n/locales/cs.ts

@ -1369,6 +1369,15 @@ export default {
"Open in store": "Open in store", "Open in store": "Open in store",
"Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.", "Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.",
"Copy event JSON": "Copy event JSON", "Copy event JSON": "Copy event JSON",
"Broadcast to mailbox stack": "Broadcast to mailbox stack",
"Broadcasting to outbox HTTP and cache relays…": "Broadcasting to outbox, HTTP, and cache relays…",
"Broadcast to mailbox stack finished": "Broadcast to mailbox stack finished",
"Failed to broadcast: {{error}}": "Failed to broadcast: {{error}}",
"Log in to broadcast": "Log in to broadcast",
"Only the author can broadcast this event": "Only the author can broadcast this event",
"No mailbox cache or HTTP write relays configured": "No mailbox, cache, or HTTP write relays configured",
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays":
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays",
"C-Tag": "C-Tag", "C-Tag": "C-Tag",
"Cache Relays": "Cache Relays", "Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully", "Cache cleared successfully": "Cache cleared successfully",

9
src/i18n/locales/de.ts

@ -1385,6 +1385,15 @@ export default {
"Open in store": "Open in store", "Open in store": "Open in store",
"Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.", "Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.",
"Copy event JSON": "Copy event JSON", "Copy event JSON": "Copy event JSON",
"Broadcast to mailbox stack": "Broadcast to mailbox stack",
"Broadcasting to outbox HTTP and cache relays…": "Broadcasting to outbox, HTTP, and cache relays…",
"Broadcast to mailbox stack finished": "Broadcast to mailbox stack finished",
"Failed to broadcast: {{error}}": "Failed to broadcast: {{error}}",
"Log in to broadcast": "Log in to broadcast",
"Only the author can broadcast this event": "Only the author can broadcast this event",
"No mailbox cache or HTTP write relays configured": "No mailbox, cache, or HTTP write relays configured",
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays":
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays",
"C-Tag": "C-Tag", "C-Tag": "C-Tag",
"Cache Relays": "Cache Relays", "Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully", "Cache cleared successfully": "Cache cleared successfully",

10
src/i18n/locales/en.ts

@ -1431,6 +1431,16 @@ export default {
"Open in store": "Open in store", "Open in store": "Open in store",
"Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.", "Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.",
"Copy event JSON": "Copy event JSON", "Copy event JSON": "Copy event JSON",
"Broadcast to mailbox stack": "Broadcast to mailbox stack",
"Broadcasting to outbox HTTP and cache relays…": "Broadcasting to outbox, HTTP, and cache relays…",
"Broadcast to mailbox stack finished": "Broadcast to mailbox stack finished",
"Failed to broadcast: {{error}}": "Failed to broadcast: {{error}}",
"Log in to broadcast": "Log in to broadcast",
"Only the author can broadcast this event": "Only the author can broadcast this event",
"Event must be signed to broadcast": "Event must be signed to broadcast",
"No mailbox cache or HTTP write relays configured": "No mailbox, cache, or HTTP write relays configured",
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays":
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays",
"C-Tag": "C-Tag", "C-Tag": "C-Tag",
"Cache Relays": "Cache Relays", "Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully", "Cache cleared successfully": "Cache cleared successfully",

9
src/i18n/locales/es.ts

@ -1369,6 +1369,15 @@ export default {
"Open in store": "Open in store", "Open in store": "Open in store",
"Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.", "Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.",
"Copy event JSON": "Copy event JSON", "Copy event JSON": "Copy event JSON",
"Broadcast to mailbox stack": "Broadcast to mailbox stack",
"Broadcasting to outbox HTTP and cache relays…": "Broadcasting to outbox, HTTP, and cache relays…",
"Broadcast to mailbox stack finished": "Broadcast to mailbox stack finished",
"Failed to broadcast: {{error}}": "Failed to broadcast: {{error}}",
"Log in to broadcast": "Log in to broadcast",
"Only the author can broadcast this event": "Only the author can broadcast this event",
"No mailbox cache or HTTP write relays configured": "No mailbox, cache, or HTTP write relays configured",
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays":
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays",
"C-Tag": "C-Tag", "C-Tag": "C-Tag",
"Cache Relays": "Cache Relays", "Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully", "Cache cleared successfully": "Cache cleared successfully",

9
src/i18n/locales/fr.ts

@ -1369,6 +1369,15 @@ export default {
"Open in store": "Open in store", "Open in store": "Open in store",
"Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.", "Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.",
"Copy event JSON": "Copy event JSON", "Copy event JSON": "Copy event JSON",
"Broadcast to mailbox stack": "Broadcast to mailbox stack",
"Broadcasting to outbox HTTP and cache relays…": "Broadcasting to outbox, HTTP, and cache relays…",
"Broadcast to mailbox stack finished": "Broadcast to mailbox stack finished",
"Failed to broadcast: {{error}}": "Failed to broadcast: {{error}}",
"Log in to broadcast": "Log in to broadcast",
"Only the author can broadcast this event": "Only the author can broadcast this event",
"No mailbox cache or HTTP write relays configured": "No mailbox, cache, or HTTP write relays configured",
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays":
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays",
"C-Tag": "C-Tag", "C-Tag": "C-Tag",
"Cache Relays": "Cache Relays", "Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully", "Cache cleared successfully": "Cache cleared successfully",

9
src/i18n/locales/nl.ts

@ -1369,6 +1369,15 @@ export default {
"Open in store": "Open in store", "Open in store": "Open in store",
"Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.", "Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.",
"Copy event JSON": "Copy event JSON", "Copy event JSON": "Copy event JSON",
"Broadcast to mailbox stack": "Broadcast to mailbox stack",
"Broadcasting to outbox HTTP and cache relays…": "Broadcasting to outbox, HTTP, and cache relays…",
"Broadcast to mailbox stack finished": "Broadcast to mailbox stack finished",
"Failed to broadcast: {{error}}": "Failed to broadcast: {{error}}",
"Log in to broadcast": "Log in to broadcast",
"Only the author can broadcast this event": "Only the author can broadcast this event",
"No mailbox cache or HTTP write relays configured": "No mailbox, cache, or HTTP write relays configured",
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays":
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays",
"C-Tag": "C-Tag", "C-Tag": "C-Tag",
"Cache Relays": "Cache Relays", "Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully", "Cache cleared successfully": "Cache cleared successfully",

9
src/i18n/locales/pl.ts

@ -1369,6 +1369,15 @@ export default {
"Open in store": "Open in store", "Open in store": "Open in store",
"Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.", "Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.",
"Copy event JSON": "Copy event JSON", "Copy event JSON": "Copy event JSON",
"Broadcast to mailbox stack": "Broadcast to mailbox stack",
"Broadcasting to outbox HTTP and cache relays…": "Broadcasting to outbox, HTTP, and cache relays…",
"Broadcast to mailbox stack finished": "Broadcast to mailbox stack finished",
"Failed to broadcast: {{error}}": "Failed to broadcast: {{error}}",
"Log in to broadcast": "Log in to broadcast",
"Only the author can broadcast this event": "Only the author can broadcast this event",
"No mailbox cache or HTTP write relays configured": "No mailbox, cache, or HTTP write relays configured",
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays":
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays",
"C-Tag": "C-Tag", "C-Tag": "C-Tag",
"Cache Relays": "Cache Relays", "Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully", "Cache cleared successfully": "Cache cleared successfully",

9
src/i18n/locales/ru.ts

@ -1369,6 +1369,15 @@ export default {
"Open in store": "Open in store", "Open in store": "Open in store",
"Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.", "Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.",
"Copy event JSON": "Copy event JSON", "Copy event JSON": "Copy event JSON",
"Broadcast to mailbox stack": "Broadcast to mailbox stack",
"Broadcasting to outbox HTTP and cache relays…": "Broadcasting to outbox, HTTP, and cache relays…",
"Broadcast to mailbox stack finished": "Broadcast to mailbox stack finished",
"Failed to broadcast: {{error}}": "Failed to broadcast: {{error}}",
"Log in to broadcast": "Log in to broadcast",
"Only the author can broadcast this event": "Only the author can broadcast this event",
"No mailbox cache or HTTP write relays configured": "No mailbox, cache, or HTTP write relays configured",
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays":
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays",
"C-Tag": "C-Tag", "C-Tag": "C-Tag",
"Cache Relays": "Cache Relays", "Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully", "Cache cleared successfully": "Cache cleared successfully",

9
src/i18n/locales/tr.ts

@ -1369,6 +1369,15 @@ export default {
"Open in store": "Open in store", "Open in store": "Open in store",
"Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.", "Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.",
"Copy event JSON": "Copy event JSON", "Copy event JSON": "Copy event JSON",
"Broadcast to mailbox stack": "Broadcast to mailbox stack",
"Broadcasting to outbox HTTP and cache relays…": "Broadcasting to outbox, HTTP, and cache relays…",
"Broadcast to mailbox stack finished": "Broadcast to mailbox stack finished",
"Failed to broadcast: {{error}}": "Failed to broadcast: {{error}}",
"Log in to broadcast": "Log in to broadcast",
"Only the author can broadcast this event": "Only the author can broadcast this event",
"No mailbox cache or HTTP write relays configured": "No mailbox, cache, or HTTP write relays configured",
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays":
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays",
"C-Tag": "C-Tag", "C-Tag": "C-Tag",
"Cache Relays": "Cache Relays", "Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully", "Cache cleared successfully": "Cache cleared successfully",

9
src/i18n/locales/zh.ts

@ -1369,6 +1369,15 @@ export default {
"Open in store": "Open in store", "Open in store": "Open in store",
"Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.", "Browse cache root description": "View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.",
"Copy event JSON": "Copy event JSON", "Copy event JSON": "Copy event JSON",
"Broadcast to mailbox stack": "Broadcast to mailbox stack",
"Broadcasting to outbox HTTP and cache relays…": "Broadcasting to outbox, HTTP, and cache relays…",
"Broadcast to mailbox stack finished": "Broadcast to mailbox stack finished",
"Failed to broadcast: {{error}}": "Failed to broadcast: {{error}}",
"Log in to broadcast": "Log in to broadcast",
"Only the author can broadcast this event": "Only the author can broadcast this event",
"No mailbox cache or HTTP write relays configured": "No mailbox, cache, or HTTP write relays configured",
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays":
"Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays",
"C-Tag": "C-Tag", "C-Tag": "C-Tag",
"Cache Relays": "Cache Relays", "Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully", "Cache cleared successfully": "Cache cleared successfully",

21
src/lib/event-metadata.ts

@ -2,7 +2,7 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, POLL_TYPE }
import { TEmoji, TMailboxRelay, TPollType, TRelayList, TRelaySet, TPaymentInfo, TProfile } from '@/types' import { TEmoji, TMailboxRelay, TPollType, TRelayList, TRelaySet, TPaymentInfo, TProfile } from '@/types'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { buildATag } from './draft-event' import { buildATag } from './draft-event'
import { getReplaceableEventIdentifier } from './event' import { getLatestEvent, getReplaceableEventIdentifier } from './event'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
import { formatPubkey, pubkeyToNpub } from './pubkey' import { formatPubkey, pubkeyToNpub } from './pubkey'
import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag' import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag'
@ -24,6 +24,25 @@ export type GetRelayListFromEventOptions = {
globalReadWriteFallback?: boolean globalReadWriteFallback?: boolean
} }
/**
* Merge kind-10432 (cache relays) from a network fetch with IndexedDB for session hydrate.
* Some mirrors return an empty or malformed 10432 with a newer `created_at` than good local data; prefer any
* candidate that still parses to at least one `r` WebSocket URL, then newest by time.
*/
export function mergeHydratedCacheRelayListEvents(
fetchedEvents: Event[],
stored: Event | undefined | null
): Event | null {
const fromFetch = fetchedEvents.length ? getLatestEvent(fetchedEvents) : undefined
const candidates = [fromFetch, stored ?? undefined].filter((e): e is Event => Boolean(e))
if (candidates.length === 0) return null
const relayRowCount = (e: Event) =>
getRelayListFromEvent(e, undefined, { globalReadWriteFallback: false }).originalRelays.length
const withRelays = candidates.filter((e) => relayRowCount(e) > 0)
const pool = withRelays.length > 0 ? withRelays : candidates
return pool.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))[0]!
}
export function getRelayListFromEvent( export function getRelayListFromEvent(
event?: Event | null, event?: Event | null,
blockedRelays?: string[], blockedRelays?: string[],

18
src/providers/NostrProvider/index.tsx

@ -23,7 +23,12 @@ import {
} from '@/lib/draft-event' } from '@/lib/draft-event'
import { getLatestEvent, minePow } from '@/lib/event' import { getLatestEvent, minePow } from '@/lib/event'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import {
getHttpRelayListFromEvent,
getProfileFromEvent,
getRelayListFromEvent,
mergeHydratedCacheRelayListEvents
} from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { LoginRequiredError } from '@/lib/nostr-errors' import { LoginRequiredError } from '@/lib/nostr-errors'
@ -246,6 +251,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setMuteListEvent(null) setMuteListEvent(null)
setBookmarkListEvent(null) setBookmarkListEvent(null)
setRssFeedListEvent(null) setRssFeedListEvent(null)
setCacheRelayListEvent(null)
setHttpRelayListEvent(undefined) setHttpRelayListEvent(undefined)
} }
@ -414,6 +420,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setHttpRelayListEvent(storedHttpRelayListEvent ?? null) setHttpRelayListEvent(storedHttpRelayListEvent ?? null)
} }
/** Kind 10432: always surface IDB in UI (incl. forced network hydrate); network merge refines below. */
if (storedCacheRelayListEvent) {
setCacheRelayListEvent(storedCacheRelayListEvent)
}
const lastNetworkHydrateAt = storage.getAccountNetworkHydrateAt(account.pubkey) const lastNetworkHydrateAt = storage.getAccountNetworkHydrateAt(account.pubkey)
const hasLocalRelayAndProfile = !!storedRelayListEvent && !!storedProfileEvent const hasLocalRelayAndProfile = !!storedRelayListEvent && !!storedProfileEvent
const skipNetworkHydrate = const skipNetworkHydrate =
@ -496,7 +507,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return controller return controller
} }
const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent
const cacheRelayListEvent = getLatestEvent(cacheRelayListEvents) ?? storedCacheRelayListEvent const cacheRelayListEvent = mergeHydratedCacheRelayListEvents(
cacheRelayListEvents,
storedCacheRelayListEvent
)
const httpRelayListEventFetched = getLatestEvent(httpRelayListEvents) ?? storedHttpRelayListEvent ?? null const httpRelayListEventFetched = getLatestEvent(httpRelayListEvents) ?? storedHttpRelayListEvent ?? null
if (relayListEvent) { if (relayListEvent) {
client.updateRelayListCache(relayListEvent) client.updateRelayListCache(relayListEvent)

11
src/services/client.service.ts

@ -4110,6 +4110,17 @@ class ClientService extends EventTarget {
return rl! return rl!
} }
/**
* Write targets for republishing from the cache browser: merged NIP-65 WS outbox + kind 10432 cache relays +
* kind 10243 HTTP write relays (same merge as {@link peekRelayListFromStorage}). No FAST_WRITE padding.
*/
async getMailboxStackWriteUrlsForRepublish(pubkey: string): Promise<string[]> {
const rl = await this.peekRelayListFromStorage(pubkey)
const ws = (rl.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u)
const http = (rl.httpWrite ?? []).map((u) => normalizeHttpRelayUrl(u) || u).filter((u): u is string => !!u)
return dedupeNormalizeRelayUrlsOrdered([...http, ...ws])
}
/** Newest kind 10002 for `pubkey` from IndexedDB and/or session LRU (session may hold a copy not persisted yet). */ /** Newest kind 10002 for `pubkey` from IndexedDB and/or session LRU (session may hold a copy not persisted yet). */
private async getKind10002FromIdbOrSession(pubkey: string): Promise<NEvent | undefined | null> { private async getKind10002FromIdbOrSession(pubkey: string): Promise<NEvent | undefined | null> {
let idb: NEvent | undefined | null let idb: NEvent | undefined | null

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

@ -64,7 +64,8 @@ export type TCachedEventSearchHit = {
addedAt: number addedAt: number
} }
function isLikelyCachedNostrEvent(v: unknown): v is Event { /** Shape check for persisted rows that look like Nostr events (used by cache search and cache browser). */
export function isLikelyCachedNostrEvent(v: unknown): v is Event {
if (!v || typeof v !== 'object') return false if (!v || typeof v !== 'object') return false
const o = v as Record<string, unknown> const o = v as Record<string, unknown>
return ( return (

36
vite.config.ts

@ -119,6 +119,31 @@ function quietOptionalDevProxyErrors(devIndexRelayTarget: string): Plugin {
} }
} }
/**
* Loopback / RFC1918 / ULA mirrors `isLocalNetworkUrl` in `src/lib/url.ts` without importing it
* (Vite's config bundle does not resolve `@/` for transitive deps).
*/
function isLocalNetworkHostForSw(url: URL): boolean {
const hostname = url.hostname
if (hostname === 'localhost' || hostname === '::1') return true
const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)
if (ipv4Match) {
const [, a, b, c, d] = ipv4Match.map(Number)
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 127 && b === 0 && c === 0 && d === 1)
)
}
if (hostname.includes(':')) {
const lower = hostname.toLowerCase()
if (lower.startsWith('fe80:')) return true
if (lower.startsWith('fc') || lower.startsWith('fd')) return true
}
return false
}
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
// `.env.local` is not on `process.env` when this file is evaluated unless we load it. // `.env.local` is not on `process.env` when this file is evaluated unless we load it.
@ -500,8 +525,15 @@ export default defineConfig(({ mode }) => {
{ {
// NIP-11 relay info documents: short-lived cache so relay metadata is fresh but // NIP-11 relay info documents: short-lived cache so relay metadata is fresh but
// the app can render offline or on a slow connection without blocking on network. // the app can render offline or on a slow connection without blocking on network.
urlPattern: ({ request }: { request: Request }) => // Do not intercept loopback/LAN: cross-origin + CORS often yields no cacheable response;
request.headers.get('accept')?.includes('application/nostr+json') ?? false, // StaleWhileRevalidate then rejects (Firefox: SW "no-response") and breaks relay pages.
urlPattern: ({ request, url }: { request: Request; url: URL }) => {
if (!(request.headers.get('accept')?.includes('application/nostr+json') ?? false)) {
return false
}
if (isLocalNetworkHostForSw(url)) return false
return true
},
handler: 'StaleWhileRevalidate', handler: 'StaleWhileRevalidate',
options: { options: {
cacheName: 'nip11-relay-info', cacheName: 'nip11-relay-info',

Loading…
Cancel
Save