+
{t('Note kind label line', { kind: event.kind, description })}
+ {relays.length > 0 ? (
+
+
+ {t('Seen on')}
+
+ {inDropdown ? (
+
{relayRows}
+ ) : (
+
+ )}
+
+ ) : null}
)
}
diff --git a/src/components/NoteOptions/index.tsx b/src/components/NoteOptions/index.tsx
index 2fe4e385..de2746b5 100644
--- a/src/components/NoteOptions/index.tsx
+++ b/src/components/NoteOptions/index.tsx
@@ -109,7 +109,6 @@ export default function NoteOptions({
setIsRawEventDialogOpen,
setIsReportDialogOpen,
isSmallScreen,
- seenOnAllowlist,
onOpenPublicMessage,
onOpenCallInvite,
onOpenEditOrClone: (mode) => {
diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx
index 520500eb..f8b3124c 100644
--- a/src/components/NoteOptions/useMenuActions.tsx
+++ b/src/components/NoteOptions/useMenuActions.tsx
@@ -14,8 +14,7 @@ import { buildHiveTalkJoinUrl } from '@/lib/hivetalk'
import {
toAlexandria,
encodeArticleLikePublicationNaddr,
- openAlexandriaPublicationFromNaddr,
- toRelay
+ openAlexandriaPublicationFromNaddr
} from '@/lib/link'
import logger from '@/lib/logger'
import { pubkeyToNpub } from '@/lib/pubkey'
@@ -90,7 +89,6 @@ import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import RelayIcon from '../RelayIcon'
-import { useSeenOnRelays } from '@/hooks/useSeenOnRelays'
import { useSecondaryPage } from '@/PageManager'
import { PrimaryPageContext } from '@/contexts/primary-page-context'
import { showPublishingFeedback, toastPublishPromise } from '@/lib/publishing-feedback'
@@ -137,8 +135,6 @@ interface UseMenuActionsProps {
pinned?: boolean
/** Opens JSON viewer for the kind 9741 attestation of this payment or zap receipt. */
onViewAttestation?: () => void
- /** When set (home favorites feed), "Seen on" in Advanced matches the feed allowlist. */
- seenOnAllowlist?: readonly string[]
}
export function useMenuActions({
@@ -152,12 +148,10 @@ export function useMenuActions({
onOpenCallInvite,
onOpenEditOrClone,
pinned: _pinnedInFeed = false,
- onViewAttestation,
- seenOnAllowlist
+ onViewAttestation
}: UseMenuActionsProps) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
- const seenOnRelays = useSeenOnRelays(event.id, seenOnAllowlist)
// Use useContext directly to avoid error if provider is not available
const primaryPageContext = useContext(PrimaryPageContext)
const currentPrimaryPage = primaryPageContext?.current ?? null
@@ -1262,38 +1256,6 @@ export function useMenuActions({
}
}
- if (seenOnRelays.length > 0) {
- advancedSubMenu.push({
- label: (
-
e.stopPropagation()}
- onPointerDown={(e) => e.stopPropagation()}
- >
- {seenOnRelays.map((relay) => (
-
- ))}
-
- ),
- onClick: () => {},
- separator: true
- })
- }
-
const actions: MenuAction[] = []
if (READ_ALOUD_KINDS.includes(event.kind)) {
@@ -1529,7 +1491,6 @@ export function useMenuActions({
noteTranslationFromMenu,
translateMenuOptions,
onViewAttestation,
- seenOnRelays,
push,
currentPrimaryPage,
isReplyToDiscussion,
diff --git a/src/components/NoteStats/SeenOnButton.tsx b/src/components/NoteStats/SeenOnButton.tsx
index d82ea9b2..a01cba80 100644
--- a/src/components/NoteStats/SeenOnButton.tsx
+++ b/src/components/NoteStats/SeenOnButton.tsx
@@ -1,4 +1,4 @@
-import { useSecondaryPage } from '@/PageManager'
+import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
drawerMenuButtonClassName,
@@ -34,7 +34,7 @@ export default function SeenOnButton({
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
- const { push } = useSecondaryPage()
+ const { navigateToRelay } = useSmartRelayNavigation()
const relays = useSeenOnRelays(event.id, allowedRelays)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
@@ -73,7 +73,7 @@ export default function SeenOnButton({
onClick={() => {
setIsDrawerOpen(false)
setTimeout(() => {
- push(toRelay(relay))
+ navigateToRelay(toRelay(relay))
}, 50)
}}
>
@@ -94,7 +94,12 @@ export default function SeenOnButton({
{t('Seen on')}
{relays.map((relay) => (
-
push(toRelay(relay))} className="min-w-52">
+ e.preventDefault()}
+ onClick={() => navigateToRelay(toRelay(relay))}
+ className="min-w-52"
+ >
{simplifyUrl(relay)}
diff --git a/src/lib/feed-relay-urls.test.ts b/src/lib/feed-relay-urls.test.ts
new file mode 100644
index 00000000..5bb473f2
--- /dev/null
+++ b/src/lib/feed-relay-urls.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { pinHttpIndexRelaysInRelayCap, uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
+
+describe('feed-relay-urls', () => {
+ it('collects deduped relay URLs from subrequests', () => {
+ expect(
+ uniqueRelayUrlsFromSubRequests([
+ { urls: ['wss://a.example/', 'wss://b.example/'], filter: { limit: 1 } },
+ { urls: ['wss://a.example/', 'wss://c.example/'], filter: { limit: 1 } }
+ ])
+ ).toEqual(['wss://a.example/', 'wss://b.example/', 'wss://c.example/'])
+ })
+
+ it('pins kind-10243 HTTP read relays into a capped faux spell stack', () => {
+ const ws = Array.from({ length: 10 }, (_, i) => `wss://relay-${i}.example/`)
+ const http = 'https://index.example.com/'
+ const capped = pinHttpIndexRelaysInRelayCap(ws, [...ws, http], 10)
+ expect(capped.some((u) => u.includes('index.example.com'))).toBe(true)
+ expect(capped.length).toBe(10)
+ })
+})
diff --git a/src/lib/feed-relay-urls.ts b/src/lib/feed-relay-urls.ts
new file mode 100644
index 00000000..dc013db6
--- /dev/null
+++ b/src/lib/feed-relay-urls.ts
@@ -0,0 +1,67 @@
+import { normalizeHttpRelayUrl, normalizeRelayUrlByScheme, isHttpOrHttpsScheme } from '@/lib/url'
+import type { TFeedSubRequest } from '@/types'
+
+function relayDedupeKey(url: string): string {
+ return (normalizeRelayUrlByScheme(url) || url.trim()).toLowerCase()
+}
+
+/** Deduped relay URLs from all timeline subrequests (REQ order preserved). */
+export function uniqueRelayUrlsFromSubRequests(requests: readonly TFeedSubRequest[]): string[] {
+ const seen = new Set()
+ const out: string[] = []
+ for (const req of requests) {
+ for (const raw of req.urls) {
+ const n = normalizeRelayUrlByScheme(raw) || raw.trim()
+ if (!n) continue
+ const key = relayDedupeKey(n)
+ if (seen.has(key)) continue
+ seen.add(key)
+ out.push(n)
+ }
+ }
+ return out
+}
+
+/**
+ * Keep viewer kind-10243 HTTP index relays in a capped feed stack (they are easy to drop when
+ * favorites + NIP-65 WS fill {@link FAUX_SPELL_MAX_RELAYS}).
+ */
+export function pinHttpIndexRelaysInRelayCap(
+ capped: readonly string[],
+ sourceUrls: readonly string[],
+ maxRelays: number
+): string[] {
+ const httpSources = sourceUrls
+ .map((u) => normalizeHttpRelayUrl(u) || (isHttpOrHttpsScheme(u.trim()) ? u.trim() : ''))
+ .filter(Boolean)
+ if (httpSources.length === 0) return [...capped]
+
+ const httpKeySet = new Set(httpSources.map((u) => u.toLowerCase()))
+ const out = [...capped]
+ const outKeys = new Set(out.map(relayDedupeKey))
+
+ for (const http of httpSources) {
+ const key = http.toLowerCase()
+ if (outKeys.has(key)) continue
+
+ while (out.length >= maxRelays) {
+ let dropped = false
+ for (let i = out.length - 1; i >= 0; i--) {
+ const candidate = out[i]!
+ const ck = relayDedupeKey(candidate)
+ if (httpKeySet.has(ck) || isHttpOrHttpsScheme(candidate.trim())) continue
+ out.splice(i, 1)
+ outKeys.delete(ck)
+ dropped = true
+ break
+ }
+ if (!dropped) break
+ }
+
+ if (out.length >= maxRelays) continue
+ out.push(http)
+ outKeys.add(key)
+ }
+
+ return out.slice(0, maxRelays)
+}
diff --git a/src/lib/relay-url-priority.ts b/src/lib/relay-url-priority.ts
index 9f0124b8..4466fc66 100644
--- a/src/lib/relay-url-priority.ts
+++ b/src/lib/relay-url-priority.ts
@@ -5,7 +5,12 @@ import {
MAX_REQ_RELAY_URLS
} from '@/constants'
import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy'
-import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeRelayUrlByScheme, normalizeUrl } from '@/lib/url'
+import {
+ isLocalNetworkUrl,
+ normalizeAnyRelayUrl,
+ normalizeRelayUrlByScheme,
+ normalizeUrl
+} from '@/lib/url'
export { MAX_REQ_RELAY_URLS }
@@ -110,8 +115,8 @@ export function buildPrioritizedReadRelayUrls(opts: {
const applySocial = opts.applySocialKindBlockedFilter !== false
const exemptFromSocial = new Set()
for (const u of opts.userReadRelays ?? []) {
- const n = normalizeAnyRelayUrl(u) || u.trim()
- if (n) exemptFromSocial.add(n)
+ const n = normalizeRelayUrlByScheme(u) || u.trim()
+ if (n) exemptFromSocial.add(n.toLowerCase())
}
const layers = buildReadRelayPriorityLayers({
userReadRelays: opts.userReadRelays,
diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts
index 39a09e6b..1a3d3bce 100644
--- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts
+++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts
@@ -29,6 +29,7 @@ import {
parseThreadWatchListRefs
} from '@/lib/notification-thread-watch'
import { userIdToPubkey } from '@/lib/pubkey'
+import { pinHttpIndexRelaysInRelayCap } from '@/lib/feed-relay-urls'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import type { TFeedSubRequest } from '@/types'
import { type Event, type Filter } from 'nostr-tools'
@@ -88,6 +89,7 @@ const INTERESTS_MAX_TOPIC_TAG_VALUES = INTERESTS_MAX_TOPICS * 4
* relays — live faux feeds (media, etc.) stayed empty while the console showed only connection refused.
*/
export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string[] {
+ const sourceUrls = dedupeNormalizeRelayUrlsOrdered(urls)
const fast = dedupeNormalizeRelayUrlsOrdered(
FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
)
@@ -96,7 +98,7 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string
const n = normalizeAnyRelayUrl(u) || u.trim()
if (n) fastNormSet.add(n)
}
- const out = feedRelayPolicyUrls([{ source: 'fallback', urls: dedupeNormalizeRelayUrlsOrdered(urls) }], {
+ const out = feedRelayPolicyUrls([{ source: 'fallback', urls: sourceUrls }], {
operation: 'read',
maxRelays: FAUX_SPELL_MAX_RELAYS,
applySocialKindBlockedFilter: false,
@@ -133,12 +135,13 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string
}
if (!addedOne) break
}
- return feedRelayPolicyUrls([{ source: 'fallback', urls: dedupeNormalizeRelayUrlsOrdered(out) }], {
+ const capped = feedRelayPolicyUrls([{ source: 'fallback', urls: dedupeNormalizeRelayUrlsOrdered(out) }], {
operation: 'read',
maxRelays: FAUX_SPELL_MAX_RELAYS,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
})
+ return pinHttpIndexRelaysInRelayCap(capped, sourceUrls, FAUX_SPELL_MAX_RELAYS)
}
/** Dedupe curated read relays and drop user-blocked URLs (no {@link READ_ONLY_RELAY_URLS} prepend). */