diff --git a/package-lock.json b/package-lock.json index ea58ac91..f52e6a7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6bd51333..a1b030ed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index c2f97b79..dba60025 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -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({ 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 ( (undefined) const [isFetching, setIsFetching] = useState(true) const eventRef = useRef(undefined) @@ -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({ }) embedFetchCtxRef.current = { fetchRelayOpts, wideRelaysStatic } - const allowNostrLandAggrRef = useRef(allowNostrLandAggr) - allowNostrLandAggrRef.current = allowNostrLandAggr - const resolveAndSetRef = useRef(resolveAndSet) resolveAndSetRef.current = resolveAndSet @@ -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[] { 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( ...PROFILE_RELAY_URLS, ...menuRelayUrls ]) - ), - allowNostrLandAggr + ) ) } diff --git a/src/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx index a1c8ac7d..63803ad5 100644 --- a/src/components/NoteInteractions/index.tsx +++ b/src/components/NoteInteractions/index.tsx @@ -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({ 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('oldest') @@ -57,6 +60,7 @@ export default function NoteInteractions({ sort={replySort} showQuotes={showQuotes} statsForeground={statsForeground} + refreshToken={refreshToken} /> ) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 3fe468d1..5edce3d0 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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 { 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' 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({ sort = 'oldest', showQuotes = true, duplicateWebPreviewCleanedUrlHints, - statsForeground = false + statsForeground = false, + refreshToken = 0 }: { index?: number event: NEvent @@ -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({ 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({ 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({ // 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 - .filter((evt) => commentKindsNested.includes(evt.kind)) - .map((evt) => evt.id) + 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({ blockedRelays, favoriteRelays, browsingRelayUrls, + refreshToken, addReplies, mutePubkeySet, hideContentMentioningMutedUsers, @@ -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 (
@@ -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( return { ...r, - urls: mergeRelayPriorityLayers(layers, blockedRelays, max, { - applySocialKindBlockedFilter: applySocial, - exemptNormUrlsFromSocialKindBlock: userReadSocialExempt - }) + urls: ensureNostrLandAggrRelay( + mergeRelayPriorityLayers(layers, blockedRelays, max, { + applySocialKindBlockedFilter: applySocial, + exemptNormUrlsFromSocialKindBlock: userReadSocialExempt + }), + { blockedRelays, maxRelays: max } + ) } }) } diff --git a/src/lib/nostr-land-aggr.ts b/src/lib/nostr-land-aggr.ts index 7952c92a..8b9451c2 100644 --- a/src/lib/nostr-land-aggr.ts +++ b/src/lib/nostr-land-aggr.ts @@ -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 } 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() + 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() + 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 +} diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 5c88f7cf..1415bb50 100644 --- a/src/lib/relay-list-builder.ts +++ b/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 { 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 } 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: { 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: { 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( authorPubkey: opAuthorPubkey, userPubkey, relayHints: threadRelayHints, + includeUserOwnRelays: Boolean(userPubkey), includeFastReadRelays: true, includeSearchableRelays: true, includeLocalRelays: true, diff --git a/src/lib/relay-url-priority.test.ts b/src/lib/relay-url-priority.test.ts index 692ec598..2befa11b 100644 --- a/src/lib/relay-url-priority.test.ts +++ b/src/lib/relay-url-priority.test.ts @@ -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', () => { 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/']) + }) +}) diff --git a/src/lib/relay-url-priority.ts b/src/lib/relay-url-priority.ts index d299e0e5..3ad0b6ec 100644 --- a/src/lib/relay-url-priority.ts +++ b/src/lib/relay-url-priority.ts @@ -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: { authorWriteRelays: opts.authorWriteRelays, favoriteRelays: opts.favoriteRelays }) - return mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { - applySocialKindBlockedFilter: applySocial, - exemptNormUrlsFromSocialKindBlock: exemptFromSocial - }) + return ensureNostrLandAggrRelay( + mergeRelayPriorityLayers(layers, opts.blockedRelays, max, { + applySocialKindBlockedFilter: applySocial, + exemptNormUrlsFromSocialKindBlock: exemptFromSocial + }), + { blockedRelays: opts.blockedRelays, maxRelays: max } + ) } /** diff --git a/src/lib/thread-reply-root-match.test.ts b/src/lib/thread-reply-root-match.test.ts new file mode 100644 index 00000000..20284ab7 --- /dev/null +++ b/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 { + 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) + }) +}) diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts index 7499c99f..8dea88c2 100644 --- a/src/lib/thread-reply-root-match.ts +++ b/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 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) diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 2c10d23d..e2aa4b4a 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -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 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 } 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[] { diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index e29a122f..65852d7b 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -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' 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 }: const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent) const [externalEvent, setExternalEvent] = useState(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 }: 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 }: refetchRoot() refetchParent() refetchCalendarInvite() + setReplyRefreshToken((n) => n + 1) }, [refetchMain, refetchRoot, refetchParent, refetchCalendarInvite]) useEffect(() => { @@ -528,6 +536,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: pageIndex={index} event={finalEvent} statsForeground + refreshToken={replyRefreshToken} />
@@ -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({ '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 && ( )}
{ - e.stopPropagation() - if (event) client.addEventToCache(event) - navigateToNote(toNote(event ?? eventBech32Id)) - }} + onClick={navigate} >
diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 6312104b..0d88a008 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -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,7 +48,9 @@ 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(() => - mergeRelayUrlLayers([getFavoritesFeedRelayUrls([], []), [buildWispTrendingNotesRelayUrl()]], []) + stripNostrLandAggrRelay( + mergeRelayUrlLayers([getFavoritesFeedRelayUrls([], []), [buildWispTrendingNotesRelayUrl()]], []) + ) ) const [isReady, setIsReady] = useState(true) const [feedInfo, setFeedInfo] = useState({ @@ -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 }) { 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. diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 6045aa63..c88359cd 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -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 { } // 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 { client.fetchRelayList(me), new Promise((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 + }) } /**