diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx index 2f6ea503..f72c5c6b 100644 --- a/src/components/Relay/index.tsx +++ b/src/components/Relay/index.tsx @@ -52,6 +52,20 @@ const Relay = forwardRef< } }, [normalizedUrl]) + /** + * Session strikes skip a relay for reads until cleared. Refresh in the titlebar already clears; without this, + * opening the panel on a striked relay subscribed too late or showed an empty feed while the banner confused users. + * Runs after child effects so the NoteList ref is ready for {@link refresh}. + */ + useEffect(() => { + if (!normalizedUrl) return + if (!client.clearSessionRelayStrikeForUrl(normalizedUrl)) return + setStrikeCount(0) + if (typeof noteListRef !== 'function') { + noteListRef.current?.refresh() + } + }, [normalizedUrl]) + useEffect(() => { const handler = setTimeout(() => { setDebouncedInput(searchInput) diff --git a/src/constants.ts b/src/constants.ts index 3dcf9160..c97ed8b5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -230,12 +230,24 @@ export const BOOKSTR_RELAY_URLS = [ /** * Block-list order (applied in sequence when building relay lists): - * 1. READ_ONLY — never publish - * 2. SOCIAL_KIND_BLOCKED — skip for REQ/publish that target {@link SOCIAL_KIND_BLOCKED_KINDS} + * 1. READ_ONLY — never publish (search mirrors, index relays, NIP-42 read-only aggregators) + * 2. SOCIAL_KIND_BLOCKED — skip for REQ/publish that touch {@link SOCIAL_KIND_BLOCKED_KINDS} (see list below) * 3. E_TAG_FILTER_BLOCKED — skip for reply/quote/stats fetches (#e, #a, #q filters) */ -/** Relays that must never be used for publishing (read-only aggregators, etc.). */ -export const READ_ONLY_RELAY_URLS = ['wss://aggr.nostr.land'] +/** + * Relays that must never receive publishes: search engines, index mirrors, and similar endpoints that only ingest + * or aggregate for read. Distinct from {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} (kind-coverage limits, not write policy). + */ +export const READ_ONLY_RELAY_URLS = [ + 'wss://aggr.nostr.land', + 'wss://relay.nostr.watch', + 'wss://relaypag.es', + 'wss://relay.noswhere.com', + 'wss://search.nos.today', + 'wss://trending.nostr.wine', + 'wss://sendit.nosflare.com', + 'wss://relay.nip46.com' +] /** * Relays that reject or poorly serve “social” kinds (short notes, discussions, URL comments). @@ -244,7 +256,6 @@ export const READ_ONLY_RELAY_URLS = ['wss://aggr.nostr.land'] */ export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [ 'wss://thecitadel.nostr1.com', - 'wss://hist.nostr.land', 'wss://profiles.nostr1.com', 'wss://purplepag.es', 'wss://relay.nsec.app', @@ -252,12 +263,7 @@ export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [ 'wss://spatia-arcana.com', 'wss://relay.wikifreedia.xyz', 'wss://relay.gifbuddy.lol', - 'wss://relay.noswhere.com', - 'wss://aggr.nostr.land', - 'wss://search.nos.today', - 'wss://trending.nostr.wine', - 'wss://sendit.nosflare.com', - 'wss://relay.nip46.com' + 'wss://hist.nostr.land', ] /** Relays that reject #e (and similar) tag filters; skip for reply/quote/stats fetches. */ @@ -439,8 +445,9 @@ export const THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT: readonly number[] = THREAD_BACKLINK_STREAM_KINDS.filter((k) => k !== kinds.Highlights) /** - * Kinds aligned with {@link SOCIAL_KIND_BLOCKED_RELAY_URLS}: omit those relays when querying or publishing - * these kinds (or when `kinds` is omitted on a filter — see {@link relayFilterIncludesSocialKindBlockedKind}). + * When a filter touches these kinds (or omits `kinds`), omit {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} from the relay + * stack — those relays do not carry this note/comment surface (kinds **1** / **1111** / **11** per relay policy). + * @see {@link relayFilterIncludesSocialKindBlockedKind} */ export const SOCIAL_KIND_BLOCKED_KINDS: readonly number[] = [ kinds.ShortTextNote, diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index 75569d64..645f4757 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -14,6 +14,7 @@ import { mergeRelayPriorityLayers, relayUrlsLocalsFirst } from '@/lib/relay-url-priority' +import { normalizeAnyRelayUrl } from '@/lib/url' const blockedSet = (blockedRelays: string[]) => new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) @@ -179,6 +180,11 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( options?: ReadRelayPriorityOptions ): TFeedSubRequest[] { const max = options?.maxRelays ?? MAX_REQ_RELAY_URLS + const userReadSocialExempt = new Set() + for (const u of userInboxReadRelays) { + const n = normalizeAnyRelayUrl(u) || u.trim() + if (n) userReadSocialExempt.add(n) + } return requests.map((r) => { const useSubUrls = options?.mergeSubrequestRelayUrls !== false const foldIntoAuthor = options?.mergeSubrequestRelaysIntoAuthorTier === true @@ -221,7 +227,8 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( return { ...r, urls: mergeRelayPriorityLayers(layers, blockedRelays, max, { - applySocialKindBlockedFilter: applySocial + applySocialKindBlockedFilter: applySocial, + exemptNormUrlsFromSocialKindBlock: userReadSocialExempt }) } }) diff --git a/src/lib/relay-url-priority.ts b/src/lib/relay-url-priority.ts index f0f74737..674844cd 100644 --- a/src/lib/relay-url-priority.ts +++ b/src/lib/relay-url-priority.ts @@ -53,6 +53,12 @@ function socialKindBlockedNormSet(): Set { export type MergeRelayPriorityLayersOptions = { /** When true, drop {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} before applying the max cap. */ applySocialKindBlockedFilter?: boolean + /** + * Normalized relay URLs that stay in the stack even when {@link applySocialKindBlockedFilter} is on — e.g. the + * user’s NIP-65 read list — so an explicit inbox still appears under “Seen on”. ({@link READ_ONLY_RELAY_URLS} such as + * aggr are a separate concern: no publishes, but they are not in {@link SOCIAL_KIND_BLOCKED_RELAY_URLS}.) + */ + exemptNormUrlsFromSocialKindBlock?: Set } /** @@ -68,13 +74,20 @@ export function mergeRelayPriorityLayers( const socialBlocked = mergeOpts?.applySocialKindBlockedFilter ? socialKindBlockedNormSet() : new Set() + const socialExempt = mergeOpts?.exemptNormUrlsFromSocialKindBlock const seen = new Set() const out: string[] = [] for (const layer of layers) { for (const u of layer) { // Must not use {@link normalizeUrl}: it turns http(s) index relays into ws(s), which then hit the WS pool. const n = normalizeAnyRelayUrl(u) || u.trim() - if (!n || blocked.has(n) || socialBlocked.has(n) || seen.has(n)) continue + if (!n || blocked.has(n) || seen.has(n)) continue + if ( + socialBlocked.has(n) && + !(socialExempt?.has(n) ?? false) + ) { + continue + } seen.add(n) out.push(n) if (out.length >= max) return out @@ -131,6 +144,11 @@ export function buildPrioritizedReadRelayUrls(opts: { }): string[] { const max = opts.maxRelays ?? MAX_REQ_RELAY_URLS 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 layers = buildReadRelayPriorityLayers({ userReadRelays: opts.userReadRelays, userWriteRelays: opts.userWriteRelays, @@ -138,7 +156,8 @@ export function buildPrioritizedReadRelayUrls(opts: { favoriteRelays: opts.favoriteRelays }) return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { - applySocialKindBlockedFilter: applySocial + applySocialKindBlockedFilter: applySocial, + exemptNormUrlsFromSocialKindBlock: exemptFromSocial }) } diff --git a/src/lib/rss-web-feed.ts b/src/lib/rss-web-feed.ts index 65b83698..3a6b87ba 100644 --- a/src/lib/rss-web-feed.ts +++ b/src/lib/rss-web-feed.ts @@ -176,8 +176,9 @@ export function isRssWebUnifiedClutterUrl(url: string): boolean { } /** - * Split filters: kind 1/1111 in `social` strip aggregator relays from the whole REQ; reactions and - * `#r` queries stay in `nonSocial` so aggr and similar still answer. + * Split filters: `social` uses kinds that match {@link relayFilterIncludesSocialKindBlockedKind} and therefore omit + * {@link SOCIAL_KIND_BLOCKED_RELAY_URLS}; `nonSocial` keeps reactions / `#r` on batches that do not apply that strip. + * Read-only index relays ({@link READ_ONLY_RELAY_URLS}) are unrelated to the social-kind block list. */ export function buildRssArticleUrlThreadInteractionFilterGroups( canonicalArticleUrl: string, diff --git a/src/pages/secondary/FollowingListPage/index.tsx b/src/pages/secondary/FollowingListPage/index.tsx index fdd0095b..a16cfa36 100644 --- a/src/pages/secondary/FollowingListPage/index.tsx +++ b/src/pages/secondary/FollowingListPage/index.tsx @@ -20,10 +20,22 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const [listRefreshNonce, setListRefreshNonce] = useState(0) const { profile } = useFetchProfile(id) - const { followings } = useFetchFollowings(profile?.pubkey, listRefreshNonce) + const { followings, followListEvent } = useFetchFollowings(profile?.pubkey, listRefreshNonce) + const [jsonOpen, setJsonOpen] = useState(false) + const [followJsonPayload, setFollowJsonPayload] = useState(null) const bumpList = useCallback(() => setListRefreshNonce((n) => n + 1), []) + const openFollowingListJson = useCallback(() => { + setFollowJsonPayload({ + pubkey: profile?.pubkey ?? null, + contactsKind3Event: followListEvent ?? null, + derivedFollowingPubkeys: followings, + note: 'Following pubkeys are derived from `p` tags on the kind 3 contacts event when present.' + }) + setJsonOpen(true) + }, [profile?.pubkey, followListEvent, followings]) + useEffect(() => { if (!hideTitlebar) { registerPrimaryPanelRefresh(null) @@ -56,7 +68,7 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? - setJsonOpen(true)}> + openFollowingListJson()}> {t('View JSON')} diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx index fdbda04d..05efdaa7 100644 --- a/src/pages/secondary/RelaySettingsPage/index.tsx +++ b/src/pages/secondary/RelaySettingsPage/index.tsx @@ -25,9 +25,36 @@ import { useTranslation } from 'react-i18next' const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() + const { account, relayList } = useNostr() const [contentKey, setContentKey] = useState(0) const bump = useCallback(() => setContentKey((k) => k + 1), []) const [tabValue, setTabValue] = useState('favorite-relays') + const [jsonOpen, setJsonOpen] = useState(false) + const [jsonPayload, setJsonPayload] = useState(null) + + const openRelayListJson = useCallback(async () => { + const pk = account?.pubkey + if (!pk) { + setJsonPayload({ error: 'Not logged in' }) + setJsonOpen(true) + return + } + const [k10002, k10432, k10243] = await Promise.all([ + indexedDb.getReplaceableEvent(pk, kinds.RelayList).catch(() => null), + indexedDb.getReplaceableEvent(pk, ExtendedKind.CACHE_RELAYS).catch(() => null), + indexedDb.getReplaceableEvent(pk, ExtendedKind.HTTP_RELAY_LIST).catch(() => null) + ]) + setJsonPayload({ + pubkey: pk, + mergedRelayList: relayList, + kind10002_mailbox_fromIndexedDb: k10002 ?? null, + kind10432_cacheRelays_fromIndexedDb: k10432 ?? null, + kind10243_httpRelayList_fromIndexedDb: k10243 ?? null, + note: + 'Merged list is from the client cache service. IndexedDB values are your locally stored replaceable lists.' + }) + setJsonOpen(true) + }, [account?.pubkey, relayList]) useEffect(() => { switch (window.location.hash) { diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index b32290a3..1924f5ea 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -257,7 +257,7 @@ class NoteStatsService { seen.add(n) } - // 1. Broad search index / aggregator relays + // 1. Search / discovery relay set (includes read-only index mirrors; see READ_ONLY_RELAY_URLS in constants) SEARCHABLE_RELAY_URLS.forEach(add) // 2. Default fast read set (includes e.g. theforest — not in SEARCHABLE) @@ -294,9 +294,10 @@ class NoteStatsService { } /** - * Split REQ batches so “social” kinds (1 / 11 / 1111) do not strip aggregator relays from the - * same subscription as reactions and zaps ({@link relayFilterIncludesSocialKindBlockedKind}). - * RSS URL threads also need `#r` + kind 7 for NIP-73 page-targeted likes. + * Split REQ batches: filters that include social kinds (1 / 11 / 1111) trigger + * {@link relayFilterIncludesSocialKindBlockedKind} and drop {@link SOCIAL_KIND_BLOCKED_RELAY_URLS}; keep reactions, + * zaps, and `#r` queries in separate batches so read-only index relays ({@link READ_ONLY_RELAY_URLS}) still answer + * where appropriate. RSS URL threads also need `#r` + kind 7 for NIP-73 page-targeted likes. */ private buildFilterGroups( event: Event,