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
}