Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
eb77cb62fd
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 26
      src/components/Embedded/EmbeddedNote.tsx
  4. 6
      src/components/NoteInteractions/index.tsx
  5. 83
      src/components/ReplyNoteList/index.tsx
  6. 10
      src/lib/favorites-feed-relays.ts
  7. 43
      src/lib/nostr-land-aggr.ts
  8. 35
      src/lib/relay-list-builder.ts
  9. 34
      src/lib/relay-url-priority.test.ts
  10. 8
      src/lib/relay-url-priority.ts
  11. 57
      src/lib/thread-reply-root-match.test.ts
  12. 11
      src/lib/thread-reply-root-match.ts
  13. 9
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  14. 39
      src/pages/secondary/NotePage/index.tsx
  15. 11
      src/providers/FeedProvider.tsx
  16. 11
      src/services/note-stats.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.7.3", "version": "23.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.7.3", "version": "23.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.7.3", "version": "23.8.0",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

26
src/components/Embedded/EmbeddedNote.tsx

@ -19,7 +19,7 @@ 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 { useViewerInboxRelayUrlsAndAggrEligibility } from '@/hooks/useViewerInboxRelayUrlsAndAggr'
import { applyNostrLandAggrRelayPolicy } from '@/lib/nostr-land-aggr' import { ensureNostrLandAggrRelay } 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'
@ -98,12 +98,12 @@ export function EmbeddedNote({
const suppress = useSuppressEmbeddedNoteId() const suppress = useSuppressEmbeddedNoteId()
const embeddedHexId = useMemo(() => hexEventIdFromNoteId(noteId), [noteId]) const embeddedHexId = useMemo(() => hexEventIdFromNoteId(noteId), [noteId])
const embeddedCoordinate = useMemo(() => coordinateFromNoteId(noteId), [noteId]) const embeddedCoordinate = useMemo(() => coordinateFromNoteId(noteId), [noteId])
const validation = useMemo(() => validateEmbeddedNotePointer(noteId), [noteId])
if (suppress) { if (suppress) {
if (embeddedHexId && embeddedHexId === suppress.hexId.toLowerCase()) return null if (embeddedHexId && embeddedHexId === suppress.hexId.toLowerCase()) return null
if (suppress.coordinate && embeddedCoordinate && embeddedCoordinate === suppress.coordinate.toLowerCase()) if (suppress.coordinate && embeddedCoordinate && embeddedCoordinate === suppress.coordinate.toLowerCase())
return null return null
} }
const validation = useMemo(() => validateEmbeddedNotePointer(noteId), [noteId])
if (!validation.valid) { if (!validation.valid) {
return ( return (
<EmbeddedNoteInvalid <EmbeddedNoteInvalid
@ -215,7 +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 { inboxRelayUrls } = 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)
@ -238,10 +238,9 @@ function EmbeddedNoteFetched({
buildEmbedWideRelayUrlsStatic( buildEmbedWideRelayUrlsStatic(
menuRelayUrls, menuRelayUrls,
relayHintsFromParent, relayHintsFromParent,
inboxRelayUrls, inboxRelayUrls
allowNostrLandAggr
), ),
[menuRelayUrls, relayHintsFromParent, inboxRelayUrls, allowNostrLandAggr] [menuRelayUrls, relayHintsFromParent, inboxRelayUrls]
) )
const fetchRelayOpts = useMemo( const fetchRelayOpts = useMemo(
() => (relayHintsFromParent.length > 0 ? { relayHints: relayHintsFromParent } : undefined), () => (relayHintsFromParent.length > 0 ? { relayHints: relayHintsFromParent } : undefined),
@ -270,9 +269,6 @@ 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
@ -337,7 +333,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(applyNostrLandAggrRelayPolicy(wideMerged, allowNostrLandAggrRef.current)) const ev = await runWidePass(ensureNostrLandAggrRelay(wideMerged, { blockedRelays }))
if (cancelled || !ev) return if (cancelled || !ev) return
resolve(ev) resolve(ev)
})() })()
@ -517,14 +513,13 @@ 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 + viewer inboxes: REQ on embed mount; nostr.land aggregator only for subscribers. */ /** Static + menu favorites + viewer inboxes: REQ on embed mount; always include the nostr.land aggregator. */
function buildEmbedWideRelayUrlsStatic( function buildEmbedWideRelayUrlsStatic(
menuRelayUrls: string[], menuRelayUrls: string[],
relayHintsFromParent: string[], relayHintsFromParent: string[],
viewerInboxRelayUrls: string[], viewerInboxRelayUrls: string[]
allowNostrLandAggr: boolean
): string[] { ): string[] {
return applyNostrLandAggrRelayPolicy( return ensureNostrLandAggrRelay(
preferPublicIndexRelaysFirst( preferPublicIndexRelaysFirst(
dedupeRelayUrls([ dedupeRelayUrls([
...relayHintsFromParent, ...relayHintsFromParent,
@ -536,8 +531,7 @@ function buildEmbedWideRelayUrlsStatic(
...PROFILE_RELAY_URLS, ...PROFILE_RELAY_URLS,
...menuRelayUrls ...menuRelayUrls
]) ])
), )
allowNostrLandAggr
) )
} }

6
src/components/NoteInteractions/index.tsx

@ -12,7 +12,8 @@ export default function NoteInteractions({
pageIndex, pageIndex,
event, event,
showQuotes: showQuotesProp, showQuotes: showQuotesProp,
statsForeground = false statsForeground = false,
refreshToken = 0
}: { }: {
pageIndex?: number pageIndex?: number
event: Event event: Event
@ -20,6 +21,8 @@ export default function NoteInteractions({
showQuotes?: boolean showQuotes?: boolean
/** Reply row stats use the same priority lane as the open note (`foregroundStats` on `NoteStats`). */ /** Reply row stats use the same priority lane as the open note (`foregroundStats` on `NoteStats`). */
statsForeground?: boolean statsForeground?: boolean
/** Bump to force the reply list to refetch. */
refreshToken?: number
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [replySort, setReplySort] = useState<ReplySortOption>('oldest') const [replySort, setReplySort] = useState<ReplySortOption>('oldest')
@ -57,6 +60,7 @@ export default function NoteInteractions({
sort={replySort} sort={replySort}
showQuotes={showQuotes} showQuotes={showQuotes}
statsForeground={statsForeground} statsForeground={statsForeground}
refreshToken={refreshToken}
/> />
</> </>
) )

83
src/components/ReplyNoteList/index.tsx

@ -8,8 +8,7 @@ import {
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes'
import { import {
canonicalizeRssArticleUrl, canonicalizeRssArticleUrl,
getArticleUrlFromCommentITags, getArticleUrlFromCommentITags
getHighlightSourceHttpUrl
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { import {
getParentATag, getParentATag,
@ -17,11 +16,11 @@ import {
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
getRootATag, getRootATag,
getRootETag, getRootETag,
getRootEventHexId,
isNip25ReactionKind, isNip25ReactionKind,
isNip56ReportEvent, isNip56ReportEvent,
isReplaceableEvent, isReplaceableEvent,
kind1QuotesThreadRoot kind1QuotesThreadRoot,
resolveDeclaredThreadRootEventHex
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
@ -49,7 +48,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 { ensureNostrLandAggrRelay } 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 {
@ -354,7 +353,8 @@ function ReplyNoteList({
sort = 'oldest', sort = 'oldest',
showQuotes = true, showQuotes = true,
duplicateWebPreviewCleanedUrlHints, duplicateWebPreviewCleanedUrlHints,
statsForeground = false statsForeground = false,
refreshToken = 0
}: { }: {
index?: number index?: number
event: NEvent event: NEvent
@ -365,6 +365,8 @@ function ReplyNoteList({
duplicateWebPreviewCleanedUrlHints?: string[] duplicateWebPreviewCleanedUrlHints?: string[]
/** Passed through to reply row `NoteStats` on note & article pages. */ /** Passed through to reply row `NoteStats` on note & article pages. */
statsForeground?: boolean statsForeground?: boolean
/** Bump to force the relay reply scan to run again. */
refreshToken?: number
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
@ -830,22 +832,24 @@ function ReplyNoteList({
if (rootETag) { if (rootETag) {
const [, rootEventHexId, , , rootEventPubkey] = rootETag const [, rootEventHexId, , , rootEventPubkey] = rootETag
if (rootEventHexId && rootEventPubkey) { if (rootEventHexId && rootEventPubkey) {
const hid = rootEventHexId const hid = resolveDeclaredThreadRootEventHex(rootEventHexId)
const resolvedRootEvent = client.peekSessionCachedEvent(hid)
root = { root = {
type: 'E', type: 'E',
id: /^[0-9a-f]{64}$/i.test(hid) ? hid.toLowerCase() : hid, id: /^[0-9a-f]{64}$/i.test(hid) ? hid.toLowerCase() : hid,
pubkey: rootEventPubkey pubkey: resolvedRootEvent?.pubkey ?? rootEventPubkey
} }
} else { } else {
const rootEventId = generateBech32IdFromETag(rootETag) const rootEventId = generateBech32IdFromETag(rootETag)
if (rootEventId) { if (rootEventId) {
const rootEvent = await eventService.fetchEvent(rootEventId) const rootEvent = await eventService.fetchEvent(rootEventId)
if (rootEvent) { if (rootEvent) {
const rid = rootEvent.id const rid = resolveDeclaredThreadRootEventHex(rootEvent.id)
const resolvedRootEvent = client.peekSessionCachedEvent(rid) ?? rootEvent
root = { root = {
type: 'E', type: 'E',
id: /^[0-9a-f]{64}$/i.test(rid) ? rid.toLowerCase() : rid, id: /^[0-9a-f]{64}$/i.test(rid) ? rid.toLowerCase() : rid,
pubkey: rootEvent.pubkey pubkey: resolvedRootEvent.pubkey
} }
} }
} }
@ -1220,20 +1224,9 @@ function ReplyNoteList({
filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT)) filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT))
} }
const vp = userPubkey?.trim() const relayUrlsForThreadReq = ensureNostrLandAggrRelay(finalRelayUrls, {
let relayUrlsForThreadReq = finalRelayUrls blockedRelays: replyBlockedRelays
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.
@ -1347,19 +1340,34 @@ function ReplyNoteList({
// nested 1 / 1111 / 1244 often tag only the parent's #e; root-scoped REQ misses them (same // nested 1 / 1111 / 1244 often tag only the parent's #e; root-scoped REQ misses them (same
// idea as URL-thread #I follow-up above). // idea as URL-thread #I follow-up above).
if ( if (
regularReplies.length > 0 && (rootInfo.type === 'E' &&
((rootInfo.type === 'E' && [
(event.kind === ExtendedKind.DISCUSSION || event.kind === kinds.ShortTextNote)) || ExtendedKind.DISCUSSION,
rootInfo.type === 'A') ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
kinds.ShortTextNote
].includes(event.kind)) ||
rootInfo.type === 'A'
) { ) {
const commentKindsNested = [ const commentKindsNested = [
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,
kinds.ShortTextNote kinds.ShortTextNote
] ]
const parentIdsNested = regularReplies const focusedParentId =
commentKindsNested.includes(event.kind) && /^[0-9a-f]{64}$/i.test(event.id)
? event.id.toLowerCase()
: undefined
const parentIdsNested = Array.from(
new Set(
[
focusedParentId,
...regularReplies
.filter((evt) => commentKindsNested.includes(evt.kind)) .filter((evt) => commentKindsNested.includes(evt.kind))
.map((evt) => evt.id) .map((evt) => evt.id)
].filter(Boolean) as string[]
)
)
if (parentIdsNested.length > 0) { if (parentIdsNested.length > 0) {
const nestedAccum: NEvent[] = [] const nestedAccum: NEvent[] = []
for (let off = 0; off < parentIdsNested.length; off += MAX_PARENT_IDS_PER_NESTED_REQ) { for (let off = 0; off < parentIdsNested.length; off += MAX_PARENT_IDS_PER_NESTED_REQ) {
@ -1418,6 +1426,7 @@ function ReplyNoteList({
blockedRelays, blockedRelays,
favoriteRelays, favoriteRelays,
browsingRelayUrls, browsingRelayUrls,
refreshToken,
addReplies, addReplies,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
@ -1569,20 +1578,8 @@ function ReplyNoteList({
const parentEventHexId = parentETag?.[1] const parentEventHexId = parentETag?.[1]
const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined
const replyRootId = getRootEventHexId(reply) const belongsToSameThread =
const replyUrlForIThread = rootInfo && replyMatchesThreadForList(reply, event, rootInfo, isDiscussionRoot)
rootInfo?.type === 'I'
? reply.kind === kinds.Highlights
? getHighlightSourceHttpUrl(reply)
: getArticleUrlFromCommentITags(reply)
: undefined
const belongsToSameThread = rootInfo && (
(rootInfo.type === 'E' && replyRootId === rootInfo.id) ||
(rootInfo.type === 'A' && getRootATag(reply)?.[1] === rootInfo.id) ||
(rootInfo.type === 'I' &&
!!replyUrlForIThread &&
canonicalizeRssArticleUrl(replyUrlForIThread) === canonicalizeRssArticleUrl(rootInfo.id))
)
return ( return (
<div <div

10
src/lib/favorites-feed-relays.ts

@ -17,6 +17,7 @@ import {
mergeRelayPriorityLayers, mergeRelayPriorityLayers,
relayUrlsLocalsFirst relayUrlsLocalsFirst
} from '@/lib/relay-url-priority' } from '@/lib/relay-url-priority'
import { ensureNostrLandAggrRelay, stripNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
const blockedSet = (blockedRelays: string[]) => const blockedSet = (blockedRelays: string[]) =>
@ -57,7 +58,7 @@ export function getFavoritesFeedRelayUrls(
seen.add(k) seen.add(k)
out.push(k) out.push(k)
} }
return out return stripNostrLandAggrRelay(out)
} }
/** /**
@ -272,10 +273,13 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
return { return {
...r, ...r,
urls: mergeRelayPriorityLayers(layers, blockedRelays, max, { urls: ensureNostrLandAggrRelay(
mergeRelayPriorityLayers(layers, blockedRelays, max, {
applySocialKindBlockedFilter: applySocial, applySocialKindBlockedFilter: applySocial,
exemptNormUrlsFromSocialKindBlock: userReadSocialExempt exemptNormUrlsFromSocialKindBlock: userReadSocialExempt
}) }),
{ blockedRelays, maxRelays: max }
)
} }
}) })
} }

43
src/lib/nostr-land-aggr.ts

@ -20,6 +20,12 @@ export function isNostrLandWsUrl(url: string | undefined | null): boolean {
return canonWs(url) === NOSTR_LAND_CANON return canonWs(url) === NOSTR_LAND_CANON
} }
/** True if this URL is the nostr.land aggregator websocket (normalized). */
export function isAggrNostrLandWsUrl(url: string | undefined | null): boolean {
if (!url?.trim()) return false
return canonWs(url) === AGGR_CANON
}
/** True if any normalized URL equals nostr.land. */ /** True if any normalized URL equals nostr.land. */
export function relayUrlListMentionsNostrLand(urls: readonly string[] | undefined): boolean { export function relayUrlListMentionsNostrLand(urls: readonly string[] | undefined): boolean {
if (!urls?.length) return false if (!urls?.length) return false
@ -72,3 +78,40 @@ export function applyNostrLandAggrRelayPolicy(urls: readonly string[], allowAggr
} }
return out return out
} }
/** Remove the aggregator from relay stacks that must stay strictly user-curated (favorites feed). */
export function stripNostrLandAggrRelay(urls: readonly string[]): string[] {
const out: string[] = []
const seen = new Set<string>()
for (const u of urls) {
const c = canonWs(u)
if (!c || c === AGGR_CANON || seen.has(c)) continue
seen.add(c)
out.push(normalizeAnyRelayUrl(u) || u.trim())
}
return out
}
/**
* Feed/read surfaces should always hit the nostr.land aggregator. Prepend it before relay caps
* can drop it, unless the user explicitly blocked it for that surface.
*/
export function ensureNostrLandAggrRelay(
urls: readonly string[],
options: { blockedRelays?: readonly string[]; maxRelays?: number } = {}
): string[] {
const blocked = new Set((options.blockedRelays ?? []).map(canonWs))
const out: string[] = []
const seen = new Set<string>()
const push = (u: string) => {
const c = canonWs(u)
if (!c || blocked.has(c) || seen.has(c)) return
seen.add(c)
out.push(normalizeAnyRelayUrl(u) || u.trim())
}
push(AGGR_NOSTR_LAND_WSS)
for (const u of urls) {
push(u)
}
return typeof options.maxRelays === 'number' ? out.slice(0, options.maxRelays) : out
}

35
src/lib/relay-list-builder.ts

@ -11,12 +11,11 @@
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 { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { applyNostrLandAggrRelayPolicy, viewerMayUseNostrLandAggr } from '@/lib/nostr-land-aggr' import { ensureNostrLandAggrRelay } 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[] {
@ -247,24 +246,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
} }
const merged = Array.from(relayUrls) const merged = Array.from(relayUrls)
const viewer = userPubkey ?? client.pubkey ?? undefined return ensureNostrLandAggrRelay(merged, { blockedRelays })
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)
} }
/** /**
@ -359,13 +341,11 @@ 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)
]) ])
viewerRlForAggr = viewerRl
if (authorRl) { if (authorRl) {
authorReadSlice = userReadRelaysWithHttp(authorRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE) authorReadSlice = userReadRelaysWithHttp(authorRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE)
} }
@ -391,15 +371,15 @@ export async function buildPollResultsReadRelayUrls(options: {
pushLayer([...FAST_READ_RELAY_URLS]) pushLayer([...FAST_READ_RELAY_URLS])
pushLayer(authorReadSlice) pushLayer(authorReadSlice)
const allowAggr = viewerPubkey return ensureNostrLandAggrRelay(ordered.slice(0, POLL_RESULTS_MAX_RELAYS), {
? viewerMayUseNostrLandAggr(viewerFavoriteRelayUrls, viewerRlForAggr ?? undefined) blockedRelays,
: false maxRelays: POLL_RESULTS_MAX_RELAYS
return applyNostrLandAggrRelayPolicy(ordered.slice(0, POLL_RESULTS_MAX_RELAYS), allowAggr) })
} }
/** /**
* Build relay list for reading replies/comments * Build relay list for reading replies/comments
* READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes * READ from: FAST_READ_RELAY_URLS + user's inboxes/outboxes + local relays + OP author's outboxes
*/ */
export async function buildReplyReadRelayList( export async function buildReplyReadRelayList(
opAuthorPubkey: string | undefined, opAuthorPubkey: string | undefined,
@ -411,6 +391,7 @@ export async function buildReplyReadRelayList(
authorPubkey: opAuthorPubkey, authorPubkey: opAuthorPubkey,
userPubkey, userPubkey,
relayHints: threadRelayHints, relayHints: threadRelayHints,
includeUserOwnRelays: Boolean(userPubkey),
includeFastReadRelays: true, includeFastReadRelays: true,
includeSearchableRelays: true, includeSearchableRelays: true,
includeLocalRelays: true, includeLocalRelays: true,

34
src/lib/relay-url-priority.test.ts

@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { dedupeNormalizeRelayUrlsOrdered, filterContextAuthorReadRelaysForPublish } from '@/lib/relay-url-priority' import {
buildPrioritizedReadRelayUrls,
dedupeNormalizeRelayUrlsOrdered,
filterContextAuthorReadRelaysForPublish
} from '@/lib/relay-url-priority'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
describe('filterContextAuthorReadRelaysForPublish', () => { describe('filterContextAuthorReadRelaysForPublish', () => {
@ -39,3 +44,30 @@ describe('stripMailboxLocalUrlsForRemoteViewers', () => {
expect(out.httpWrite).toEqual([]) expect(out.httpWrite).toEqual([])
}) })
}) })
describe('nostr.land aggregator feed relay policy', () => {
it('keeps aggr.nostr.land in capped read feed relay stacks', () => {
const out = buildPrioritizedReadRelayUrls({
userReadRelays: [
'wss://reader-a.example/',
'wss://reader-b.example/',
'wss://reader-c.example/'
],
favoriteRelays: [],
maxRelays: 3,
applySocialKindBlockedFilter: false
})
expect(out).toHaveLength(3)
expect(out[0]).toBe('wss://aggr.nostr.land/')
})
it('excludes aggr.nostr.land from the favorites feed relay list', () => {
const out = getFavoritesFeedRelayUrls(
['wss://relay.example.com/', 'wss://aggr.nostr.land/'],
[]
)
expect(out).toEqual(['wss://relay.example.com/'])
})
})

8
src/lib/relay-url-priority.ts

@ -5,6 +5,7 @@ import {
MAX_PUBLISH_RELAYS, MAX_PUBLISH_RELAYS,
MAX_REQ_RELAY_URLS MAX_REQ_RELAY_URLS
} from '@/constants' } from '@/constants'
import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
export { MAX_REQ_RELAY_URLS } export { MAX_REQ_RELAY_URLS }
@ -174,10 +175,13 @@ export function buildPrioritizedReadRelayUrls(opts: {
authorWriteRelays: opts.authorWriteRelays, authorWriteRelays: opts.authorWriteRelays,
favoriteRelays: opts.favoriteRelays favoriteRelays: opts.favoriteRelays
}) })
return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { return ensureNostrLandAggrRelay(
mergeRelayPriorityLayers(layers, opts.blockedRelays, max, {
applySocialKindBlockedFilter: applySocial, applySocialKindBlockedFilter: applySocial,
exemptNormUrlsFromSocialKindBlock: exemptFromSocial exemptNormUrlsFromSocialKindBlock: exemptFromSocial
}) }),
{ blockedRelays: opts.blockedRelays, maxRelays: max }
)
} }
/** /**

57
src/lib/thread-reply-root-match.test.ts

@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest'
import type { Event } from 'nostr-tools'
const { peekSessionCachedEvent } = vi.hoisted(() => ({
peekSessionCachedEvent: vi.fn()
}))
vi.mock('@/services/client.service', () => ({
default: {
peekSessionCachedEvent
}
}))
import { eventReplyMatchesThreadRoot } from './thread-reply-root-match'
const rootId = '0'.repeat(64)
const parentId = '1'.repeat(64)
const childId = '2'.repeat(64)
const author = 'a'.repeat(64)
function event(overrides: Partial<Event>): Event {
return {
id: overrides.id ?? 'f'.repeat(64),
pubkey: overrides.pubkey ?? author,
created_at: overrides.created_at ?? 1,
kind: overrides.kind ?? 1,
tags: overrides.tags ?? [],
content: overrides.content ?? '',
sig: overrides.sig ?? 'b'.repeat(128)
}
}
describe('eventReplyMatchesThreadRoot', () => {
it('accepts a nested reply that only tags a cached parent in the thread', () => {
const parent = event({
id: parentId,
tags: [
['e', rootId, '', 'root'],
['p', author]
]
})
const child = event({
id: childId,
tags: [
['e', parentId, '', 'reply'],
['p', author]
]
})
peekSessionCachedEvent.mockImplementation((id: string) => {
if (id === parentId) return parent
return undefined
})
expect(eventReplyMatchesThreadRoot(child, { type: 'E', id: rootId, pubkey: author })).toBe(true)
})
})

11
src/lib/thread-reply-root-match.ts

@ -120,12 +120,23 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b
if (coord === root.id) return true if (coord === root.id) return true
const rootHex = getRootEventHexId(evt) const rootHex = getRootEventHexId(evt)
if (rootHex && (rootHex === root.eventId || rootHex === root.id)) return true if (rootHex && (rootHex === root.eventId || rootHex === root.id)) return true
const parentHex = getParentEventHexId(evt)?.toLowerCase()
const rootEventHex = root.eventId.trim().toLowerCase()
if (
parentHex &&
/^[0-9a-f]{64}$/i.test(rootEventHex) &&
hexNoteParticipatesInThread(parentHex, rootEventHex)
) {
return true
}
return kind1QuotesThreadRoot(evt, root) return kind1QuotesThreadRoot(evt, root)
} }
const rid = root.id.trim().toLowerCase() const rid = root.id.trim().toLowerCase()
const evtRootHex = getRootEventHexId(evt)?.toLowerCase() const evtRootHex = getRootEventHexId(evt)?.toLowerCase()
if (evtRootHex === rid) return true if (evtRootHex === rid) return true
if (evtRootHex && resolveDeclaredThreadRootEventHex(evtRootHex) === rid) return true if (evtRootHex && resolveDeclaredThreadRootEventHex(evtRootHex) === rid) return true
const parentHex = getParentEventHexId(evt)?.toLowerCase()
if (parentHex && hexNoteParticipatesInThread(parentHex, rid)) return true
if (replyParentIsZapToThreadHex(evt, rid)) return true if (replyParentIsZapToThreadHex(evt, rid)) return true
if (replyParentIsReactionToThreadHex(evt, rid)) return true if (replyParentIsReactionToThreadHex(evt, rid)) return true
return kind1QuotesThreadRoot(evt, root) return kind1QuotesThreadRoot(evt, root)

9
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -18,6 +18,7 @@ import {
} from '@/constants' } from '@/constants'
import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds' import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds'
import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays'
import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { normalizeTopic } from '@/lib/discussion-topics' import { normalizeTopic } from '@/lib/discussion-topics'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
@ -93,7 +94,9 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string
const n = normalizeAnyRelayUrl(u) || u.trim() const n = normalizeAnyRelayUrl(u) || u.trim()
if (n) fastNormSet.add(n) if (n) fastNormSet.add(n)
} }
let out = dedupeNormalizeRelayUrlsOrdered(urls) const out = ensureNostrLandAggrRelay(dedupeNormalizeRelayUrlsOrdered(urls), {
maxRelays: FAUX_SPELL_MAX_RELAYS
})
if (!out.length) return fast.slice(0, FAUX_SPELL_MAX_RELAYS) if (!out.length) return fast.slice(0, FAUX_SPELL_MAX_RELAYS)
const fastCount = () => const fastCount = () =>
@ -125,7 +128,9 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string
} }
if (!addedOne) break if (!addedOne) break
} }
return dedupeNormalizeRelayUrlsOrdered(out).slice(0, FAUX_SPELL_MAX_RELAYS) return ensureNostrLandAggrRelay(dedupeNormalizeRelayUrlsOrdered(out), {
maxRelays: FAUX_SPELL_MAX_RELAYS
})
} }
export function appendCuratedReadOnlyRelays(curated: string[], blockedRelays: string[]): string[] { export function appendCuratedReadOnlyRelays(curated: string[], blockedRelays: string[]): string[] {

39
src/pages/secondary/NotePage/index.tsx

@ -20,10 +20,12 @@ import {
getParentETag, getParentETag,
getParentEventHexId, getParentEventHexId,
getRootBech32Id, getRootBech32Id,
getRootEventHexId getRootEventHexId,
resolveDeclaredThreadRootEventHex
} from '@/lib/event' } from '@/lib/event'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { stripMarkupForPreview } from '@/lib/parent-reply-blurb' import { stripMarkupForPreview } from '@/lib/parent-reply-blurb'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
@ -31,7 +33,7 @@ import { cn } from '@/lib/utils'
import { Ellipsis } from 'lucide-react' import { Ellipsis } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns' import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns'
import { import {
@ -93,6 +95,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent) const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent)
const [externalEvent, setExternalEvent] = useState<Event | undefined>(undefined) const [externalEvent, setExternalEvent] = useState<Event | undefined>(undefined)
const [replyRefreshToken, setReplyRefreshToken] = useState(0)
const finalEvent = event || externalEvent const finalEvent = event || externalEvent
const nip84HighlightEvents = useNip84HighlightTargetEvents(finalEvent) const nip84HighlightEvents = useNip84HighlightTargetEvents(finalEvent)
@ -105,7 +108,11 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
const rootEventId = useMemo(() => { const rootEventId = useMemo(() => {
if (!finalEvent) return undefined if (!finalEvent) return undefined
const rootHex = getRootEventHexId(finalEvent)?.toLowerCase() const rootHex = getRootEventHexId(finalEvent)?.toLowerCase()
if (rootHex && rootHex === finalEvent.id.toLowerCase()) return undefined if (rootHex && /^[0-9a-f]{64}$/i.test(rootHex)) {
const resolvedRootHex = resolveDeclaredThreadRootEventHex(rootHex)
if (resolvedRootHex === finalEvent.id.toLowerCase()) return undefined
return resolvedRootHex
}
return getRootBech32Id(finalEvent) return getRootBech32Id(finalEvent)
}, [finalEvent]) }, [finalEvent])
const rootITag = useMemo( const rootITag = useMemo(
@ -155,6 +162,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
refetchRoot() refetchRoot()
refetchParent() refetchParent()
refetchCalendarInvite() refetchCalendarInvite()
setReplyRefreshToken((n) => n + 1)
}, [refetchMain, refetchRoot, refetchParent, refetchCalendarInvite]) }, [refetchMain, refetchRoot, refetchParent, refetchCalendarInvite])
useEffect(() => { useEffect(() => {
@ -528,6 +536,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
pageIndex={index} pageIndex={index}
event={finalEvent} event={finalEvent}
statsForeground statsForeground
refreshToken={replyRefreshToken}
/> />
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
@ -567,6 +576,18 @@ function ParentNote({
isConsecutive?: boolean isConsecutive?: boolean
}) { }) {
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const navigate = useCallback(
(e: MouseEvent) => {
e.stopPropagation()
if (event) client.addEventToCache(event)
navigateToNote(
toNote(event ?? eventBech32Id),
event,
event ? getCachedThreadContextEvents(event) : undefined
)
},
[event, eventBech32Id, navigateToNote]
)
if (isFetching) { if (isFetching) {
return ( return (
@ -589,22 +610,14 @@ function ParentNote({
'flex space-x-1 px-[0.4375rem] py-1 items-center rounded-full border clickable text-sm text-muted-foreground', 'flex space-x-1 px-[0.4375rem] py-1 items-center rounded-full border clickable text-sm text-muted-foreground',
event && 'hover:text-foreground' event && 'hover:text-foreground'
)} )}
onClick={(e) => { onClick={navigate}
e.stopPropagation()
if (event) client.addEventToCache(event)
navigateToNote(toNote(event ?? eventBech32Id))
}}
> >
{event && ( {event && (
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" deferRemoteAvatar={false} /> <UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" deferRemoteAvatar={false} />
)} )}
<div <div
className="truncate flex-1" className="truncate flex-1"
onClick={(e) => { onClick={navigate}
e.stopPropagation()
if (event) client.addEventToCache(event)
navigateToNote(toNote(event ?? eventBech32Id))
}}
> >
<ContentPreview event={event} /> <ContentPreview event={event} />
</div> </div>

11
src/providers/FeedProvider.tsx

@ -1,5 +1,6 @@
import { getFavoritesFeedRelayUrls, mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls, mergeRelayUrlLayers } from '@/lib/favorites-feed-relays'
import { getRelaySetFromEvent, getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' import { getRelaySetFromEvent, getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { stripNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url' import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
@ -47,8 +48,10 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
}, [cacheRelayListEvent, httpRelayListEvent]) }, [cacheRelayListEvent, httpRelayListEvent])
/** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */ /** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */
const [relayUrls, setRelayUrls] = useState<string[]>(() => const [relayUrls, setRelayUrls] = useState<string[]>(() =>
stripNostrLandAggrRelay(
mergeRelayUrlLayers([getFavoritesFeedRelayUrls([], []), [buildWispTrendingNotesRelayUrl()]], []) mergeRelayUrlLayers([getFavoritesFeedRelayUrls([], []), [buildWispTrendingNotesRelayUrl()]], [])
) )
)
const [isReady, setIsReady] = useState(true) const [isReady, setIsReady] = useState(true)
const [feedInfo, setFeedInfo] = useState<TFeedInfo>({ const [feedInfo, setFeedInfo] = useState<TFeedInfo>({
feedType: 'all-favorites' feedType: 'all-favorites'
@ -138,7 +141,9 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
} }
if (feedType === 'all-favorites') { if (feedType === 'all-favorites') {
const baseRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) const baseRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
const finalRelays = mergeRelayUrlLayers([baseRelays, extraFeedRelayUrls], blockedRelays) const finalRelays = stripNostrLandAggrRelay(
mergeRelayUrlLayers([baseRelays, extraFeedRelayUrls], blockedRelays)
)
logger.debug('Switching to all-favorites, finalRelays:', finalRelays) logger.debug('Switching to all-favorites, finalRelays:', finalRelays)
const newFeedInfo = { feedType } const newFeedInfo = { feedType }
setFeedInfo(newFeedInfo) setFeedInfo(newFeedInfo)
@ -234,7 +239,9 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
if (feedInfo.feedType !== 'all-favorites') return if (feedInfo.feedType !== 'all-favorites') return
const baseRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) const baseRelays = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
const finalRelays = mergeRelayUrlLayers([baseRelays, extraFeedRelayUrls], blockedRelays) const finalRelays = stripNostrLandAggrRelay(
mergeRelayUrlLayers([baseRelays, extraFeedRelayUrls], blockedRelays)
)
logger.debug('Updating relay URLs for all-favorites:', finalRelays) logger.debug('Updating relay URLs for all-favorites:', finalRelays)
// Same logical list can be merged into a new array each run; keep the previous reference so // Same logical list can be merged into a new array each run; keep the previous reference so
// feed consumers (RelaysFeed → NoteList relay subscription) do not re-enter effects in a tight loop. // feed consumers (RelaysFeed → NoteList relay subscription) do not re-enter effects in a tight loop.

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

@ -28,7 +28,7 @@ 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 { ensureNostrLandAggrRelay } 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'
@ -520,7 +520,6 @@ class NoteStatsService {
} }
// 8. Logged-in viewer's inboxes (NIP-65 read + kind 10243 http read) — same events often land on personal relays. // 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 { try {
const me = client.pubkey?.trim() const me = client.pubkey?.trim()
if (me) { if (me) {
@ -536,17 +535,15 @@ class NoteStatsService {
client.fetchRelayList(me), client.fetchRelayList(me),
new Promise<TRelayList>((r) => setTimeout(() => r(emptyViewerRl), 2000)) new Promise<TRelayList>((r) => setTimeout(() => r(emptyViewerRl), 2000))
]) ])
viewerNip65ForAggr = mine
userReadRelaysWithHttp(mine).slice(0, 12).forEach(add) userReadRelaysWithHttp(mine).slice(0, 12).forEach(add)
} }
} catch { } catch {
// ignore // ignore
} }
const allowAggr = client.pubkey return ensureNostrLandAggrRelay(Array.from(seen), {
? viewerMayUseNostrLandAggr(favoriteRelays ?? [], viewerNip65ForAggr) blockedRelays: E_TAG_FILTER_BLOCKED_RELAY_URLS
: false })
return applyNostrLandAggrRelayPolicy(Array.from(seen), allowAggr)
} }
/** /**

Loading…
Cancel
Save