Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
c13a99569b
  1. 35
      src/components/Embedded/EmbeddedNote.tsx
  2. 110
      src/components/NoteList/index.tsx
  3. 22
      src/components/Profile/ProfileFeedWithPins.tsx
  4. 22
      src/components/ReplyNoteList/index.tsx
  5. 46
      src/hooks/useViewerInboxRelayUrlsAndAggr.ts
  6. 74
      src/lib/nostr-land-aggr.ts
  7. 46
      src/lib/relay-list-builder.ts
  8. 3
      src/services/mention-event-search.service.ts
  9. 26
      src/services/note-stats.service.ts

35
src/components/Embedded/EmbeddedNote.tsx

@ -18,6 +18,8 @@ import client, { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import nip66Service from '@/services/nip66.service' import nip66Service from '@/services/nip66.service'
import { navigationEventStore } from '@/services/navigation-event-store' 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 { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
@ -213,6 +215,7 @@ function EmbeddedNoteFetched({
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply() const { addReplies } = useReply()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { inboxRelayUrls, allowNostrLandAggr } = useViewerInboxRelayUrlsAndAggrEligibility()
const [event, setEvent] = useState<Event | undefined>(undefined) const [event, setEvent] = useState<Event | undefined>(undefined)
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
const eventRef = useRef<Event | undefined>(undefined) const eventRef = useRef<Event | undefined>(undefined)
@ -231,8 +234,14 @@ function EmbeddedNoteFetched({
[favoriteRelays, blockedRelays] [favoriteRelays, blockedRelays]
) )
const wideRelaysStatic = useMemo( const wideRelaysStatic = useMemo(
() => buildEmbedWideRelayUrlsStatic(menuRelayUrls, relayHintsFromParent), () =>
[menuRelayUrls, relayHintsFromParent] buildEmbedWideRelayUrlsStatic(
menuRelayUrls,
relayHintsFromParent,
inboxRelayUrls,
allowNostrLandAggr
),
[menuRelayUrls, relayHintsFromParent, inboxRelayUrls, allowNostrLandAggr]
) )
const fetchRelayOpts = useMemo( const fetchRelayOpts = useMemo(
() => (relayHintsFromParent.length > 0 ? { relayHints: relayHintsFromParent } : undefined), () => (relayHintsFromParent.length > 0 ? { relayHints: relayHintsFromParent } : undefined),
@ -261,6 +270,9 @@ function EmbeddedNoteFetched({
}) })
embedFetchCtxRef.current = { fetchRelayOpts, wideRelaysStatic } embedFetchCtxRef.current = { fetchRelayOpts, wideRelaysStatic }
const allowNostrLandAggrRef = useRef(allowNostrLandAggr)
allowNostrLandAggrRef.current = allowNostrLandAggr
const resolveAndSetRef = useRef(resolveAndSet) const resolveAndSetRef = useRef(resolveAndSet)
resolveAndSetRef.current = resolveAndSet resolveAndSetRef.current = resolveAndSet
@ -325,7 +337,7 @@ function EmbeddedNoteFetched({
if (cancelled || eventRef.current) return if (cancelled || eventRef.current) return
const wide0 = embedFetchCtxRef.current.wideRelaysStatic const wide0 = embedFetchCtxRef.current.wideRelaysStatic
const wideMerged = preferPublicIndexRelaysFirst(dedupeRelayUrls([...wide0, ...extra])) const wideMerged = preferPublicIndexRelaysFirst(dedupeRelayUrls([...wide0, ...extra]))
const ev = await runWidePass(wideMerged) const ev = await runWidePass(applyNostrLandAggrRelayPolicy(wideMerged, allowNostrLandAggrRef.current))
if (cancelled || !ev) return if (cancelled || !ev) return
resolve(ev) resolve(ev)
})() })()
@ -505,18 +517,27 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] {
return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b)) 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). */ /** Static + menu favorites + viewer inboxes: REQ on embed mount; nostr.land aggregator only for subscribers. */
function buildEmbedWideRelayUrlsStatic(menuRelayUrls: string[], relayHintsFromParent: string[]): string[] { function buildEmbedWideRelayUrlsStatic(
return preferPublicIndexRelaysFirst( menuRelayUrls: string[],
relayHintsFromParent: string[],
viewerInboxRelayUrls: string[],
allowNostrLandAggr: boolean
): string[] {
return applyNostrLandAggrRelayPolicy(
preferPublicIndexRelaysFirst(
dedupeRelayUrls([ dedupeRelayUrls([
...relayHintsFromParent, ...relayHintsFromParent,
...viewerInboxRelayUrls,
...nip66Service.getSearchableRelayUrls(), ...nip66Service.getSearchableRelayUrls(),
...SEARCHABLE_RELAY_URLS, ...SEARCHABLE_RELAY_URLS,
...FAST_READ_RELAY_URLS, ...FAST_READ_RELAY_URLS,
...FAST_WRITE_RELAY_URLS, ...FAST_WRITE_RELAY_URLS,
...PROFILE_RELAY_URLS, ...PROFILE_RELAY_URLS,
...menuRelayUrls, ...menuRelayUrls
]) ])
),
allowNostrLandAggr
) )
} }

