Browse Source

adjust web bookmarks

imwald
Silberengel 1 week ago
parent
commit
54e7e0c5b4
  1. 5
      src/components/Note/index.tsx
  2. 8
      src/components/NoteList/index.tsx
  3. 103
      src/components/ReplyNoteList/index.tsx
  4. 37
      src/components/ReplyNoteList/reply-list-utils.ts
  5. 32
      src/components/RssArticleWebBookmarks/index.tsx
  6. 28
      src/hooks/useProfileTimeline.tsx
  7. 87
      src/lib/replaceable-revision.test.ts
  8. 83
      src/lib/replaceable-revision.ts
  9. 86
      src/lib/rss-web-feed.test.ts
  10. 51
      src/lib/rss-web-feed.ts
  11. 15
      src/lib/thread-response-filter.ts
  12. 8
      src/pages/secondary/RssArticlePage/index.tsx
  13. 20
      src/services/note-stats.service.ts

5
src/components/Note/index.tsx

@ -454,6 +454,7 @@ export default function Note({ @@ -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({ @@ -472,7 +473,9 @@ export default function Note({
<WebPreview url={href} className="w-full" authorPubkey={event.pubkey} sourceEvent={event} />
</div>
) : null}
{displayEvent.content?.trim() ? renderEventContent({ hideMetadata: true }) : null}
{description ? (
<p className="mt-2 text-base whitespace-pre-wrap break-words">{description}</p>
) : null}
</>
)
} else if (event.kind === ExtendedKind.WIKI_ARTICLE) {

8
src/components/NoteList/index.tsx

@ -32,6 +32,7 @@ import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal @@ -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( @@ -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. */

103
src/components/ReplyNoteList/index.tsx

@ -42,7 +42,11 @@ import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' @@ -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 { @@ -69,7 +73,6 @@ import {
hydrateThreadRepliesFromStats,
isEaThreadTailBacklinkCandidate,
isPollVoteKind,
isWebThreadTailKind,
loadThreadRepliesFromLocalStores,
mergeFetchedKind7ReactionsIntoRootNoteStats,
moveReportsToEndPreserveOrder,
@ -79,7 +82,8 @@ import { @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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<string>()
const tail: NEvent[] = []
const pushTail = (e: NEvent) => {
@ -567,7 +581,8 @@ function ReplyNoteList({ @@ -567,7 +581,8 @@ function ReplyNoteList({
repliesMap,
[],
mutePubkeySet,
hideContentMentioningMutedUsers
hideContentMentioningMutedUsers,
rootInfo
)
if (resolved.length >= statsLen) return
@ -589,7 +604,12 @@ function ReplyNoteList({ @@ -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({ @@ -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({ @@ -644,7 +669,8 @@ function ReplyNoteList({
shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers
hideContentMentioningMutedUsers,
threadResponseHideOpts
)
) {
return
@ -816,7 +842,14 @@ function ReplyNoteList({ @@ -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({ @@ -893,7 +926,8 @@ function ReplyNoteList({
shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers
hideContentMentioningMutedUsers,
threadResponseHideOpts
)
) {
return false
@ -997,7 +1031,14 @@ function ReplyNoteList({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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)

37
src/components/ReplyNoteList/reply-list-utils.ts

@ -4,7 +4,11 @@ import { isSuperchatKind, replyFeedSuperchatsFirst } from '@/lib/superchat' @@ -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' @@ -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[] { @@ -56,7 +64,7 @@ function dedupeEventsFromRepliesMap(repliesMap: TRepliesMap): NEvent[] {
const byId = new Map<string, NEvent>()
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( @@ -106,21 +114,23 @@ export function buildRepliesListAlignedWithNoteStats(
repliesMap: TRepliesMap,
threadDisplayed: NEvent[],
mutePubkeySet: Set<string>,
hideContentMentioningMutedUsers: boolean | undefined
hideContentMentioningMutedUsers: boolean | undefined,
rootInfo?: TRootInfo
): NEvent[] {
const statsIds = buildNoteStatsReplyIdSet(statsReplies)
const byId = new Map<string, NEvent>()
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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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<NEvent, 'kind'>): boolean { @@ -602,6 +612,9 @@ export function isPollVoteKind(evt: Pick<NEvent, 'kind'>): 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 ||

32
src/components/RssArticleWebBookmarks/index.tsx

@ -15,7 +15,9 @@ import { @@ -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' @@ -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 @@ -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<string, Event>()
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 @@ -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 {

28
src/hooks/useProfileTimeline.tsx

@ -8,6 +8,7 @@ import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' @@ -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( @@ -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<string, Event>()
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)

87
src/lib/replaceable-revision.test.ts

@ -0,0 +1,87 @@ @@ -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<Event> & Pick<Event, 'kind' | 'tags'>): 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<string, Event>()
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])
})
})

83
src/lib/replaceable-revision.ts

@ -0,0 +1,83 @@ @@ -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<Event, 'kind' | 'pubkey' | 'tags'>): 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<string, Event>()
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<string, Event>()
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<string>()
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<string, Event>, 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)
}

86
src/lib/rss-web-feed.test.ts

@ -0,0 +1,86 @@ @@ -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<Event> & Pick<Event, 'kind' | 'tags'>): 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)
})
})

51
src/lib/rss-web-feed.ts

@ -1,7 +1,6 @@ @@ -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 { @@ -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 { @@ -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( @@ -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( @@ -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
}
/**

15
src/lib/thread-response-filter.ts

@ -1,7 +1,8 @@ @@ -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<string> {
@ -43,10 +44,18 @@ export const shouldHideOwnReactionInOthersThread = shouldHideOwnReactionThreadRo @@ -43,10 +44,18 @@ export const shouldHideOwnReactionInOthersThread = shouldHideOwnReactionThreadRo
export function shouldHideThreadResponseEvent(
evt: Event,
mutePubkeySet: Set<string>,
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

8
src/pages/secondary/RssArticlePage/index.tsx

@ -60,6 +60,8 @@ const RssArticlePage = forwardRef( @@ -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<TRssFeedItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedSource, setSelectedSource] = useState<'all' | string>('all')
@ -294,7 +296,7 @@ const RssArticlePage = forwardRef( @@ -294,7 +296,7 @@ const RssArticlePage = forwardRef(
) : null}
{isHttpArticleUrl(articleUrl) ? (
<div className="w-full pt-1">
<RssArticleWebBookmarks articleUrl={articleUrl} />
<RssArticleWebBookmarks articleUrl={articleUrl} onPublished={bumpThreadRefresh} />
</div>
) : null}
{showNostrThread && syntheticRoot ? (
@ -316,6 +318,7 @@ const RssArticlePage = forwardRef( @@ -316,6 +318,7 @@ const RssArticlePage = forwardRef(
event={syntheticRoot}
showQuotes={false}
statsForeground
refreshToken={threadRefreshToken}
/>
) : null}
</div>
@ -388,7 +391,7 @@ const RssArticlePage = forwardRef( @@ -388,7 +391,7 @@ const RssArticlePage = forwardRef(
</div>
{isHttpArticleUrl(articleUrl) ? (
<div className="pt-2">
<RssArticleWebBookmarks articleUrl={articleUrl} />
<RssArticleWebBookmarks articleUrl={articleUrl} onPublished={bumpThreadRefresh} />
</div>
) : null}
</div>
@ -411,6 +414,7 @@ const RssArticlePage = forwardRef( @@ -411,6 +414,7 @@ const RssArticlePage = forwardRef(
event={syntheticRoot}
showQuotes={false}
statsForeground
refreshToken={threadRefreshToken}
/>
) : null}
</div>

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

@ -1415,9 +1415,25 @@ class NoteStatsService { @@ -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<string>()
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
}

Loading…
Cancel
Save