diff --git a/package.json b/package.json
index 4535eb80..576b04b2 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"homepage": "https://github.com/Silberengel/jumble",
"scripts": {
"dev": "vite --host",
+ "orly:relay": "bash scripts/start-orly-cache-relay.sh",
"dev:all": "bash scripts/dev-all-local.sh",
"stack:remote": "bash scripts/stack-remote.sh",
"dev:refresh": "rm -rf node_modules/.vite && vite --host",
diff --git a/scripts/start-orly-cache-relay.sh b/scripts/start-orly-cache-relay.sh
new file mode 100755
index 00000000..78b5671c
--- /dev/null
+++ b/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
diff --git a/src/PageManager.tsx b/src/PageManager.tsx
index 2f98be16..412915ce 100644
--- a/src/PageManager.tsx
+++ b/src/PageManager.tsx
@@ -1935,7 +1935,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const pushSecondaryPage = (url: string, index?: number) => {
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
const currentTab = currentTabStateRef.current.get(currentPrimaryPage)
diff --git a/src/components/CacheBrowser/CacheBrowserDialog.tsx b/src/components/CacheBrowser/CacheBrowserDialog.tsx
index ceb7e24b..0fe0e75c 100644
--- a/src/components/CacheBrowser/CacheBrowserDialog.tsx
+++ b/src/components/CacheBrowser/CacheBrowserDialog.tsx
@@ -2,10 +2,10 @@ import { Button } from '@/components/ui/button'
import logger from '@/lib/logger'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
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 { 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 { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
@@ -13,6 +13,9 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { toast } from 'sonner'
import { Event } from 'nostr-tools'
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
@@ -25,6 +28,8 @@ export default function CacheBrowserDialog({
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
+ const { pubkey: accountPubkey } = useNostr()
+ const [broadcastingKey, setBroadcastingKey] = useState(null)
const [cacheInfo, setCacheInfo] = useState>({})
const [selectedStore, setSelectedStore] = useState(null)
const [storeItems, setStoreItems] = useState([])
@@ -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(
(item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => {
if (!item) return true
@@ -240,9 +293,9 @@ export default function CacheBrowserDialog({
}
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.pubkey || typeof event.kind !== 'number' || typeof event.created_at !== 'number') 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
},
[]
@@ -254,11 +307,11 @@ export default function CacheBrowserDialog({
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.kind !== 'number') 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 (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(', ') })
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 })}
)}
- {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 (
)}
+ {showBroadcast && (
+
+ )}
-
+
{item.key}
{typeof nestedCount === 'number' && nestedCount > 0 && (
diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx
index 79c96541..69e029a3 100644
--- a/src/components/Relay/index.tsx
+++ b/src/components/Relay/index.tsx
@@ -5,7 +5,7 @@ import SearchInput from '@/components/SearchInput'
import { useFetchRelayInfo } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager'
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 client from '@/services/client.service'
import type { TFeedSubRequest } from '@/types'
@@ -87,11 +87,14 @@ const Relay = forwardRef<
const shouldHideEventNotFromThisRelay = useCallback(
(ev: Event) => {
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)
if (seen.length === 0) return false
return !seen.some((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase() === relaySeenMatchKey)
},
- [relaySeenMatchKey]
+ [relaySeenMatchKey, normalizedUrl]
)
if (!normalizedUrl) {
diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts
index 659df9a5..689c1d78 100644
--- a/src/i18n/locales/cs.ts
+++ b/src/i18n/locales/cs.ts
@@ -1369,6 +1369,15 @@ export default {
"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.",
"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",
"Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully",
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 0bc91c48..88e08b85 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -1385,6 +1385,15 @@ export default {
"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.",
"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",
"Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully",
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index e43c8fe9..48bfde4a 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -1431,6 +1431,16 @@ export default {
"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.",
"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",
"Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully",
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index c3f7e782..8c19dcde 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -1369,6 +1369,15 @@ export default {
"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.",
"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",
"Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully",
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index c2993687..c8884019 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -1369,6 +1369,15 @@ export default {
"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.",
"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",
"Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully",
diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts
index 055f6626..6414f81e 100644
--- a/src/i18n/locales/nl.ts
+++ b/src/i18n/locales/nl.ts
@@ -1369,6 +1369,15 @@ export default {
"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.",
"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",
"Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully",
diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts
index 4bf5a99b..b90c1847 100644
--- a/src/i18n/locales/pl.ts
+++ b/src/i18n/locales/pl.ts
@@ -1369,6 +1369,15 @@ export default {
"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.",
"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",
"Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully",
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index 7ed7fc7e..85935cf1 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -1369,6 +1369,15 @@ export default {
"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.",
"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",
"Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully",
diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts
index adee87d9..2b23eb27 100644
--- a/src/i18n/locales/tr.ts
+++ b/src/i18n/locales/tr.ts
@@ -1369,6 +1369,15 @@ export default {
"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.",
"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",
"Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully",
diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts
index 098636bd..8303b5b8 100644
--- a/src/i18n/locales/zh.ts
+++ b/src/i18n/locales/zh.ts
@@ -1369,6 +1369,15 @@ export default {
"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.",
"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",
"Cache Relays": "Cache Relays",
"Cache cleared successfully": "Cache cleared successfully",
diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts
index 32cdbf17..c64814bd 100644
--- a/src/lib/event-metadata.ts
+++ b/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 { Event, kinds } from 'nostr-tools'
import { buildATag } from './draft-event'
-import { getReplaceableEventIdentifier } from './event'
+import { getLatestEvent, getReplaceableEventIdentifier } from './event'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
import { formatPubkey, pubkeyToNpub } from './pubkey'
import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag'
@@ -24,6 +24,25 @@ export type GetRelayListFromEventOptions = {
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(
event?: Event | null,
blockedRelays?: string[],
diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx
index 26d5d813..00867988 100644
--- a/src/providers/NostrProvider/index.tsx
+++ b/src/providers/NostrProvider/index.tsx
@@ -23,7 +23,12 @@ import {
} from '@/lib/draft-event'
import { getLatestEvent, minePow } from '@/lib/event'
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 { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { LoginRequiredError } from '@/lib/nostr-errors'
@@ -246,6 +251,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setMuteListEvent(null)
setBookmarkListEvent(null)
setRssFeedListEvent(null)
+ setCacheRelayListEvent(null)
setHttpRelayListEvent(undefined)
}
@@ -414,6 +420,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
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 hasLocalRelayAndProfile = !!storedRelayListEvent && !!storedProfileEvent
const skipNetworkHydrate =
@@ -496,7 +507,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return controller
}
const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent
- const cacheRelayListEvent = getLatestEvent(cacheRelayListEvents) ?? storedCacheRelayListEvent
+ const cacheRelayListEvent = mergeHydratedCacheRelayListEvents(
+ cacheRelayListEvents,
+ storedCacheRelayListEvent
+ )
const httpRelayListEventFetched = getLatestEvent(httpRelayListEvents) ?? storedHttpRelayListEvent ?? null
if (relayListEvent) {
client.updateRelayListCache(relayListEvent)
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 3f0005c2..1a72014b 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -4110,6 +4110,17 @@ class ClientService extends EventTarget {
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 {
+ 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). */
private async getKind10002FromIdbOrSession(pubkey: string): Promise {
let idb: NEvent | undefined | null
diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts
index 8f4d338a..fe0cafc7 100644
--- a/src/services/indexed-db.service.ts
+++ b/src/services/indexed-db.service.ts
@@ -64,7 +64,8 @@ export type TCachedEventSearchHit = {
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
const o = v as Record
return (
diff --git a/vite.config.ts b/vite.config.ts
index c5ec4c6a..40eb18f8 100644
--- a/vite.config.ts
+++ b/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/
export default defineConfig(({ mode }) => {
// `.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
// the app can render offline or on a slow connection without blocking on network.
- urlPattern: ({ request }: { request: Request }) =>
- request.headers.get('accept')?.includes('application/nostr+json') ?? false,
+ // Do not intercept loopback/LAN: cross-origin + CORS often yields no cacheable response;
+ // 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',
options: {
cacheName: 'nip11-relay-info',