110
src/components/NoteList/index.tsx

@ -70,7 +70,7 @@ import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh' import PullToRefresh from 'react-simple-pull-to-refresh'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { toast } from 'sonner' 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 { usePrimaryPageOptional } from '@/contexts/primary-page-context'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' 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 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<number>()
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( const NoteList = forwardRef(
( (
{ {
@ -2132,6 +2162,84 @@ const NoteList = forwardRef(
/* spell local + disk snapshot is best-effort */ /* 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 (!primedFromDisk) {
if (!keepRowsVisible) setLoading(true) if (!keepRowsVisible) setLoading(true)

22
src/components/Profile/ProfileFeedWithPins.tsx

@ -9,29 +9,10 @@ import { useProfilePins } from '@/hooks/useProfilePins'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { nip19, kinds } from 'nostr-tools' import { nip19, kinds } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent() 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) if (!next.includes(ExtendedKind.GENERIC_REPOST)) next.push(ExtendedKind.GENERIC_REPOST)
return next.sort((a, b) => a - b) return next.sort((a, b) => a - b)
}, [showKinds]) }, [showKinds])
const hideReplies = useHideRepliesLikeMainFeed()
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const noteListRef = useRef<TNoteListRef>(null) const noteListRef = useRef<TNoteListRef>(null)
@ -151,7 +131,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
preserveTimelineOnSubRequestsChange preserveTimelineOnSubRequestsChange
mergeTimelineWhenSubRequestFiltersMatch mergeTimelineWhenSubRequestFiltersMatch
pinnedEventIds={pinnedEventIds} pinnedEventIds={pinnedEventIds}
hideReplies={hideReplies} hideReplies={false}
hideUntrustedNotes={false} hideUntrustedNotes={false}
filterMutedNotes={false} filterMutedNotes={false}
showKind1OPs={showKind1OPs} showKind1OPs={showKind1OPs}

22
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 discussionFeedCache from '@/services/discussion-feed-cache.service'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { applyNostrLandAggrRelayPolicy, viewerMayUseNostrLandAggr } from '@/lib/nostr-land-aggr'
import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags'
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match'
import { import {
@ -1219,6 +1220,21 @@ function ReplyNoteList({
filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT)) 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 // 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. // immediately, rather than waiting up to 10 s for all relays to EOSE.
const urlThreadRootInfo = rootInfo.type === 'I' ? rootInfo : null const urlThreadRootInfo = rootInfo.type === 'I' ? rootInfo : null
@ -1235,7 +1251,7 @@ function ReplyNoteList({
// Use fetchEvents instead of subscribeTimeline for one-time fetching // Use fetchEvents instead of subscribeTimeline for one-time fetching
const allReplies = await queryService.fetchEvents( const allReplies = await queryService.fetchEvents(
finalRelayUrls, relayUrlsForThreadReq,
filters, filters,
urlThreadOnevent ? { onevent: urlThreadOnevent } : undefined urlThreadOnevent ? { onevent: urlThreadOnevent } : undefined
) )
@ -1304,7 +1320,7 @@ function ReplyNoteList({
const nestedFilters: Filter[] = [ const nestedFilters: Filter[] = [
{ '#e': idChunk, kinds: commentKinds, limit: LIMIT } { '#e': idChunk, kinds: commentKinds, limit: LIMIT }
] ]
const nestedReplies = await queryService.fetchEvents(finalRelayUrls, nestedFilters, { const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, {
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers))
@ -1356,7 +1372,7 @@ function ReplyNoteList({
limit: LIMIT limit: LIMIT
} }
] ]
const nestedReplies = await queryService.fetchEvents(finalRelayUrls, nestedFilters, { const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, {
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers))

46
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<string[]>([])
const [peekedNip65, setPeekedNip65] = useState<TRelayList | null>(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 }
}

74
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<string>()
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
}

46
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 { 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 { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { getCacheRelayUrls } from './private-relays' import { getCacheRelayUrls } from './private-relays'
import client from '@/services/client.service' import client from '@/services/client.service'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import type { TRelayList } from '@/types'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
function dedupeNormalizedRelayUrls(urls: string[]): string[] { function dedupeNormalizedRelayUrls(urls: string[]): string[] {
@ -142,7 +145,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
const authorRelayList = await client.peekRelayListFromStorage(authorPubkey) const authorRelayList = await client.peekRelayListFromStorage(authorPubkey)
const authorOutboxes = [...(authorRelayList.write || []).slice(0, 10)] const authorOutboxes = [...(authorRelayList.write || []).slice(0, 10)]
authorOutboxes.forEach(addRelay) authorOutboxes.forEach(addRelay)
const authorInboxes = [...(authorRelayList.read || []).slice(0, 10)] const authorInboxes = userReadRelaysWithHttp(authorRelayList).slice(0, 10)
authorInboxes.forEach(addRelay) authorInboxes.forEach(addRelay)
logger.debug('[RelayListBuilder] Added author relays', { logger.debug('[RelayListBuilder] Added author relays', {
author: authorPubkey.substring(0, 8), author: authorPubkey.substring(0, 8),
@ -158,7 +161,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
if (includeUserOwnRelays && userPubkey) { if (includeUserOwnRelays && userPubkey) {
try { try {
const userRelayList = await client.peekRelayListFromStorage(userPubkey) 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)] const userWrite = [...(userRelayList.write || []).slice(0, 10)]
userRead.forEach(addRelay) userRead.forEach(addRelay)
userWrite.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 // Even if not including user's own relays, still include user's inboxes for reading
try { try {
const userRelayList = await client.peekRelayListFromStorage(userPubkey) 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 // Include local relays from kind 10432 if enabled
if (includeLocalRelays) { if (includeLocalRelays) {
@ -241,7 +246,25 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
SEARCHABLE_RELAY_URLS.forEach(addRelay) 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 authorReadSlice: string[] = []
let viewerReadSlice: string[] = [] let viewerReadSlice: string[] = []
let viewerRlForAggr: TRelayList | null = null
try { try {
const [authorRl, viewerRl] = await Promise.all([ const [authorRl, viewerRl] = await Promise.all([
pollEvent.pubkey ? client.peekRelayListFromStorage(pollEvent.pubkey) : Promise.resolve(null), pollEvent.pubkey ? client.peekRelayListFromStorage(pollEvent.pubkey) : Promise.resolve(null),
viewerPubkey ? client.peekRelayListFromStorage(viewerPubkey) : Promise.resolve(null) viewerPubkey ? client.peekRelayListFromStorage(viewerPubkey) : Promise.resolve(null)
]) ])
if (authorRl?.read?.length) { viewerRlForAggr = viewerRl
authorReadSlice = authorRl.read.slice(0, POLL_RESULTS_NIP65_READ_SLICE) if (authorRl) {
authorReadSlice = userReadRelaysWithHttp(authorRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE)
} }
if (viewerRl?.read?.length) { if (viewerRl) {
viewerReadSlice = viewerRl.read.slice(0, POLL_RESULTS_NIP65_READ_SLICE) viewerReadSlice = userReadRelaysWithHttp(viewerRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE)
} }
} catch { } catch {
logger.debug('[RelayListBuilder] poll results: NIP-65 relay list race failed') 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([...FAST_READ_RELAY_URLS])
pushLayer(authorReadSlice) 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)
} }
/** /**

3
src/services/mention-event-search.service.ts

@ -173,10 +173,11 @@ export async function searchEventsForPicker(
if (out.length >= limit) return out.slice(0, limit) if (out.length >= limit) return out.slice(0, limit)
const need = limit - out.length const need = limit - out.length
const userCentricRelayUrls = await buildCitationPickerSearchRelayUrls()
const [fromIdb, fromRelays] = await Promise.all([ const [fromIdb, fromRelays] = await Promise.all([
indexedDb.getCachedEventsForSearch(q, need, kindsList), indexedDb.getCachedEventsForSearch(q, need, kindsList),
queryService.fetchEvents( queryService.fetchEvents(
SEARCHABLE_RELAY_URLS, userCentricRelayUrls,
{ kinds: kindsList, search: q, limit: need }, { kinds: kindsList, search: q, limit: need },
{ eoseTimeout: 5000, globalTimeout: 8000 } { eoseTimeout: 5000, globalTimeout: 8000 }
) )

26
src/services/note-stats.service.ts

@ -28,10 +28,11 @@ import {
rssArticleStableEventId rssArticleStableEventId
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { applyNostrLandAggrRelayPolicy, viewerMayUseNostrLandAggr } from '@/lib/nostr-land-aggr'
import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import client, { eventService } from '@/services/client.service' import client, { eventService } from '@/services/client.service'
import { TEmoji } from '@/types' import { TEmoji, type TRelayList } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools' import { Event, Filter, kinds } from 'nostr-tools'
@ -503,7 +504,28 @@ class NoteStatsService {
// ignore // 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)
} }
/** /**

Loading…
Cancel
Save