Browse Source

adjust web bookmarks

imwald
Silberengel 1 week ago
parent
commit
54e7e0c5b4
  1. 5
      src/components/Note/index.tsx
  2. 6
      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. 45
      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({
} else if (event.kind === ExtendedKind.WEB_BOOKMARK) { } else if (event.kind === ExtendedKind.WEB_BOOKMARK) {
const href = getWebBookmarkArticleUrl(displayEvent) const href = getWebBookmarkArticleUrl(displayEvent)
const title = displayEvent.tags.find((tag) => tag[0] === 'title')?.[1]?.trim() const title = displayEvent.tags.find((tag) => tag[0] === 'title')?.[1]?.trim()
const description = displayEvent.content?.trim()
content = ( content = (
<> <>
{title ? ( {title ? (
@ -472,7 +473,9 @@ export default function Note({
<WebPreview url={href} className="w-full" authorPubkey={event.pubkey} sourceEvent={event} /> <WebPreview url={href} className="w-full" authorPubkey={event.pubkey} sourceEvent={event} />
</div> </div>
) : null} ) : 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) { } else if (event.kind === ExtendedKind.WIKI_ARTICLE) {

6
src/components/NoteList/index.tsx

@ -32,6 +32,7 @@ import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal
import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist' import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist'
import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls' import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { collapseStaleAddressableRevisions } from '@/lib/replaceable-revision'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch' import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch'
@ -365,9 +366,10 @@ function mergeEventBatchesById(
for (const e of incoming) { for (const e of incoming) {
byId.set(e.id, e) byId.set(e.id, e)
} }
return Array.from(byId.values()) return collapseStaleAddressableRevisions(
Array.from(byId.values())
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
.slice(0, cap) ).slice(0, cap)
} }
/** Multi-layer search: keep all existing rows, add new ids only; newer `created_at` wins on duplicate id. No 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'
import { appendMoneroNostrRelays } from '@/lib/monero-nostr-relays' import { appendMoneroNostrRelays } from '@/lib/monero-nostr-relays'
import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req' import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' 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 type { TProfile } from '@/types'
import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
@ -69,7 +73,6 @@ import {
hydrateThreadRepliesFromStats, hydrateThreadRepliesFromStats,
isEaThreadTailBacklinkCandidate, isEaThreadTailBacklinkCandidate,
isPollVoteKind, isPollVoteKind,
isWebThreadTailKind,
loadThreadRepliesFromLocalStores, loadThreadRepliesFromLocalStores,
mergeFetchedKind7ReactionsIntoRootNoteStats, mergeFetchedKind7ReactionsIntoRootNoteStats,
moveReportsToEndPreserveOrder, moveReportsToEndPreserveOrder,
@ -79,7 +82,8 @@ import {
replyFeedZapsFirst, replyFeedZapsFirst,
replyIdPresentInRepliesMap, replyIdPresentInRepliesMap,
replyMatchesThreadForList, replyMatchesThreadForList,
threadBacklinkRelationLabel threadBacklinkRelationLabel,
threadResponseFilterOptions
} from './reply-list-utils' } from './reply-list-utils'
import { useThreadRootInfo } from './useThreadRootInfo' import { useThreadRootInfo } from './useThreadRootInfo'
import { useThreadAttestedPayments } from './useThreadAttestedPayments' import { useThreadAttestedPayments } from './useThreadAttestedPayments'
@ -143,6 +147,10 @@ function ReplyNoteList({
() => buildNoteStatsReplyIdSet(noteStats?.replies), () => buildNoteStatsReplyIdSet(noteStats?.replies),
[noteStats?.replies, noteStats?.updatedAt] [noteStats?.replies, noteStats?.updatedAt]
) )
const threadResponseHideOpts = useMemo(
() => threadResponseFilterOptions(rootInfo),
[rootInfo?.type]
)
const replies: NEvent[] = useMemo(() => { const replies: NEvent[] = useMemo(() => {
const threadDisplayed = collectDisplayedThreadReplies( const threadDisplayed = collectDisplayedThreadReplies(
@ -159,7 +167,8 @@ function ReplyNoteList({
repliesMap, repliesMap,
threadDisplayed, threadDisplayed,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers hideContentMentioningMutedUsers,
rootInfo
) )
const replyIdSet = new Set(replyEvents.map((r) => r.id)) const replyIdSet = new Set(replyEvents.map((r) => r.id))
@ -173,7 +182,12 @@ function ReplyNoteList({
const includeThreadReply = (evt: NEvent) => { const includeThreadReply = (evt: NEvent) => {
if (isPollVoteKind(evt)) return false if (isPollVoteKind(evt)) return false
if ( if (
shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers,
threadResponseHideOpts
)
) { ) {
return false return false
} }
@ -321,7 +335,7 @@ function ReplyNoteList({
} }
if (rootInfo?.type === 'I') { if (rootInfo?.type === 'I') {
for (const r of replies) { 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 return s
@ -358,8 +372,8 @@ function ReplyNoteList({
// Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A // Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A
if (rootInfo?.type === 'I') { if (rootInfo?.type === 'I') {
const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replies, attestedPaymentIds) const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replies, attestedPaymentIds)
const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind)) const middle = nonZaps.filter((e) => !isRssUrlThreadAntwortenTailKind(e.kind))
const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind)) const tailFromReplies = nonZaps.filter((e) => isRssUrlThreadAntwortenTailKind(e.kind))
const tailSeen = new Set<string>() const tailSeen = new Set<string>()
const tail: NEvent[] = [] const tail: NEvent[] = []
const pushTail = (e: NEvent) => { const pushTail = (e: NEvent) => {
@ -567,7 +581,8 @@ function ReplyNoteList({
repliesMap, repliesMap,
[], [],
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers hideContentMentioningMutedUsers,
rootInfo
) )
if (resolved.length >= statsLen) return if (resolved.length >= statsLen) return
@ -589,7 +604,12 @@ function ReplyNoteList({
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (!statsReplyIds.has(evt.id)) return if (!statsReplyIds.has(evt.id)) return
if ( if (
shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers,
threadResponseHideOpts
)
) { ) {
return return
} }
@ -602,7 +622,12 @@ function ReplyNoteList({
(evt) => (evt) =>
statsReplyIds.has(evt.id) && statsReplyIds.has(evt.id) &&
!isPollVoteKind(evt) && !isPollVoteKind(evt) &&
!shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) !shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers,
threadResponseHideOpts
)
) )
if (ok.length > 0) addReplies(ok) if (ok.length > 0) addReplies(ok)
}) })
@ -644,7 +669,8 @@ function ReplyNoteList({
shouldHideThreadResponseEvent( shouldHideThreadResponseEvent(
evt, evt,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers hideContentMentioningMutedUsers,
threadResponseHideOpts
) )
) { ) {
return return
@ -816,7 +842,14 @@ function ReplyNoteList({
if (rootInfo.type === 'I') { if (rootInfo.type === 'I') {
if (!isRssArticleUrlThreadInteraction(evt, rootInfo.id)) return if (!isRssArticleUrlThreadInteraction(evt, rootInfo.id)) return
} }
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) if (
shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers,
threadResponseHideOpts
)
)
return return
streamWalk.set(evt.id.toLowerCase(), evt) streamWalk.set(evt.id.toLowerCase(), evt)
if (statsIdsStream.has(evt.id)) { if (statsIdsStream.has(evt.id)) {
@ -893,7 +926,8 @@ function ReplyNoteList({
shouldHideThreadResponseEvent( shouldHideThreadResponseEvent(
evt, evt,
mutePubkeySet, mutePubkeySet,
hideContentMentioningMutedUsers hideContentMentioningMutedUsers,
threadResponseHideOpts
) )
) { ) {
return false return false
@ -997,7 +1031,14 @@ function ReplyNoteList({
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) if (
shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers,
threadResponseHideOpts
)
)
return return
addReplies([evt]) addReplies([evt])
} }
@ -1008,7 +1049,12 @@ function ReplyNoteList({
const validNested = nestedAccum.filter( const validNested = nestedAccum.filter(
(evt) => (evt) =>
!isPollVoteKind(evt) && !isPollVoteKind(evt) &&
!shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) !shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers,
threadResponseHideOpts
)
) )
if (validNested.length > 0) { if (validNested.length > 0) {
discussionFeedCache.setCachedReplies(rootInfo, validNested) discussionFeedCache.setCachedReplies(rootInfo, validNested)
@ -1069,7 +1115,14 @@ function ReplyNoteList({
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) if (
shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers,
threadResponseHideOpts
)
)
return return
streamWalkById.set(evt.id.toLowerCase(), evt) streamWalkById.set(evt.id.toLowerCase(), evt)
if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, streamWalkById)) return if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, streamWalkById)) return
@ -1084,7 +1137,12 @@ function ReplyNoteList({
const validNested = nestedAccum.filter( const validNested = nestedAccum.filter(
(evt) => (evt) =>
!isPollVoteKind(evt) && !isPollVoteKind(evt) &&
!shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) && !shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers,
threadResponseHideOpts
) &&
replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, nestedWalkMerged) replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, nestedWalkMerged)
) )
if (validNested.length > 0) { if (validNested.length > 0) {
@ -1185,7 +1243,14 @@ function ReplyNoteList({
const shouldShowFeedItem = useCallback( const shouldShowFeedItem = useCallback(
(item: NEvent) => { (item: NEvent) => {
if (isPollVoteKind(item)) return false if (isPollVoteKind(item)) return false
if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { if (
shouldHideThreadResponseEvent(
item,
mutePubkeySet,
hideContentMentioningMutedUsers,
threadResponseHideOpts
)
) {
return false return false
} }
const isQuote = quoteUiIdSet.has(item.id) const isQuote = quoteUiIdSet.has(item.id)

37
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 { eventReferencesThreadTarget } from '@/lib/op-reference-tags'
import type { TRepliesMap } from '@/lib/reply-index' import type { TRepliesMap } from '@/lib/reply-index'
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' 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 { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req' import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
@ -16,6 +20,10 @@ import type { TFunction } from 'i18next'
import type { TRootInfo } from './types' import type { TRootInfo } from './types'
import { THREAD_REPLY_LIMIT } 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 type { TRootInfo } from './types'
export { export {
THREAD_REPLY_LIMIT, THREAD_REPLY_LIMIT,
@ -56,7 +64,7 @@ function dedupeEventsFromRepliesMap(repliesMap: TRepliesMap): NEvent[] {
const byId = new Map<string, NEvent>() const byId = new Map<string, NEvent>()
for (const { events } of repliesMap.values()) { for (const { events } of repliesMap.values()) {
for (const evt of events) { for (const evt of events) {
byId.set(evt.id, evt) upsertEventMapPreferNewestAddressable(byId, evt)
} }
} }
return [...byId.values()] return [...byId.values()]
@ -106,21 +114,23 @@ export function buildRepliesListAlignedWithNoteStats(
repliesMap: TRepliesMap, repliesMap: TRepliesMap,
threadDisplayed: NEvent[], threadDisplayed: NEvent[],
mutePubkeySet: Set<string>, mutePubkeySet: Set<string>,
hideContentMentioningMutedUsers: boolean | undefined hideContentMentioningMutedUsers: boolean | undefined,
rootInfo?: TRootInfo
): NEvent[] { ): NEvent[] {
const statsIds = buildNoteStatsReplyIdSet(statsReplies) const statsIds = buildNoteStatsReplyIdSet(statsReplies)
const byId = new Map<string, NEvent>() const byId = new Map<string, NEvent>()
const hideOpts = threadResponseFilterOptions(rootInfo)
const keep = (evt: NEvent) => { const keep = (evt: NEvent) => {
if (isPollVoteKind(evt)) return false if (isPollVoteKind(evt)) return false
return !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) return !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers, hideOpts)
} }
for (const evt of resolveEventsForStatsReplyIds(statsReplies, repliesMap)) { 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) { for (const evt of threadDisplayed) {
if (keep(evt)) byId.set(evt.id, evt) if (keep(evt)) upsertEventMapPreferNewestAddressable(byId, evt)
} }
const ordered: NEvent[] = [] const ordered: NEvent[] = []
@ -137,7 +147,7 @@ export function buildRepliesListAlignedWithNoteStats(
seen.add(evt.id) seen.add(evt.id)
ordered.push(evt) ordered.push(evt)
} }
return ordered return collapseStaleAddressableRevisions(ordered)
} }
/** Replies to show under “Antworten” for the opened note (direct + nested, not sibling branches). */ /** 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()) { for (const evt of threadWalk.values()) {
if (seen.has(evt.id)) continue if (seen.has(evt.id)) continue
if (isPollVoteKind(evt)) continue if (isPollVoteKind(evt)) continue
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) continue if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers, threadResponseFilterOptions(rootInfo))) continue
if (statsReplyIds?.has(evt.id)) { if (statsReplyIds?.has(evt.id)) {
seen.add(evt.id) seen.add(evt.id)
out.push(evt) out.push(evt)
@ -188,7 +198,7 @@ export function collectDisplayedThreadReplies(
seen.add(evt.id) seen.add(evt.id)
out.push(evt) out.push(evt)
} }
return out return collapseStaleAddressableRevisions(out)
} }
const opHex = openNoteHexId(opEvent) const opHex = openNoteHexId(opEvent)
@ -205,7 +215,7 @@ export function collectDisplayedThreadReplies(
for (const evt of threadWalk.values()) { for (const evt of threadWalk.values()) {
if (seen.has(evt.id)) continue if (seen.has(evt.id)) continue
if (isPollVoteKind(evt)) continue if (isPollVoteKind(evt)) continue
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) continue if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers, threadResponseFilterOptions(rootInfo))) continue
if (statsReplyIds?.has(evt.id)) { if (statsReplyIds?.has(evt.id)) {
seen.add(evt.id) seen.add(evt.id)
out.push(evt) out.push(evt)
@ -218,7 +228,7 @@ export function collectDisplayedThreadReplies(
seen.add(evt.id) seen.add(evt.id)
out.push(evt) out.push(evt)
} }
return out return collapseStaleAddressableRevisions(out)
} }
/** Session LRU + publication store + archive: paint thread replies before relay round-trip. */ /** 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)) const threadWalk = new Map(local.map((e) => [e.id.toLowerCase(), e] as const))
return local.filter((evt) => { return local.filter((evt) => {
if (isPollVoteKind(evt)) return false 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') { if (rootInfo.type === 'I') {
return isRssArticleUrlThreadInteraction(evt, rootInfo.id) return isRssArticleUrlThreadInteraction(evt, rootInfo.id)
} }
@ -602,6 +612,9 @@ export function isPollVoteKind(evt: Pick<NEvent, 'kind'>): boolean {
export function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string { export function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string {
if (item.kind === kinds.Highlights) return t('highlighted this note') 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.ShortTextNote) return t('quoted this note')
if ( if (
item.kind === kinds.LongFormArticle || item.kind === kinds.LongFormArticle ||

32
src/components/RssArticleWebBookmarks/index.tsx

@ -15,7 +15,9 @@ import {
getWebBookmarkArticleUrl getWebBookmarkArticleUrl
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { expandWebBookmarkDTagQueryValues } from '@/lib/web-bookmark-nip' import { expandWebBookmarkDTagQueryValues } from '@/lib/web-bookmark-nip'
import { dedupeLatestAddressableEvents } from '@/lib/replaceable-revision'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { useDeletedEventSafe } from '@/providers/DeletedEventProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' 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). * 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. * 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 { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { isEventDeleted } = useDeletedEventSafe()
const { pubkey, publish, attemptDelete, relayList, cacheRelayListEvent, account } = useNostr() const { pubkey, publish, attemptDelete, relayList, cacheRelayListEvent, account } = useNostr()
const canonical = useMemo(() => canonicalizeRssArticleUrl(articleUrl), [articleUrl]) const canonical = useMemo(() => canonicalizeRssArticleUrl(articleUrl), [articleUrl])
@ -71,24 +81,21 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str
const batches = await Promise.all( const batches = await Promise.all(
filters.map((f) => client.fetchEvents(relayUrls, f, { cache: false }).catch(() => [] as Event[])) filters.map((f) => client.fetchEvents(relayUrls, f, { cache: false }).catch(() => [] as Event[]))
) )
const byKey = new Map<string, Event>() const matched = batches.flat().filter((ev) => {
for (const ev of batches.flat()) { if (ev.pubkey !== pubkey) return false
if (ev.pubkey !== pubkey) continue if (isEventDeleted(ev)) return false
const u = getWebBookmarkArticleUrl(ev) const u = getWebBookmarkArticleUrl(ev)
if (!u || canonicalizeRssArticleUrl(u) !== canonical) continue return !!u && canonicalizeRssArticleUrl(u) === canonical
const d = ev.tags.find((t) => t[0] === 'd')?.[1] })
const key = d ? `wb:${pubkey}:${d}` : ev.id const latest = dedupeLatestAddressableEvents(matched)
const prev = byKey.get(key) setMine(latest.sort((a, b) => b.created_at - a.created_at))
if (!prev || ev.created_at > prev.created_at) byKey.set(key, ev)
}
setMine([...byKey.values()].sort((a, b) => b.created_at - a.created_at))
} catch (e) { } catch (e) {
logger.warn('[RssArticleWebBookmarks] fetch failed', e) logger.warn('[RssArticleWebBookmarks] fetch failed', e)
setMine([]) setMine([])
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [pubkey, relayUrls, iVals, dVals, canonical]) }, [pubkey, relayUrls, iVals, dVals, canonical, isEventDeleted])
useEffect(() => { useEffect(() => {
void reload() void reload()
@ -115,6 +122,7 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str
noteStatsService.updateNoteStatsByEvents([ev], undefined, { noteStatsService.updateNoteStatsByEvents([ev], undefined, {
interactionTargetNoteId: rssRootId interactionTargetNoteId: rssRootId
}) })
onPublished?.()
} catch (e) { } catch (e) {
showPublishingError(e instanceof Error ? e : new Error(String(e))) showPublishingError(e instanceof Error ? e : new Error(String(e)))
} finally { } finally {

28
src/hooks/useProfileTimeline.tsx

@ -8,6 +8,7 @@ import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import type { ProfileReportsRelayList } from '@/lib/profile-reports-relays' import type { ProfileReportsRelayList } from '@/lib/profile-reports-relays'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url'
import { dedupeLatestAddressableEvents } from '@/lib/replaceable-revision'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context' import { useNostrOptional } from '@/providers/nostr-context'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
@ -92,32 +93,7 @@ function postProcessEvents(
let events = Array.from(dedupMap.values()).filter((e) => !isEventDeleted(e)) let events = Array.from(dedupMap.values()).filter((e) => !isEventDeleted(e))
// Parameterized replaceable events (kinds 30000-39999) should be unique by pubkey+kind+d. events = dedupeLatestAddressableEvents(events)
// 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()]
if (filterPredicate) { if (filterPredicate) {
events = events.filter(filterPredicate) events = events.filter(filterPredicate)

87
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<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 @@
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 @@
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)
})
})

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

@ -1,7 +1,6 @@
import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { isReplyNoteEvent } from '@/lib/event'
import { import {
articleUrlMatchesThreadScope, articleUrlMatchesThreadScope,
canonicalizeRssArticleUrl, canonicalizeRssArticleUrl,
@ -12,6 +11,7 @@ import {
getWebBookmarkArticleUrl, getWebBookmarkArticleUrl,
getWebExternalReactionTargetUrl getWebExternalReactionTargetUrl
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { expandWebBookmarkDTagQueryValues } from '@/lib/web-bookmark-nip'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isImage, isLocalNetworkUrl, isMedia, isVideo, normalizeUrl } from '@/lib/url' import { isImage, isLocalNetworkUrl, isMedia, isVideo, normalizeUrl } from '@/lib/url'
import { eventService, queryService } from '@/services/client.service' import { eventService, queryService } from '@/services/client.service'
@ -231,6 +231,22 @@ export function isRssWebUnifiedClutterUrl(url: string): boolean {
return false 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 * 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. * {@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 canonical = canonicalizeRssArticleUrl(canonicalArticleUrl)
const tagVals = expandArticleUrlThreadQueryValues(canonical) const tagVals = expandArticleUrlThreadQueryValues(canonical)
const iFilterVals = tagVals.length > 0 ? tagVals : [canonical] const iFilterVals = tagVals.length > 0 ? tagVals : [canonical]
const dFilterVals = expandWebBookmarkDTagQueryValues(canonical)
const rFilterVals = tagVals.length > 0 ? tagVals : [canonical]
const social: Filter[] = [ const social: Filter[] = [
{ '#i': iFilterVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit }, { '#i': iFilterVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit },
{ '#I': iFilterVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit } { '#I': iFilterVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit }
] ]
const nonSocial: Filter[] = [ const nonSocial: Filter[] = [
{ '#i': iFilterVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit }, { '#r': rFilterVals, kinds: [kinds.Highlights], limit },
{ '#I': iFilterVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit } { '#r': rFilterVals, kinds: [kinds.Reaction], limit }
] ]
if (tagVals.length > 0) { if (dFilterVals.length > 0) {
nonSocial.push({ '#d': dFilterVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit })
}
// Legacy bookmarks that still carry i/I URL tags.
nonSocial.push( nonSocial.push(
{ '#r': tagVals, kinds: [kinds.Highlights], limit }, { '#i': iFilterVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit },
{ '#r': tagVals, kinds: [kinds.Reaction], limit } { '#I': iFilterVals, kinds: [ExtendedKind.WEB_BOOKMARK], limit }
) )
}
return { nonSocial, social } return { nonSocial, social }
} }
@ -272,24 +292,27 @@ export function buildRssArticleUrlThreadInteractionFilters(
return [...nonSocial, ...social] 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 { export function isRssArticleUrlThreadInteraction(evt: Event, canonicalArticleUrl: string): boolean {
if (!RSS_URL_THREAD_ANTWORTEN_KIND_SET.has(evt.kind)) return false
const key = canonicalizeRssArticleUrl(canonicalArticleUrl) const key = canonicalizeRssArticleUrl(canonicalArticleUrl)
if (evt.kind === kinds.Highlights) { if (evt.kind === kinds.Highlights) {
const hu = getHighlightSourceHttpUrl(evt) const hu = getHighlightSourceHttpUrl(evt)
return !!hu && articleUrlMatchesThreadScope(hu, key) return !!hu && articleUrlMatchesThreadScope(hu, key)
} }
if (evt.kind === ExtendedKind.EXTERNAL_REACTION) { if (evt.kind === ExtendedKind.WEB_BOOKMARK) {
const u = getWebExternalReactionTargetUrl(evt) const u = getWebBookmarkArticleUrl(evt)
return !!u && articleUrlMatchesThreadScope(u, key) return !!u && articleUrlMatchesThreadScope(u, key)
} }
if (evt.kind === kinds.Reaction) { if (evt.kind === kinds.Reaction) {
const u = getReactionPageUrlFromRTags(evt) const u = getReactionPageUrlFromRTags(evt)
return !!u && articleUrlMatchesThreadScope(u, key) return !!u && articleUrlMatchesThreadScope(u, key)
} }
if (!isReplyNoteEvent(evt)) return false if (evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) {
const u = getArticleUrlFromCommentITags(evt) const u = getArticleUrlFromCommentITags(evt)
return !!u && articleUrlMatchesThreadScope(u, key) return !!u && articleUrlMatchesThreadScope(u, key)
}
return false
} }
/** /**

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

@ -1,7 +1,8 @@
import { isMentioningMutedUsers, isNip18RepostKind, isNip25ReactionKind } from '@/lib/event' import { isMentioningMutedUsers, isNip18RepostKind, isNip25ReactionKind } from '@/lib/event'
import { getReactionPageUrlFromRTags } from '@/lib/rss-article'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { normalizeUrl } from '@/lib/url' 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). */ /** Lowercase normalized URLs for comparing user-blocked relays (e.g. before REQ). */
export function buildNormalizedBlockedRelaySet(blockedRelays: readonly string[] | undefined): Set<string> { export function buildNormalizedBlockedRelaySet(blockedRelays: readonly string[] | undefined): Set<string> {
@ -43,10 +44,18 @@ export const shouldHideOwnReactionInOthersThread = shouldHideOwnReactionThreadRo
export function shouldHideThreadResponseEvent( export function shouldHideThreadResponseEvent(
evt: Event, evt: Event,
mutePubkeySet: Set<string>, mutePubkeySet: Set<string>,
hideContentMentioningMutedUsers: boolean | undefined hideContentMentioningMutedUsers: boolean | undefined,
options?: { allowPageTargetedReactions?: boolean }
): boolean { ): boolean {
if (isThreadBoosterOnlyRow(evt)) return true 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 (muteSetHas(mutePubkeySet, evt.pubkey)) return true
if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true
return false return false

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

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

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

@ -1415,9 +1415,25 @@ class NoteStatsService {
const targetId = this.statsKey(rssArticleStableEventId(canonicalizeRssArticleUrl(url))) const targetId = this.statsKey(rssArticleStableEventId(canonicalizeRssArticleUrl(url)))
const old = this.noteStatsMap.get(targetId) || {} const old = this.noteStatsMap.get(targetId) || {}
const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set<string>() 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) 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) this.notifyNoteStats(targetId)
return targetId return targetId
} }

Loading…
Cancel
Save