diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index a508b01e..30c43cce 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -454,6 +454,7 @@ export default function Note({ } else if (event.kind === ExtendedKind.WEB_BOOKMARK) { const href = getWebBookmarkArticleUrl(displayEvent) const title = displayEvent.tags.find((tag) => tag[0] === 'title')?.[1]?.trim() + const description = displayEvent.content?.trim() content = ( <> {title ? ( @@ -472,7 +473,9 @@ export default function Note({ ) : null} - {displayEvent.content?.trim() ? renderEventContent({ hideMetadata: true }) : null} + {description ? ( +

{description}

+ ) : null} ) } else if (event.kind === ExtendedKind.WIKI_ARTICLE) { diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 5128a9ce..d0d2176b 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -32,6 +32,7 @@ import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist' import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { collapseStaleAddressableRevisions } from '@/lib/replaceable-revision' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch' @@ -365,9 +366,10 @@ function mergeEventBatchesById( for (const e of incoming) { byId.set(e.id, e) } - return Array.from(byId.values()) - .sort((a, b) => b.created_at - a.created_at) - .slice(0, cap) + return collapseStaleAddressableRevisions( + Array.from(byId.values()) + .sort((a, b) => b.created_at - a.created_at) + ).slice(0, cap) } /** Multi-layer search: keep all existing rows, add new ids only; newer `created_at` wins on duplicate id. No cap. */ diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 36a3a0b5..22ece7d5 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -42,7 +42,11 @@ import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { appendMoneroNostrRelays } from '@/lib/monero-nostr-relays' import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' -import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' +import { + buildRssWebNostrQueryRelayUrls, + isRssArticleUrlThreadInteraction, + isRssUrlThreadAntwortenTailKind +} from '@/lib/rss-web-feed' import type { TProfile } from '@/types' import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useNoteStatsById } from '@/hooks/useNoteStatsById' @@ -69,7 +73,6 @@ import { hydrateThreadRepliesFromStats, isEaThreadTailBacklinkCandidate, isPollVoteKind, - isWebThreadTailKind, loadThreadRepliesFromLocalStores, mergeFetchedKind7ReactionsIntoRootNoteStats, moveReportsToEndPreserveOrder, @@ -79,7 +82,8 @@ import { replyFeedZapsFirst, replyIdPresentInRepliesMap, replyMatchesThreadForList, - threadBacklinkRelationLabel + threadBacklinkRelationLabel, + threadResponseFilterOptions } from './reply-list-utils' import { useThreadRootInfo } from './useThreadRootInfo' import { useThreadAttestedPayments } from './useThreadAttestedPayments' @@ -143,6 +147,10 @@ function ReplyNoteList({ () => buildNoteStatsReplyIdSet(noteStats?.replies), [noteStats?.replies, noteStats?.updatedAt] ) + const threadResponseHideOpts = useMemo( + () => threadResponseFilterOptions(rootInfo), + [rootInfo?.type] + ) const replies: NEvent[] = useMemo(() => { const threadDisplayed = collectDisplayedThreadReplies( @@ -159,7 +167,8 @@ function ReplyNoteList({ repliesMap, threadDisplayed, mutePubkeySet, - hideContentMentioningMutedUsers + hideContentMentioningMutedUsers, + rootInfo ) const replyIdSet = new Set(replyEvents.map((r) => r.id)) @@ -173,7 +182,12 @@ function ReplyNoteList({ const includeThreadReply = (evt: NEvent) => { if (isPollVoteKind(evt)) return false if ( - shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) + shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers, + threadResponseHideOpts + ) ) { return false } @@ -321,7 +335,7 @@ function ReplyNoteList({ } if (rootInfo?.type === 'I') { for (const r of replies) { - if (EA_THREAD_TAIL_REFERENCE_KINDS.has(r.kind)) s.add(r.id) + if (isRssUrlThreadAntwortenTailKind(r.kind)) s.add(r.id) } } return s @@ -358,8 +372,8 @@ function ReplyNoteList({ // Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A if (rootInfo?.type === 'I') { const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replies, attestedPaymentIds) - const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind)) - const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind)) + const middle = nonZaps.filter((e) => !isRssUrlThreadAntwortenTailKind(e.kind)) + const tailFromReplies = nonZaps.filter((e) => isRssUrlThreadAntwortenTailKind(e.kind)) const tailSeen = new Set() const tail: NEvent[] = [] const pushTail = (e: NEvent) => { @@ -567,7 +581,8 @@ function ReplyNoteList({ repliesMap, [], mutePubkeySet, - hideContentMentioningMutedUsers + hideContentMentioningMutedUsers, + rootInfo ) if (resolved.length >= statsLen) return @@ -589,7 +604,12 @@ function ReplyNoteList({ if (isPollVoteKind(evt)) return if (!statsReplyIds.has(evt.id)) return if ( - shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) + shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers, + threadResponseHideOpts + ) ) { return } @@ -602,7 +622,12 @@ function ReplyNoteList({ (evt) => statsReplyIds.has(evt.id) && !isPollVoteKind(evt) && - !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) + !shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers, + threadResponseHideOpts + ) ) if (ok.length > 0) addReplies(ok) }) @@ -644,7 +669,8 @@ function ReplyNoteList({ shouldHideThreadResponseEvent( evt, mutePubkeySet, - hideContentMentioningMutedUsers + hideContentMentioningMutedUsers, + threadResponseHideOpts ) ) { return @@ -816,7 +842,14 @@ function ReplyNoteList({ if (rootInfo.type === 'I') { if (!isRssArticleUrlThreadInteraction(evt, rootInfo.id)) return } - if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) + if ( + shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers, + threadResponseHideOpts + ) + ) return streamWalk.set(evt.id.toLowerCase(), evt) if (statsIdsStream.has(evt.id)) { @@ -893,7 +926,8 @@ function ReplyNoteList({ shouldHideThreadResponseEvent( evt, mutePubkeySet, - hideContentMentioningMutedUsers + hideContentMentioningMutedUsers, + threadResponseHideOpts ) ) { return false @@ -997,7 +1031,14 @@ function ReplyNoteList({ onevent: (evt: NEvent) => { if (fetchGeneration !== replyFetchGenRef.current) return if (isPollVoteKind(evt)) return - if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) + if ( + shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers, + threadResponseHideOpts + ) + ) return addReplies([evt]) } @@ -1008,7 +1049,12 @@ function ReplyNoteList({ const validNested = nestedAccum.filter( (evt) => !isPollVoteKind(evt) && - !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) + !shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers, + threadResponseHideOpts + ) ) if (validNested.length > 0) { discussionFeedCache.setCachedReplies(rootInfo, validNested) @@ -1069,7 +1115,14 @@ function ReplyNoteList({ onevent: (evt: NEvent) => { if (fetchGeneration !== replyFetchGenRef.current) return if (isPollVoteKind(evt)) return - if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) + if ( + shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers, + threadResponseHideOpts + ) + ) return streamWalkById.set(evt.id.toLowerCase(), evt) if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, streamWalkById)) return @@ -1084,7 +1137,12 @@ function ReplyNoteList({ const validNested = nestedAccum.filter( (evt) => !isPollVoteKind(evt) && - !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) && + !shouldHideThreadResponseEvent( + evt, + mutePubkeySet, + hideContentMentioningMutedUsers, + threadResponseHideOpts + ) && replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, nestedWalkMerged) ) if (validNested.length > 0) { @@ -1185,7 +1243,14 @@ function ReplyNoteList({ const shouldShowFeedItem = useCallback( (item: NEvent) => { if (isPollVoteKind(item)) return false - if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { + if ( + shouldHideThreadResponseEvent( + item, + mutePubkeySet, + hideContentMentioningMutedUsers, + threadResponseHideOpts + ) + ) { return false } const isQuote = quoteUiIdSet.has(item.id) diff --git a/src/components/ReplyNoteList/reply-list-utils.ts b/src/components/ReplyNoteList/reply-list-utils.ts index ecbecb5b..07892b32 100644 --- a/src/components/ReplyNoteList/reply-list-utils.ts +++ b/src/components/ReplyNoteList/reply-list-utils.ts @@ -4,7 +4,11 @@ import { isSuperchatKind, replyFeedSuperchatsFirst } from '@/lib/superchat' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import type { TRepliesMap } from '@/lib/reply-index' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' -import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' +import { isRssArticleUrlThreadInteraction, isRssUrlThreadAntwortenTailKind } from '@/lib/rss-web-feed' +import { + collapseStaleAddressableRevisions, + upsertEventMapPreferNewestAddressable +} from '@/lib/replaceable-revision' import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req' import noteStatsService from '@/services/note-stats.service' @@ -16,6 +20,10 @@ import type { TFunction } from 'i18next' import type { TRootInfo } from './types' import { THREAD_REPLY_LIMIT } from './types' +export function threadResponseFilterOptions(rootInfo: TRootInfo | undefined) { + return rootInfo?.type === 'I' ? { allowPageTargetedReactions: true as const } : undefined +} + export type { TRootInfo } from './types' export { THREAD_REPLY_LIMIT, @@ -56,7 +64,7 @@ function dedupeEventsFromRepliesMap(repliesMap: TRepliesMap): NEvent[] { const byId = new Map() for (const { events } of repliesMap.values()) { for (const evt of events) { - byId.set(evt.id, evt) + upsertEventMapPreferNewestAddressable(byId, evt) } } return [...byId.values()] @@ -106,21 +114,23 @@ export function buildRepliesListAlignedWithNoteStats( repliesMap: TRepliesMap, threadDisplayed: NEvent[], mutePubkeySet: Set, - hideContentMentioningMutedUsers: boolean | undefined + hideContentMentioningMutedUsers: boolean | undefined, + rootInfo?: TRootInfo ): NEvent[] { const statsIds = buildNoteStatsReplyIdSet(statsReplies) const byId = new Map() + const hideOpts = threadResponseFilterOptions(rootInfo) const keep = (evt: NEvent) => { if (isPollVoteKind(evt)) return false - return !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) + return !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers, hideOpts) } for (const evt of resolveEventsForStatsReplyIds(statsReplies, repliesMap)) { - if (keep(evt)) byId.set(evt.id, evt) + if (keep(evt)) upsertEventMapPreferNewestAddressable(byId, evt) } for (const evt of threadDisplayed) { - if (keep(evt)) byId.set(evt.id, evt) + if (keep(evt)) upsertEventMapPreferNewestAddressable(byId, evt) } const ordered: NEvent[] = [] @@ -137,7 +147,7 @@ export function buildRepliesListAlignedWithNoteStats( seen.add(evt.id) ordered.push(evt) } - return ordered + return collapseStaleAddressableRevisions(ordered) } /** Replies to show under “Antworten” for the opened note (direct + nested, not sibling branches). */ @@ -171,7 +181,7 @@ export function collectDisplayedThreadReplies( for (const evt of threadWalk.values()) { if (seen.has(evt.id)) continue if (isPollVoteKind(evt)) continue - if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) continue + if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers, threadResponseFilterOptions(rootInfo))) continue if (statsReplyIds?.has(evt.id)) { seen.add(evt.id) out.push(evt) @@ -188,7 +198,7 @@ export function collectDisplayedThreadReplies( seen.add(evt.id) out.push(evt) } - return out + return collapseStaleAddressableRevisions(out) } const opHex = openNoteHexId(opEvent) @@ -205,7 +215,7 @@ export function collectDisplayedThreadReplies( for (const evt of threadWalk.values()) { if (seen.has(evt.id)) continue if (isPollVoteKind(evt)) continue - if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) continue + if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers, threadResponseFilterOptions(rootInfo))) continue if (statsReplyIds?.has(evt.id)) { seen.add(evt.id) out.push(evt) @@ -218,7 +228,7 @@ export function collectDisplayedThreadReplies( seen.add(evt.id) out.push(evt) } - return out + return collapseStaleAddressableRevisions(out) } /** Session LRU + publication store + archive: paint thread replies before relay round-trip. */ @@ -250,7 +260,7 @@ export async function loadThreadRepliesFromLocalStores( const threadWalk = new Map(local.map((e) => [e.id.toLowerCase(), e] as const)) return local.filter((evt) => { if (isPollVoteKind(evt)) return false - if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) return false + if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers, threadResponseFilterOptions(rootInfo))) return false if (rootInfo.type === 'I') { return isRssArticleUrlThreadInteraction(evt, rootInfo.id) } @@ -602,6 +612,9 @@ export function isPollVoteKind(evt: Pick): boolean { export function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string { if (item.kind === kinds.Highlights) return t('highlighted this note') + if (item.kind === ExtendedKind.WEB_BOOKMARK) { + return t('saved a web bookmark', { defaultValue: 'Saved a web bookmark' }) + } if (item.kind === kinds.ShortTextNote) return t('quoted this note') if ( item.kind === kinds.LongFormArticle || diff --git a/src/components/RssArticleWebBookmarks/index.tsx b/src/components/RssArticleWebBookmarks/index.tsx index 1ded8292..8f23cca3 100644 --- a/src/components/RssArticleWebBookmarks/index.tsx +++ b/src/components/RssArticleWebBookmarks/index.tsx @@ -15,7 +15,9 @@ import { getWebBookmarkArticleUrl } from '@/lib/rss-article' import { expandWebBookmarkDTagQueryValues } from '@/lib/web-bookmark-nip' +import { dedupeLatestAddressableEvents } from '@/lib/replaceable-revision' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' +import { useDeletedEventSafe } from '@/providers/DeletedEventProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' @@ -29,9 +31,17 @@ import { useTranslation } from 'react-i18next' * NIP-B0 (kind 39701) web bookmarks for the current article URL: list, add, and remove (replaceable tombstone). * Shown under URL cards on {@link RssArticlePage}, separate from NIP-51 bookmark lists. */ -export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: string }) { +export default function RssArticleWebBookmarks({ + articleUrl, + onPublished +}: { + articleUrl: string + /** Bump RSS/Web thread reply refetch after a local publish. */ + onPublished?: () => void +}) { const { t } = useTranslation() const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { isEventDeleted } = useDeletedEventSafe() const { pubkey, publish, attemptDelete, relayList, cacheRelayListEvent, account } = useNostr() const canonical = useMemo(() => canonicalizeRssArticleUrl(articleUrl), [articleUrl]) @@ -71,24 +81,21 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str const batches = await Promise.all( filters.map((f) => client.fetchEvents(relayUrls, f, { cache: false }).catch(() => [] as Event[])) ) - const byKey = new Map() - for (const ev of batches.flat()) { - if (ev.pubkey !== pubkey) continue + const matched = batches.flat().filter((ev) => { + if (ev.pubkey !== pubkey) return false + if (isEventDeleted(ev)) return false const u = getWebBookmarkArticleUrl(ev) - if (!u || canonicalizeRssArticleUrl(u) !== canonical) continue - const d = ev.tags.find((t) => t[0] === 'd')?.[1] - const key = d ? `wb:${pubkey}:${d}` : ev.id - const prev = byKey.get(key) - if (!prev || ev.created_at > prev.created_at) byKey.set(key, ev) - } - setMine([...byKey.values()].sort((a, b) => b.created_at - a.created_at)) + return !!u && canonicalizeRssArticleUrl(u) === canonical + }) + const latest = dedupeLatestAddressableEvents(matched) + setMine(latest.sort((a, b) => b.created_at - a.created_at)) } catch (e) { logger.warn('[RssArticleWebBookmarks] fetch failed', e) setMine([]) } finally { setLoading(false) } - }, [pubkey, relayUrls, iVals, dVals, canonical]) + }, [pubkey, relayUrls, iVals, dVals, canonical, isEventDeleted]) useEffect(() => { void reload() @@ -115,6 +122,7 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str noteStatsService.updateNoteStatsByEvents([ev], undefined, { interactionTargetNoteId: rssRootId }) + onPublished?.() } catch (e) { showPublishingError(e instanceof Error ? e : new Error(String(e))) } finally { diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index 73b918fc..78d0aca3 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -8,6 +8,7 @@ import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import type { ProfileReportsRelayList } from '@/lib/profile-reports-relays' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' +import { dedupeLatestAddressableEvents } from '@/lib/replaceable-revision' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostrOptional } from '@/providers/nostr-context' import indexedDb from '@/services/indexed-db.service' @@ -92,32 +93,7 @@ function postProcessEvents( let events = Array.from(dedupMap.values()).filter((e) => !isEventDeleted(e)) - // Parameterized replaceable events (kinds 30000-39999) should be unique by pubkey+kind+d. - // Keep only the latest version so profile feeds don't show multiple revisions of one article. - const latestAddressableByKey = new Map() - const nonAddressableEvents: Event[] = [] - events.forEach((evt) => { - const isAddressable = evt.kind >= 30000 && evt.kind < 40000 - if (!isAddressable) { - nonAddressableEvents.push(evt) - return - } - const d = evt.tags.find((t) => t[0] === 'd')?.[1]?.trim() - if (!d) { - nonAddressableEvents.push(evt) - return - } - const key = `${evt.pubkey}:${evt.kind}:${d}` - const existing = latestAddressableByKey.get(key) - if ( - !existing || - evt.created_at > existing.created_at || - (evt.created_at === existing.created_at && evt.id > existing.id) - ) { - latestAddressableByKey.set(key, evt) - } - }) - events = [...nonAddressableEvents, ...latestAddressableByKey.values()] + events = dedupeLatestAddressableEvents(events) if (filterPredicate) { events = events.filter(filterPredicate) diff --git a/src/lib/replaceable-revision.test.ts b/src/lib/replaceable-revision.test.ts new file mode 100644 index 00000000..8f536fc4 --- /dev/null +++ b/src/lib/replaceable-revision.test.ts @@ -0,0 +1,87 @@ +import { ExtendedKind } from '@/constants' +import { + collapseStaleAddressableRevisions, + compareReplaceableRevision, + dedupeLatestAddressableEvents, + getAddressableDedupeKey, + pickNewestReplaceableRevision, + upsertEventMapPreferNewestAddressable +} from '@/lib/replaceable-revision' +import { describe, expect, it } from 'vitest' +import type { Event } from 'nostr-tools' + +function fakeEvent(partial: Partial & Pick): Event { + return { + id: partial.id ?? 'a'.repeat(64), + pubkey: partial.pubkey ?? 'b'.repeat(64), + content: partial.content ?? '', + created_at: partial.created_at ?? 1, + sig: 'sig', + ...partial + } +} + +describe('replaceable revision helpers', () => { + it('builds addressable dedupe keys from d tags', () => { + const ev = fakeEvent({ + kind: ExtendedKind.WEB_BOOKMARK, + tags: [['d', 'example.com/path']] + }) + expect(getAddressableDedupeKey(ev)).toBe(`${ev.pubkey}:${ExtendedKind.WEB_BOOKMARK}:example.com/path`) + }) + + it('picks the newest revision by created_at then id', () => { + const older = fakeEvent({ + id: '1'.repeat(64), + kind: ExtendedKind.WEB_BOOKMARK, + created_at: 10, + tags: [['d', 'example.com']] + }) + const newer = fakeEvent({ + id: '2'.repeat(64), + kind: ExtendedKind.WEB_BOOKMARK, + created_at: 20, + tags: [['d', 'example.com']] + }) + expect(pickNewestReplaceableRevision([older, newer])).toBe(newer) + expect(compareReplaceableRevision(newer, older)).toBeGreaterThan(0) + }) + + it('collapses stale addressable revisions in a feed batch', () => { + const kind1 = fakeEvent({ kind: 1, created_at: 99, tags: [], content: 'note' }) + const older = fakeEvent({ + id: '1'.repeat(64), + kind: ExtendedKind.WEB_BOOKMARK, + created_at: 10, + tags: [['d', 'example.com']] + }) + const newer = fakeEvent({ + id: '2'.repeat(64), + kind: ExtendedKind.WEB_BOOKMARK, + created_at: 20, + tags: [['d', 'example.com']] + }) + const out = collapseStaleAddressableRevisions([kind1, older, newer]) + expect(out).toEqual([kind1, newer]) + expect(dedupeLatestAddressableEvents([older, newer])).toEqual([newer]) + }) + + it('supersedes older addressable rows in a by-id map', () => { + const byId = new Map() + const older = fakeEvent({ + id: '1'.repeat(64), + kind: ExtendedKind.WEB_BOOKMARK, + created_at: 10, + tags: [['d', 'example.com']] + }) + const newer = fakeEvent({ + id: '2'.repeat(64), + kind: ExtendedKind.WEB_BOOKMARK, + created_at: 20, + tags: [['d', 'example.com']] + }) + upsertEventMapPreferNewestAddressable(byId, older) + upsertEventMapPreferNewestAddressable(byId, newer) + expect([...byId.values()]).toEqual([newer]) + }) +}) diff --git a/src/lib/replaceable-revision.ts b/src/lib/replaceable-revision.ts new file mode 100644 index 00000000..b73ca71c --- /dev/null +++ b/src/lib/replaceable-revision.ts @@ -0,0 +1,83 @@ +import type { Event } from 'nostr-tools' + +/** NIP-33 addressable coordinate key (`pubkey:kind:d`) when `d` is present. */ +export function getAddressableDedupeKey(event: Pick): string | null { + if (event.kind < 30000 || event.kind >= 40000) return null + const d = event.tags.find((t) => t[0] === 'd')?.[1]?.trim() + if (!d) return null + return `${event.pubkey}:${event.kind}:${d}` +} + +/** Positive when `a` is a newer replaceable revision than `b`. */ +export function compareReplaceableRevision(a: Event, b: Event): number { + if (a.created_at !== b.created_at) return a.created_at - b.created_at + return a.id.localeCompare(b.id) +} + +export function pickNewestReplaceableRevision(candidates: readonly Event[]): Event | undefined { + if (!candidates.length) return undefined + return candidates.reduce((best, e) => (compareReplaceableRevision(e, best) > 0 ? e : best)) +} + +/** One row per `pubkey:kind:d`; keeps the newest revision only. */ +export function dedupeLatestAddressableEvents(events: readonly Event[]): Event[] { + const latestByKey = new Map() + const nonAddressable: Event[] = [] + for (const evt of events) { + const key = getAddressableDedupeKey(evt) + if (!key) { + nonAddressable.push(evt) + continue + } + const existing = latestByKey.get(key) + if (!existing || compareReplaceableRevision(evt, existing) > 0) { + latestByKey.set(key, evt) + } + } + return [...nonAddressable, ...latestByKey.values()] +} + +/** + * Remove superseded addressable revisions from a timeline batch (feeds, thread replies). + * Non-addressable rows are unchanged. + */ +export function collapseStaleAddressableRevisions(events: readonly Event[]): Event[] { + const latestByKey = new Map() + for (const evt of events) { + const key = getAddressableDedupeKey(evt) + if (!key) continue + const existing = latestByKey.get(key) + if (!existing || compareReplaceableRevision(evt, existing) > 0) { + latestByKey.set(key, evt) + } + } + if (latestByKey.size === 0) return [...events] + + const winningIds = new Set() + for (const winner of latestByKey.values()) { + winningIds.add(winner.id) + } + return events.filter((evt) => { + const key = getAddressableDedupeKey(evt) + if (!key) return true + return winningIds.has(evt.id) + }) +} + +/** When merging into a by-id map, supersede older addressable revisions (same `pubkey:kind:d`). */ +export function upsertEventMapPreferNewestAddressable(byId: Map, evt: Event): void { + const key = getAddressableDedupeKey(evt) + if (!key) { + byId.set(evt.id, evt) + return + } + for (const [id, existing] of byId) { + if (getAddressableDedupeKey(existing) !== key) continue + if (compareReplaceableRevision(evt, existing) > 0) { + byId.delete(id) + byId.set(evt.id, evt) + } + return + } + byId.set(evt.id, evt) +} diff --git a/src/lib/rss-web-feed.test.ts b/src/lib/rss-web-feed.test.ts new file mode 100644 index 00000000..29045215 --- /dev/null +++ b/src/lib/rss-web-feed.test.ts @@ -0,0 +1,86 @@ +import { ExtendedKind } from '@/constants' +import { + buildRssArticleUrlThreadInteractionFilterGroups, + isRssArticleUrlThreadInteraction, + isRssUrlThreadAntwortenTailKind +} from '@/lib/rss-web-feed' +import { describe, expect, it } from 'vitest' +import { kinds, type Event } from 'nostr-tools' + +function fakeEvent(partial: Partial & Pick): Event { + return { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1, + content: '', + sig: 'sig', + ...partial + } +} + +describe('RSS URL thread responses', () => { + const url = 'https://github.com/nostr-protocol/nips/blob/master/B0.md' + + it('matches comments, highlights, web bookmarks, and page reactions', () => { + expect( + isRssArticleUrlThreadInteraction( + fakeEvent({ + kind: ExtendedKind.COMMENT, + tags: [['i', url]] + }), + url + ) + ).toBe(true) + expect( + isRssArticleUrlThreadInteraction( + fakeEvent({ + kind: kinds.Highlights, + tags: [['r', url]] + }), + url + ) + ).toBe(true) + expect( + isRssArticleUrlThreadInteraction( + fakeEvent({ + kind: ExtendedKind.WEB_BOOKMARK, + tags: [['d', 'github.com/nostr-protocol/nips/blob/master/B0.md']] + }), + url + ) + ).toBe(true) + expect( + isRssArticleUrlThreadInteraction( + fakeEvent({ + kind: kinds.Reaction, + tags: [['r', url], ['e', 'c'.repeat(64)]] + }), + url + ) + ).toBe(true) + }) + + it('rejects unrelated kinds on the same URL scope', () => { + expect( + isRssArticleUrlThreadInteraction( + fakeEvent({ kind: ExtendedKind.EXTERNAL_REACTION, tags: [['i', url]] }), + url + ) + ).toBe(false) + }) + + it('requests web bookmarks by d-tag and legacy i/I tags', () => { + const { nonSocial } = buildRssArticleUrlThreadInteractionFilterGroups(url, 20) + expect(nonSocial.some((f) => f.kinds?.includes(ExtendedKind.WEB_BOOKMARK) && f['#d'])).toBe(true) + expect(nonSocial.some((f) => f.kinds?.includes(ExtendedKind.WEB_BOOKMARK) && f['#i'])).toBe(true) + expect(nonSocial.some((f) => f.kinds?.includes(kinds.Reaction) && f['#r'])).toBe(true) + expect(nonSocial.some((f) => f.kinds?.includes(kinds.Highlights) && f['#r'])).toBe(true) + }) + + it('classifies tail kinds for URL thread layout', () => { + expect(isRssUrlThreadAntwortenTailKind(kinds.Highlights)).toBe(true) + expect(isRssUrlThreadAntwortenTailKind(ExtendedKind.WEB_BOOKMARK)).toBe(true) + expect(isRssUrlThreadAntwortenTailKind(kinds.Reaction)).toBe(true) + expect(isRssUrlThreadAntwortenTailKind(ExtendedKind.COMMENT)).toBe(false) + }) +}) diff --git a/src/lib/rss-web-feed.ts b/src/lib/rss-web-feed.ts index f0248c59..75e03b5a 100644 --- a/src/lib/rss-web-feed.ts +++ b/src/lib/rss-web-feed.ts @@ -1,7 +1,6 @@ import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' -import { isReplyNoteEvent } from '@/lib/event' import { articleUrlMatchesThreadScope, canonicalizeRssArticleUrl, @@ -12,6 +11,7 @@ import { getWebBookmarkArticleUrl, getWebExternalReactionTargetUrl } from '@/lib/rss-article' +import { expandWebBookmarkDTagQueryValues } from '@/lib/web-bookmark-nip' import logger from '@/lib/logger' import { isImage, isLocalNetworkUrl, isMedia, isVideo, normalizeUrl } from '@/lib/url' import { eventService, queryService } from '@/services/client.service' @@ -231,6 +231,22 @@ export function isRssWebUnifiedClutterUrl(url: string): boolean { return false } +/** Kinds shown under “Antworten” on RSS/Web URL threads. */ +export const RSS_URL_THREAD_ANTWORTEN_KINDS: readonly number[] = [ + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + kinds.Highlights, + ExtendedKind.WEB_BOOKMARK, + kinds.Reaction +] + +const RSS_URL_THREAD_ANTWORTEN_KIND_SET = new Set(RSS_URL_THREAD_ANTWORTEN_KINDS) + +/** Highlights, web bookmarks, and page-targeted reactions — backlinks tail on URL threads. */ +export function isRssUrlThreadAntwortenTailKind(kind: number): boolean { + return kind === kinds.Highlights || kind === ExtendedKind.WEB_BOOKMARK || kind === kinds.Reaction +} + /** * Split filters: `social` uses kinds that match {@link relayFilterIncludesSocialKindBlockedKind} and therefore omit * {@link SOCIAL_KIND_BLOCKED_RELAY_URLS}; `nonSocial` keeps reactions / `#r` on batches that do not apply that strip. @@ -243,20 +259,24 @@ export function buildRssArticleUrlThreadInteractionFilterGroups( const canonical = canonicalizeRssArticleUrl(canonicalArticleUrl) const tagVals = expandArticleUrlThreadQueryValues(canonical) const iFilterVals = tagVals.length > 0 ? tagVals : [canonical] + const dFilterVals = expandWebBookmarkDTagQueryValues(canonical) + const rFilterVals = tagVals.length > 0 ? tagVals : [canonical] const social: Filter[] = [ { '#i': iFilterVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit }, { '#I': iFilterVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit } ] const nonSocial: Filter[] = [ - { '#i': iFilterVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit }, - { '#I': iFilterVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit } + { '#r': rFilterVals, kinds: [kinds.Highlights], limit }, + { '#r': rFilterVals, kinds: [kinds.Reaction], limit } ] - if (tagVals.length > 0) { - nonSocial.push( - { '#r': tagVals, kinds: [kinds.Highlights], limit }, - { '#r': tagVals, kinds: [kinds.Reaction], limit } - ) + if (dFilterVals.length > 0) { + nonSocial.push({ '#d': dFilterVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit }) } + // Legacy bookmarks that still carry i/I URL tags. + nonSocial.push( + { '#i': iFilterVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit }, + { '#I': iFilterVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit } + ) return { nonSocial, social } } @@ -272,24 +292,27 @@ export function buildRssArticleUrlThreadInteractionFilters( return [...nonSocial, ...social] } -/** Whether `evt` belongs to the URL-scoped article thread (comments / voice / highlight / reactions on this page). */ +/** Whether `evt` belongs to the URL-scoped article thread responses (kinds 1111, 9802, 39701, 7). */ export function isRssArticleUrlThreadInteraction(evt: Event, canonicalArticleUrl: string): boolean { + if (!RSS_URL_THREAD_ANTWORTEN_KIND_SET.has(evt.kind)) return false const key = canonicalizeRssArticleUrl(canonicalArticleUrl) if (evt.kind === kinds.Highlights) { const hu = getHighlightSourceHttpUrl(evt) return !!hu && articleUrlMatchesThreadScope(hu, key) } - if (evt.kind === ExtendedKind.EXTERNAL_REACTION) { - const u = getWebExternalReactionTargetUrl(evt) + if (evt.kind === ExtendedKind.WEB_BOOKMARK) { + const u = getWebBookmarkArticleUrl(evt) return !!u && articleUrlMatchesThreadScope(u, key) } if (evt.kind === kinds.Reaction) { const u = getReactionPageUrlFromRTags(evt) return !!u && articleUrlMatchesThreadScope(u, key) } - if (!isReplyNoteEvent(evt)) return false - const u = getArticleUrlFromCommentITags(evt) - return !!u && articleUrlMatchesThreadScope(u, key) + if (evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) { + const u = getArticleUrlFromCommentITags(evt) + return !!u && articleUrlMatchesThreadScope(u, key) + } + return false } /** diff --git a/src/lib/thread-response-filter.ts b/src/lib/thread-response-filter.ts index 13d52cae..1ebfc536 100644 --- a/src/lib/thread-response-filter.ts +++ b/src/lib/thread-response-filter.ts @@ -1,7 +1,8 @@ import { isMentioningMutedUsers, isNip18RepostKind, isNip25ReactionKind } from '@/lib/event' +import { getReactionPageUrlFromRTags } from '@/lib/rss-article' import { muteSetHas } from '@/lib/mute-set' import { normalizeUrl } from '@/lib/url' -import type { Event } from 'nostr-tools' +import { kinds, type Event } from 'nostr-tools' /** Lowercase normalized URLs for comparing user-blocked relays (e.g. before REQ). */ export function buildNormalizedBlockedRelaySet(blockedRelays: readonly string[] | undefined): Set { @@ -43,10 +44,18 @@ export const shouldHideOwnReactionInOthersThread = shouldHideOwnReactionThreadRo export function shouldHideThreadResponseEvent( evt: Event, mutePubkeySet: Set, - hideContentMentioningMutedUsers: boolean | undefined + hideContentMentioningMutedUsers: boolean | undefined, + options?: { allowPageTargetedReactions?: boolean } ): boolean { if (isThreadBoosterOnlyRow(evt)) return true - if (isThreadReactionOnlyRow(evt)) return true + if (isThreadReactionOnlyRow(evt)) { + const pageUrl = getReactionPageUrlFromRTags(evt) + if (options?.allowPageTargetedReactions && evt.kind === kinds.Reaction && pageUrl) { + // NIP-73 page likes on RSS/Web URL threads are listed under “Antworten”. + } else { + return true + } + } if (muteSetHas(mutePubkeySet, evt.pubkey)) return true if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true return false diff --git a/src/pages/secondary/RssArticlePage/index.tsx b/src/pages/secondary/RssArticlePage/index.tsx index f572b638..5d3ec886 100644 --- a/src/pages/secondary/RssArticlePage/index.tsx +++ b/src/pages/secondary/RssArticlePage/index.tsx @@ -60,6 +60,8 @@ const RssArticlePage = forwardRef( const { rssFeedListEvent } = useNostr() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const [contentKey, setContentKey] = useState(0) + const [threadRefreshToken, setThreadRefreshToken] = useState(0) + const bumpThreadRefresh = useCallback(() => setThreadRefreshToken((n) => n + 1), []) const [allCachedItems, setAllCachedItems] = useState([]) const [loading, setLoading] = useState(true) const [selectedSource, setSelectedSource] = useState<'all' | string>('all') @@ -294,7 +296,7 @@ const RssArticlePage = forwardRef( ) : null} {isHttpArticleUrl(articleUrl) ? (
- +
) : null} {showNostrThread && syntheticRoot ? ( @@ -316,6 +318,7 @@ const RssArticlePage = forwardRef( event={syntheticRoot} showQuotes={false} statsForeground + refreshToken={threadRefreshToken} /> ) : null} @@ -388,7 +391,7 @@ const RssArticlePage = forwardRef( {isHttpArticleUrl(articleUrl) ? (
- +
) : null} @@ -411,6 +414,7 @@ const RssArticlePage = forwardRef( event={syntheticRoot} showQuotes={false} statsForeground + refreshToken={threadRefreshToken} /> ) : null} diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 66d19706..f62ca3b2 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -1415,9 +1415,25 @@ class NoteStatsService { const targetId = this.statsKey(rssArticleStableEventId(canonicalizeRssArticleUrl(url))) const old = this.noteStatsMap.get(targetId) || {} const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set() - if (bookmarkPubkeySet.has(evt.pubkey)) return targetId + const replies = [...(old.replies ?? [])] + + const existingIdx = replies.findIndex((r) => r.pubkey === evt.pubkey) + if (existingIdx >= 0) { + const existing = replies[existingIdx] + if ( + existing.created_at > evt.created_at || + (existing.created_at === evt.created_at && existing.id.localeCompare(evt.id) >= 0) + ) { + return targetId + } + replies.splice(existingIdx, 1) + } + bookmarkPubkeySet.add(evt.pubkey) - this.noteStatsMap.set(targetId, { ...old, bookmarkPubkeySet }) + if (!replies.some((r) => r.id === evt.id)) { + replies.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) + } + this.noteStatsMap.set(targetId, { ...old, bookmarkPubkeySet, replies }) this.notifyNoteStats(targetId) return targetId }