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 @@ @@ -1,12 +1,12 @@
{
"name": "imwald",
"version": "23.7.3",
"version": "23.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
"version": "23.7.3",
"version": "23.8.0",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"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",
"private": true,
"type": "module",

26
src/components/Embedded/EmbeddedNote.tsx

@ -19,7 +19,7 @@ import indexedDb from '@/services/indexed-db.service' @@ -19,7 +19,7 @@ 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 { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider'
@ -98,12 +98,12 @@ export function EmbeddedNote({ @@ -98,12 +98,12 @@ export function EmbeddedNote({
const suppress = useSuppressEmbeddedNoteId()
const embeddedHexId = useMemo(() => hexEventIdFromNoteId(noteId), [noteId])
const embeddedCoordinate = useMemo(() => coordinateFromNoteId(noteId), [noteId])
const validation = useMemo(() => validateEmbeddedNotePointer(noteId), [noteId])
if (suppress) {
if (embeddedHexId && embeddedHexId === suppress.hexId.toLowerCase()) return null
if (suppress.coordinate && embeddedCoordinate && embeddedCoordinate === suppress.coordinate.toLowerCase())
return null
}
const validation = useMemo(() => validateEmbeddedNotePointer(noteId), [noteId])
if (!validation.valid) {
return (
<EmbeddedNoteInvalid
@ -215,7 +215,7 @@ function EmbeddedNoteFetched({ @@ -215,7 +215,7 @@ function EmbeddedNoteFetched({
const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { inboxRelayUrls, allowNostrLandAggr } = useViewerInboxRelayUrlsAndAggrEligibility()
const { inboxRelayUrls } = useViewerInboxRelayUrlsAndAggrEligibility()
const [event, setEvent] = useState<Event | undefined>(undefined)
const [isFetching, setIsFetching] = useState(true)
const eventRef = useRef<Event | undefined>(undefined)
@ -238,10 +238,9 @@ function EmbeddedNoteFetched({ @@ -238,10 +238,9 @@ function EmbeddedNoteFetched({
buildEmbedWideRelayUrlsStatic(
menuRelayUrls,
relayHintsFromParent,
inboxRelayUrls,
allowNostrLandAggr
inboxRelayUrls
),
[menuRelayUrls, relayHintsFromParent, inboxRelayUrls, allowNostrLandAggr]
[menuRelayUrls, relayHintsFromParent, inboxRelayUrls]
)
const fetchRelayOpts = useMemo(
() => (relayHintsFromParent.length > 0 ? { relayHints: relayHintsFromParent } : undefined),
@ -270,9 +269,6 @@ function EmbeddedNoteFetched({ @@ -270,9 +269,6 @@ function EmbeddedNoteFetched({
})
embedFetchCtxRef.current = { fetchRelayOpts, wideRelaysStatic }
const allowNostrLandAggrRef = useRef(allowNostrLandAggr)
allowNostrLandAggrRef.current = allowNostrLandAggr
const resolveAndSetRef = useRef(resolveAndSet)
resolveAndSetRef.current = resolveAndSet
@ -337,7 +333,7 @@ function EmbeddedNoteFetched({ @@ -337,7 +333,7 @@ function EmbeddedNoteFetched({
if (cancelled || eventRef.current) return
const wide0 = embedFetchCtxRef.current.wideRelaysStatic
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
resolve(ev)
})()
@ -517,14 +513,13 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] { @@ -517,14 +513,13 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] {
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(
menuRelayUrls: string[],
relayHintsFromParent: string[],
viewerInboxRelayUrls: string[],
allowNostrLandAggr: boolean
viewerInboxRelayUrls: string[]
): string[] {
return applyNostrLandAggrRelayPolicy(
return ensureNostrLandAggrRelay(
preferPublicIndexRelaysFirst(
dedupeRelayUrls([
...relayHintsFromParent,
@ -536,8 +531,7 @@ function buildEmbedWideRelayUrlsStatic( @@ -536,8 +531,7 @@ function buildEmbedWideRelayUrlsStatic(
...PROFILE_RELAY_URLS,
...menuRelayUrls
])
),
allowNostrLandAggr
)
)
}

6
src/components/NoteInteractions/index.tsx

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

83
src/components/ReplyNoteList/index.tsx

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

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

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

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

@ -20,6 +20,12 @@ export function isNostrLandWsUrl(url: string | undefined | null): boolean { @@ -20,6 +20,12 @@ export function isNostrLandWsUrl(url: string | undefined | null): boolean {
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. */
export function relayUrlListMentionsNostrLand(urls: readonly string[] | undefined): boolean {
if (!urls?.length) return false
@ -72,3 +78,40 @@ export function applyNostrLandAggrRelayPolicy(urls: readonly string[], allowAggr @@ -72,3 +78,40 @@ export function applyNostrLandAggrRelayPolicy(urls: readonly string[], allowAggr
}
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 @@ @@ -11,12 +11,11 @@
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 { ensureNostrLandAggrRelay } 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[] {
@ -247,24 +246,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -247,24 +246,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
}
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)
return ensureNostrLandAggrRelay(merged, { blockedRelays })
}
/**
@ -359,13 +341,11 @@ export async function buildPollResultsReadRelayUrls(options: { @@ -359,13 +341,11 @@ 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)
])
viewerRlForAggr = viewerRl
if (authorRl) {
authorReadSlice = userReadRelaysWithHttp(authorRl).slice(0, POLL_RESULTS_NIP65_READ_SLICE)
}
@ -391,15 +371,15 @@ export async function buildPollResultsReadRelayUrls(options: { @@ -391,15 +371,15 @@ export async function buildPollResultsReadRelayUrls(options: {
pushLayer([...FAST_READ_RELAY_URLS])
pushLayer(authorReadSlice)
const allowAggr = viewerPubkey
? viewerMayUseNostrLandAggr(viewerFavoriteRelayUrls, viewerRlForAggr ?? undefined)
: false
return applyNostrLandAggrRelayPolicy(ordered.slice(0, POLL_RESULTS_MAX_RELAYS), allowAggr)
return ensureNostrLandAggrRelay(ordered.slice(0, POLL_RESULTS_MAX_RELAYS), {
blockedRelays,
maxRelays: POLL_RESULTS_MAX_RELAYS
})
}
/**
* 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(
opAuthorPubkey: string | undefined,
@ -411,6 +391,7 @@ export async function buildReplyReadRelayList( @@ -411,6 +391,7 @@ export async function buildReplyReadRelayList(
authorPubkey: opAuthorPubkey,
userPubkey,
relayHints: threadRelayHints,
includeUserOwnRelays: Boolean(userPubkey),
includeFastReadRelays: true,
includeSearchableRelays: true,
includeLocalRelays: true,

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

@ -1,5 +1,10 @@ @@ -1,5 +1,10 @@
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'
describe('filterContextAuthorReadRelaysForPublish', () => {
@ -39,3 +44,30 @@ describe('stripMailboxLocalUrlsForRemoteViewers', () => { @@ -39,3 +44,30 @@ describe('stripMailboxLocalUrlsForRemoteViewers', () => {
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 { @@ -5,6 +5,7 @@ import {
MAX_PUBLISH_RELAYS,
MAX_REQ_RELAY_URLS
} from '@/constants'
import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
export { MAX_REQ_RELAY_URLS }
@ -174,10 +175,13 @@ export function buildPrioritizedReadRelayUrls(opts: { @@ -174,10 +175,13 @@ export function buildPrioritizedReadRelayUrls(opts: {
authorWriteRelays: opts.authorWriteRelays,
favoriteRelays: opts.favoriteRelays
})
return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, {
return ensureNostrLandAggrRelay(
mergeRelayPriorityLayers(layers, opts.blockedRelays, max, {
applySocialKindBlockedFilter: applySocial,
exemptNormUrlsFromSocialKindBlock: exemptFromSocial
})
}),
{ blockedRelays: opts.blockedRelays, maxRelays: max }
)
}
/**

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

@ -0,0 +1,57 @@ @@ -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 @@ -120,12 +120,23 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b
if (coord === root.id) return true
const rootHex = getRootEventHexId(evt)
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)
}
const rid = root.id.trim().toLowerCase()
const evtRootHex = getRootEventHexId(evt)?.toLowerCase()
if (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 (replyParentIsReactionToThreadHex(evt, rid)) return true
return kind1QuotesThreadRoot(evt, root)

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

@ -18,6 +18,7 @@ import { @@ -18,6 +18,7 @@ import {
} from '@/constants'
import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds'
import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays'
import { ensureNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { normalizeTopic } from '@/lib/discussion-topics'
import { userIdToPubkey } from '@/lib/pubkey'
@ -93,7 +94,9 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string @@ -93,7 +94,9 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string
const n = normalizeAnyRelayUrl(u) || u.trim()
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)
const fastCount = () =>
@ -125,7 +128,9 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string @@ -125,7 +128,9 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string
}
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[] {

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

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

11
src/providers/FeedProvider.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { getFavoritesFeedRelayUrls, mergeRelayUrlLayers } from '@/lib/favorites-feed-relays'
import { getRelaySetFromEvent, getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { stripNostrLandAggrRelay } from '@/lib/nostr-land-aggr'
import logger from '@/lib/logger'
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl } from '@/lib/url'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
@ -47,8 +48,10 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -47,8 +48,10 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
}, [cacheRelayListEvent, httpRelayListEvent])
/** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */
const [relayUrls, setRelayUrls] = useState<string[]>(() =>
stripNostrLandAggrRelay(
mergeRelayUrlLayers([getFavoritesFeedRelayUrls([], []), [buildWispTrendingNotesRelayUrl()]], [])
)
)
const [isReady, setIsReady] = useState(true)
const [feedInfo, setFeedInfo] = useState<TFeedInfo>({
feedType: 'all-favorites'
@ -138,7 +141,9 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -138,7 +141,9 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
}
if (feedType === 'all-favorites') {
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)
const newFeedInfo = { feedType }
setFeedInfo(newFeedInfo)
@ -234,7 +239,9 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -234,7 +239,9 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (feedInfo.feedType !== 'all-favorites') return
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)
// 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.

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

@ -28,7 +28,7 @@ import { @@ -28,7 +28,7 @@ import {
rssArticleStableEventId
} from '@/lib/rss-article'
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 { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import client, { eventService } from '@/services/client.service'
@ -520,7 +520,6 @@ class NoteStatsService { @@ -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.
let viewerNip65ForAggr: TRelayList | undefined
try {
const me = client.pubkey?.trim()
if (me) {
@ -536,17 +535,15 @@ class NoteStatsService { @@ -536,17 +535,15 @@ class NoteStatsService {
client.fetchRelayList(me),
new Promise<TRelayList>((r) => setTimeout(() => r(emptyViewerRl), 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)
return ensureNostrLandAggrRelay(Array.from(seen), {
blockedRelays: E_TAG_FILTER_BLOCKED_RELAY_URLS
})
}
/**

Loading…
Cancel
Save