diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index c808d95b..c2f97b79 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -18,6 +18,8 @@ import client, { eventService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import nip66Service from '@/services/nip66.service' import { navigationEventStore } from '@/services/navigation-event-store' +import { useViewerInboxRelayUrlsAndAggrEligibility } from '@/hooks/useViewerInboxRelayUrlsAndAggr' +import { applyNostrLandAggrRelayPolicy } from '@/lib/nostr-land-aggr' import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useReply } from '@/providers/ReplyProvider' @@ -213,6 +215,7 @@ function EmbeddedNoteFetched({ const { isEventDeleted } = useDeletedEvent() const { addReplies } = useReply() const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { inboxRelayUrls, allowNostrLandAggr } = useViewerInboxRelayUrlsAndAggrEligibility() const [event, setEvent] = useState(undefined) const [isFetching, setIsFetching] = useState(true) const eventRef = useRef(undefined) @@ -231,8 +234,14 @@ function EmbeddedNoteFetched({ [favoriteRelays, blockedRelays] ) const wideRelaysStatic = useMemo( - () => buildEmbedWideRelayUrlsStatic(menuRelayUrls, relayHintsFromParent), - [menuRelayUrls, relayHintsFromParent] + () => + buildEmbedWideRelayUrlsStatic( + menuRelayUrls, + relayHintsFromParent, + inboxRelayUrls, + allowNostrLandAggr + ), + [menuRelayUrls, relayHintsFromParent, inboxRelayUrls, allowNostrLandAggr] ) const fetchRelayOpts = useMemo( () => (relayHintsFromParent.length > 0 ? { relayHints: relayHintsFromParent } : undefined), @@ -261,6 +270,9 @@ function EmbeddedNoteFetched({ }) embedFetchCtxRef.current = { fetchRelayOpts, wideRelaysStatic } + const allowNostrLandAggrRef = useRef(allowNostrLandAggr) + allowNostrLandAggrRef.current = allowNostrLandAggr + const resolveAndSetRef = useRef(resolveAndSet) resolveAndSetRef.current = resolveAndSet @@ -325,7 +337,7 @@ function EmbeddedNoteFetched({ if (cancelled || eventRef.current) return const wide0 = embedFetchCtxRef.current.wideRelaysStatic const wideMerged = preferPublicIndexRelaysFirst(dedupeRelayUrls([...wide0, ...extra])) - const ev = await runWidePass(wideMerged) + const ev = await runWidePass(applyNostrLandAggrRelayPolicy(wideMerged, allowNostrLandAggrRef.current)) if (cancelled || !ev) return resolve(ev) })() @@ -505,18 +517,27 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] { return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b)) } -/** Static + menu favorites: REQ immediately on embed mount (no NIP-65 round-trip first). */ -function buildEmbedWideRelayUrlsStatic(menuRelayUrls: string[], relayHintsFromParent: string[]): string[] { - return preferPublicIndexRelaysFirst( - dedupeRelayUrls([ - ...relayHintsFromParent, - ...nip66Service.getSearchableRelayUrls(), - ...SEARCHABLE_RELAY_URLS, - ...FAST_READ_RELAY_URLS, - ...FAST_WRITE_RELAY_URLS, - ...PROFILE_RELAY_URLS, - ...menuRelayUrls, - ]) +/** Static + menu favorites + viewer inboxes: REQ on embed mount; nostr.land aggregator only for subscribers. */ +function buildEmbedWideRelayUrlsStatic( + menuRelayUrls: string[], + relayHintsFromParent: string[], + viewerInboxRelayUrls: string[], + allowNostrLandAggr: boolean +): string[] { + return applyNostrLandAggrRelayPolicy( + preferPublicIndexRelaysFirst( + dedupeRelayUrls([ + ...relayHintsFromParent, + ...viewerInboxRelayUrls, + ...nip66Service.getSearchableRelayUrls(), + ...SEARCHABLE_RELAY_URLS, + ...FAST_READ_RELAY_URLS, + ...FAST_WRITE_RELAY_URLS, + ...PROFILE_RELAY_URLS, + ...menuRelayUrls + ]) + ), + allowNostrLandAggr ) } diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 62986bbe..19c59a4e 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -70,7 +70,7 @@ import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import { createPortal } from 'react-dom' import { toast } from 'sonner' -import { formatPubkey, inviteInputToHexPubkey, pubkeyToNpub } from '@/lib/pubkey' +import { formatPubkey, inviteInputToHexPubkey, normalizeHexPubkey, pubkeyToNpub } from '@/lib/pubkey' import { usePrimaryPageOptional } from '@/contexts/primary-page-context' import type { TPrimaryPageName } from '@/PageManager' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' @@ -574,6 +574,36 @@ function tightestSinceFromSpellFilters(shardFilters: Filter[]): number | undefin return sinceCandidates.length > 0 ? Math.max(...sinceCandidates) : undefined } +/** + * Profile Posts / Media feeds shard by relay but share one author + kinds REQ. Session + IDB author scans are keyed + * only on that author/kinds pair — unlike {@link ClientService.getTimelineDiskSnapshotEvents}, which misses rows + * until each relay-shard timeline has been persisted under its own key. + */ +function getProfileSingleAuthorWarmupSpec( + mapped: Array<{ urls: string[]; filter: TSubRequestFilter }> +): { author: string; kinds: number[] } | null { + if (mapped.length === 0) return null + let normAuthor: string | null = null + const kindUnion = new Set() + for (const { filter: f } of mapped) { + const authors = Array.isArray(f.authors) ? f.authors : undefined + if (!authors || authors.length !== 1) return null + let pk: string + try { + pk = normalizeHexPubkey(authors[0]) + } catch { + return null + } + if (normAuthor === null) normAuthor = pk + else if (normAuthor !== pk) return null + const ks = Array.isArray(f.kinds) ? f.kinds : undefined + if (!ks || ks.length === 0) return null + for (const k of ks) kindUnion.add(k) + } + if (normAuthor === null) return null + return { author: normAuthor, kinds: Array.from(kindUnion).sort((a, b) => a - b) } +} + const NoteList = forwardRef( ( { @@ -2132,6 +2162,84 @@ const NoteList = forwardRef( /* spell local + disk snapshot is best-effort */ } })() + } else { + const profileAuthorWarmSpec = getProfileSingleAuthorWarmupSpec( + mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> + ) + if ( + hostPrimaryPageName === 'profile' && + profileAuthorWarmSpec && + !timelineEffectStale() + ) { + const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800)) + const sessionHits = client.eventService.listSessionEventsAuthoredBy( + profileAuthorWarmSpec.author, + { kinds: profileAuthorWarmSpec.kinds, limit: sessionScanLimit } + ) + if (sessionHits.length > 0) { + const narrowedS = narrowLiveBatch(sessionHits as Event[]) + if (narrowedS.length > 0) { + const mergedS = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) + ) + if (mergedS.length > 0) { + timelineMergeBootstrapRef.current = mergedS.slice() + setEvents(mergedS) + lastEventsForTimelinePrefetchRef.current = mergedS + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + setLoading(false) + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { + variant: 'profile_local_session', + mergedCount: mergedS.length + } + primedFromDisk = true + } + } + } + + void (async () => { + try { + const fromArchive = await indexedDb.scanEventArchiveByAuthorPubkey( + profileAuthorWarmSpec.author, + { + kinds: profileAuthorWarmSpec.kinds, + maxRowsScanned: 16_000, + maxMatches: Math.min(2000, Math.max(eventCapEarly, 150)) + } + ) + if (!effectActive || timelineEffectStale()) return + if (fromArchive.length === 0) return + const narrowed = narrowLiveBatch(fromArchive as Event[]) + if (narrowed.length === 0) return + setEvents((prev) => { + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(prev, narrowed, eventCapEarly, areAlgoRelays) + ) + if (merged.length > 0) { + timelineMergeBootstrapRef.current = merged.slice() + } + lastEventsForTimelinePrefetchRef.current = merged + return merged + }) + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + if (!feedPaintLiveRelayDoneRef.current) { + setLoading(false) + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { + variant: 'profile_local_archive', + mergedCount: narrowed.length + } + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) + } + } catch { + /* profile local archive is best-effort */ + } + })() + } } if (!primedFromDisk) { if (!keepRowsVisible) setLoading(true) diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index d1e698f7..00b1661d 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -9,29 +9,10 @@ import { useProfilePins } from '@/hooks/useProfilePins' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import client from '@/services/client.service' -import storage from '@/services/local-storage.service' import { nip19, kinds } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -function useHideRepliesLikeMainFeed() { - const [hideReplies, setHideReplies] = useState(() => { - const m = storage.getNoteListMode() - return m !== 'postsAndReplies' - }) - - useEffect(() => { - const sync = () => { - const m = storage.getNoteListMode() - setHideReplies(m !== 'postsAndReplies') - } - window.addEventListener('noteListModeChanged', sync) - return () => window.removeEventListener('noteListModeChanged', sync) - }, []) - - return hideReplies -} - const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { const { t } = useTranslation() const { isEventDeleted } = useDeletedEvent() @@ -46,7 +27,6 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string if (!next.includes(ExtendedKind.GENERIC_REPOST)) next.push(ExtendedKind.GENERIC_REPOST) return next.sort((a, b) => a - b) }, [showKinds]) - const hideReplies = useHideRepliesLikeMainFeed() const [isRefreshing, setIsRefreshing] = useState(false) const noteListRef = useRef(null) @@ -151,7 +131,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string preserveTimelineOnSubRequestsChange mergeTimelineWhenSubRequestFiltersMatch pinnedEventIds={pinnedEventIds} - hideReplies={hideReplies} + hideReplies={false} hideUntrustedNotes={false} filterMutedNotes={false} showKind1OPs={showKind1OPs} diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 92d2ceb7..3fe468d1 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -49,6 +49,7 @@ import noteStatsService from '@/services/note-stats.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' +import { applyNostrLandAggrRelayPolicy, viewerMayUseNostrLandAggr } from '@/lib/nostr-land-aggr' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { @@ -1219,6 +1220,21 @@ function ReplyNoteList({ filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT)) } + const vp = userPubkey?.trim() + let relayUrlsForThreadReq = finalRelayUrls + if (vp) { + const [favsForAggr, peekForAggr] = await Promise.all([ + client.fetchFavoriteRelays(vp).catch(() => [] as string[]), + client.peekRelayListFromStorage(vp).catch(() => null) + ]) + relayUrlsForThreadReq = applyNostrLandAggrRelayPolicy( + relayUrlsForThreadReq, + viewerMayUseNostrLandAggr(favsForAggr, peekForAggr ?? undefined) + ) + } else { + relayUrlsForThreadReq = applyNostrLandAggrRelayPolicy(relayUrlsForThreadReq, false) + } + // For URL threads: stream events as they arrive from each relay so replies appear // immediately, rather than waiting up to 10 s for all relays to EOSE. const urlThreadRootInfo = rootInfo.type === 'I' ? rootInfo : null @@ -1235,7 +1251,7 @@ function ReplyNoteList({ // Use fetchEvents instead of subscribeTimeline for one-time fetching const allReplies = await queryService.fetchEvents( - finalRelayUrls, + relayUrlsForThreadReq, filters, urlThreadOnevent ? { onevent: urlThreadOnevent } : undefined ) @@ -1304,7 +1320,7 @@ function ReplyNoteList({ const nestedFilters: Filter[] = [ { '#e': idChunk, kinds: commentKinds, limit: LIMIT } ] - const nestedReplies = await queryService.fetchEvents(finalRelayUrls, nestedFilters, { + const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { onevent: (evt: NEvent) => { if (fetchGeneration !== replyFetchGenRef.current) return if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) @@ -1356,7 +1372,7 @@ function ReplyNoteList({ limit: LIMIT } ] - const nestedReplies = await queryService.fetchEvents(finalRelayUrls, nestedFilters, { + const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { onevent: (evt: NEvent) => { if (fetchGeneration !== replyFetchGenRef.current) return if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) diff --git a/src/hooks/useViewerInboxRelayUrlsAndAggr.ts b/src/hooks/useViewerInboxRelayUrlsAndAggr.ts new file mode 100644 index 00000000..e112fe29 --- /dev/null +++ b/src/hooks/useViewerInboxRelayUrlsAndAggr.ts @@ -0,0 +1,46 @@ +import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { viewerMayUseNostrLandAggr } from '@/lib/nostr-land-aggr' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostrOptional } from '@/providers/nostr-context' +import client from '@/services/client.service' +import type { TRelayList } from '@/types' +import { useEffect, useMemo, useState } from 'react' + +/** + * Viewer NIP-65 read inboxes (incl. HTTP read) for embed / thread fan-out, plus whether they may use + * {@link AGGR_NOSTR_LAND_WSS} (nostr.land in favorites or NIP-65 lists). + */ +export function useViewerInboxRelayUrlsAndAggrEligibility(): { + inboxRelayUrls: string[] + allowNostrLandAggr: boolean +} { + const nostr = useNostrOptional() + const pk = nostr?.pubkey?.trim() + const { favoriteRelays } = useFavoriteRelays() + const [inboxRelayUrls, setInboxRelayUrls] = useState([]) + const [peekedNip65, setPeekedNip65] = useState(null) + + useEffect(() => { + if (!pk) { + setInboxRelayUrls([]) + setPeekedNip65(null) + return + } + let cancelled = false + void client.peekRelayListFromStorage(pk).then((rl) => { + if (cancelled) return + setPeekedNip65(rl) + setInboxRelayUrls(userReadRelaysWithHttp(rl).slice(0, 14)) + }) + return () => { + cancelled = true + } + }, [pk]) + + const allowNostrLandAggr = useMemo( + () => viewerMayUseNostrLandAggr(favoriteRelays ?? [], peekedNip65 ?? undefined), + [favoriteRelays, peekedNip65] + ) + + return { inboxRelayUrls, allowNostrLandAggr } +} diff --git a/src/lib/nostr-land-aggr.ts b/src/lib/nostr-land-aggr.ts new file mode 100644 index 00000000..7952c92a --- /dev/null +++ b/src/lib/nostr-land-aggr.ts @@ -0,0 +1,74 @@ +import type { TRelayList } from '@/types' +import { normalizeAnyRelayUrl } from '@/lib/url' + +/** Paid / subscription relay — presence in favorites or NIP-65 lists implies access to the matching aggregator. */ +export const NOSTR_LAND_WSS = 'wss://nostr.land' + +/** Aggregator for nostr.land subscribers only; others get auth / policy errors if contacted. */ +export const AGGR_NOSTR_LAND_WSS = 'wss://aggr.nostr.land' + +function canonWs(url: string): string { + return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() +} + +const NOSTR_LAND_CANON = canonWs(NOSTR_LAND_WSS) +const AGGR_CANON = canonWs(AGGR_NOSTR_LAND_WSS) + +/** True if this URL is the nostr.land websocket (normalized). */ +export function isNostrLandWsUrl(url: string | undefined | null): boolean { + if (!url?.trim()) return false + return canonWs(url) === NOSTR_LAND_CANON +} + +/** True if any normalized URL equals nostr.land. */ +export function relayUrlListMentionsNostrLand(urls: readonly string[] | undefined): boolean { + if (!urls?.length) return false + for (const u of urls) { + if (isNostrLandWsUrl(u)) return true + } + return false +} + +/** True if nostr.land appears in any NIP-65 / HTTP relay list slice. */ +export function nip65RelayListMentionsNostrLand(rl: TRelayList | null | undefined): boolean { + if (!rl) return false + return ( + relayUrlListMentionsNostrLand(rl.read) || + relayUrlListMentionsNostrLand(rl.write) || + relayUrlListMentionsNostrLand(rl.httpRead) || + relayUrlListMentionsNostrLand(rl.httpWrite) + ) +} + +/** + * Subscriber may use {@link AGGR_NOSTR_LAND_WSS}: they listed nostr.land in kind-10012 favorites or NIP-65 lists. + */ +export function viewerMayUseNostrLandAggr( + favoriteRelays: readonly string[] | undefined, + nip65: TRelayList | null | undefined +): boolean { + if (relayUrlListMentionsNostrLand(favoriteRelays)) return true + return nip65RelayListMentionsNostrLand(nip65 ?? undefined) +} + +/** + * Drop {@link AGGR_NOSTR_LAND_WSS} when the viewer is not a nostr.land subscriber; otherwise ensure it appears once. + */ +export function applyNostrLandAggrRelayPolicy(urls: readonly string[], allowAggr: boolean): string[] { + const out: string[] = [] + const seen = new Set() + const push = (u: string) => { + const c = canonWs(u) + if (!c || seen.has(c)) return + if (!allowAggr && c === AGGR_CANON) return + seen.add(c) + out.push(normalizeAnyRelayUrl(u) || u.trim()) + } + for (const u of urls) { + push(u) + } + if (allowAggr && !seen.has(AGGR_CANON)) { + out.unshift(AGGR_NOSTR_LAND_WSS) + } + return out +} diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 91665233..5c88f7cf 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -10,10 +10,13 @@ */ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { applyNostrLandAggrRelayPolicy, viewerMayUseNostrLandAggr } from '@/lib/nostr-land-aggr' import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { getCacheRelayUrls } from './private-relays' import client from '@/services/client.service' import logger from '@/lib/logger' +import type { TRelayList } from '@/types' import type { Event } from 'nostr-tools' function dedupeNormalizedRelayUrls(urls: string[]): string[] { @@ -142,7 +145,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio const authorRelayList = await client.peekRelayListFromStorage(authorPubkey) const authorOutboxes = [...(authorRelayList.write || []).slice(0, 10)] authorOutboxes.forEach(addRelay) - const authorInboxes = [...(authorRelayList.read || []).slice(0, 10)] + const authorInboxes = userReadRelaysWithHttp(authorRelayList).slice(0, 10) authorInboxes.forEach(addRelay) logger.debug('[RelayListBuilder] Added author relays', { author: authorPubkey.substring(0, 8), @@ -158,7 +161,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio if (includeUserOwnRelays && userPubkey) { try { const userRelayList = await client.peekRelayListFromStorage(userPubkey) - const userRead = [...(userRelayList.read || []).slice(0, 10)] + const userRead = userReadRelaysWithHttp(userRelayList).slice(0, 10) const userWrite = [...(userRelayList.write || []).slice(0, 10)] userRead.forEach(addRelay) userWrite.forEach(addRelay) @@ -197,7 +200,9 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio // Even if not including user's own relays, still include user's inboxes for reading try { const userRelayList = await client.peekRelayListFromStorage(userPubkey) - ;[...(userRelayList.read || []).slice(0, 10)].forEach(addRelay) + userReadRelaysWithHttp(userRelayList) + .slice(0, 10) + .forEach(addRelay) // Include local relays from kind 10432 if enabled if (includeLocalRelays) { @@ -241,7 +246,25 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio SEARCHABLE_RELAY_URLS.forEach(addRelay) } - return Array.from(relayUrls) + const merged = Array.from(relayUrls) + const viewer = userPubkey ?? client.pubkey ?? undefined + if (!viewer) { + return applyNostrLandAggrRelayPolicy(merged, false) + } + let favsForAggr: string[] = [] + try { + favsForAggr = await client.fetchFavoriteRelays(viewer) + } catch { + /* ignore */ + } + let nip65ForAggr: TRelayList | null = null + try { + nip65ForAggr = await client.peekRelayListFromStorage(viewer) + } catch { + /* ignore */ + } + const allowAggr = viewerMayUseNostrLandAggr(favsForAggr, nip65ForAggr) + return applyNostrLandAggrRelayPolicy(merged, allowAggr) } /** @@ -336,16 +359,18 @@ export async function buildPollResultsReadRelayUrls(options: { let authorReadSlice: string[] = [] let viewerReadSlice: string[] = [] + let viewerRlForAggr: TRelayList | null = null try { const [authorRl, viewerRl] = await Promise.all([ pollEvent.pubkey ? client.peekRelayListFromStorage(pollEvent.pubkey) : Promise.resolve(null), viewerPubkey ? client.peekRelayListFromStorage(viewerPubkey) : Promise.resolve(null) ]) - if (authorRl?.read?.length) { - authorReadSlice = authorRl.read.slice(0, POLL_RESULTS_NIP65_READ_SLICE) + viewerRlForAggr = viewerRl + if (authorRl) { + authorReadSlice = userReadRelaysWithHttp(authorRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE) } - if (viewerRl?.read?.length) { - viewerReadSlice = viewerRl.read.slice(0, POLL_RESULTS_NIP65_READ_SLICE) + if (viewerRl) { + viewerReadSlice = userReadRelaysWithHttp(viewerRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE) } } catch { logger.debug('[RelayListBuilder] poll results: NIP-65 relay list race failed') @@ -366,7 +391,10 @@ export async function buildPollResultsReadRelayUrls(options: { pushLayer([...FAST_READ_RELAY_URLS]) pushLayer(authorReadSlice) - return ordered.slice(0, POLL_RESULTS_MAX_RELAYS) + const allowAggr = viewerPubkey + ? viewerMayUseNostrLandAggr(viewerFavoriteRelayUrls, viewerRlForAggr ?? undefined) + : false + return applyNostrLandAggrRelayPolicy(ordered.slice(0, POLL_RESULTS_MAX_RELAYS), allowAggr) } /** diff --git a/src/services/mention-event-search.service.ts b/src/services/mention-event-search.service.ts index 56869dd3..f27b2061 100644 --- a/src/services/mention-event-search.service.ts +++ b/src/services/mention-event-search.service.ts @@ -173,10 +173,11 @@ export async function searchEventsForPicker( if (out.length >= limit) return out.slice(0, limit) const need = limit - out.length + const userCentricRelayUrls = await buildCitationPickerSearchRelayUrls() const [fromIdb, fromRelays] = await Promise.all([ indexedDb.getCachedEventsForSearch(q, need, kindsList), queryService.fetchEvents( - SEARCHABLE_RELAY_URLS, + userCentricRelayUrls, { kinds: kindsList, search: q, limit: need }, { eoseTimeout: 5000, globalTimeout: 8000 } ) diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 821f1692..062105e6 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -28,10 +28,11 @@ import { rssArticleStableEventId } from '@/lib/rss-article' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { applyNostrLandAggrRelayPolicy, viewerMayUseNostrLandAggr } from '@/lib/nostr-land-aggr' import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import client, { eventService } from '@/services/client.service' -import { TEmoji } from '@/types' +import { TEmoji, type TRelayList } from '@/types' import dayjs from 'dayjs' import { Event, Filter, kinds } from 'nostr-tools' @@ -503,7 +504,28 @@ class NoteStatsService { // ignore } - return Array.from(seen) + // 8. Logged-in viewer's inboxes (NIP-65 read + kind 10243 http read) — same events often land on personal relays. + let viewerNip65ForAggr: TRelayList | undefined + try { + const me = client.pubkey?.trim() + if (me) { + const mine = await Promise.race([ + client.fetchRelayList(me), + new Promise<{ read?: string[]; write?: string[]; httpRead?: string[]; httpWrite?: string[] }>((r) => + setTimeout(() => r({ read: [], write: [], httpRead: [], httpWrite: [] }), 2000) + ) + ]) + viewerNip65ForAggr = mine + userReadRelaysWithHttp(mine).slice(0, 12).forEach(add) + } + } catch { + // ignore + } + + const allowAggr = client.pubkey + ? viewerMayUseNostrLandAggr(favoriteRelays ?? [], viewerNip65ForAggr) + : false + return applyNostrLandAggrRelayPolicy(Array.from(seen), allowAggr) } /**