From 879d05c0ace0cbcc0b2f3cea3eadeb806d29701d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 25 Mar 2026 13:34:21 +0100 Subject: [PATCH] implement kind 17 reactions on external/web content --- src/components/ContentPreview/index.tsx | 4 +- src/components/Note/ReactionEmojiDisplay.tsx | 9 +- src/components/Note/index.tsx | 21 +++-- src/components/ReplyNote/index.tsx | 24 ++++-- src/components/ReplyNoteList/index.tsx | 5 +- src/constants.ts | 2 + src/hooks/useNotificationReactionDisplay.ts | 4 + src/lib/draft-event.ts | 30 ++++++- src/lib/event.ts | 5 ++ src/lib/note-renderable-kinds.ts | 1 + src/lib/notification.ts | 2 +- src/lib/reaction-display.ts | 5 +- src/lib/rss-article.ts | 30 ++++++- .../primary/SpellsPage/fauxSpellFeeds.ts | 1 + src/pages/secondary/NotePage/index.tsx | 2 + src/providers/NostrProvider/index.tsx | 2 +- src/providers/ReplyProvider.tsx | 5 +- src/services/discussion-feed-cache.service.ts | 9 +- src/services/note-stats.service.ts | 85 ++++++++++++++++--- 19 files changed, 200 insertions(+), 46 deletions(-) diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index 9b768167..c0f34116 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -4,7 +4,7 @@ import { notificationReactionSummaryKey, useNotificationReactionDisplay } from '@/hooks/useNotificationReactionDisplay' -import { isMentioningMutedUsers } from '@/lib/event' +import { isMentioningMutedUsers, isNip25ReactionKind } from '@/lib/event' import { DISCUSSION_DOWNVOTE_DISPLAY, DISCUSSION_UPVOTE_DISPLAY @@ -150,7 +150,7 @@ export default function ContentPreview({ return } - if (event.kind === kinds.Reaction) { + if (isNip25ReactionKind(event.kind)) { return (
{reactionDisplay.status === 'pending' ? ( diff --git a/src/components/Note/ReactionEmojiDisplay.tsx b/src/components/Note/ReactionEmojiDisplay.tsx index 1276ebee..5e956a74 100644 --- a/src/components/Note/ReactionEmojiDisplay.tsx +++ b/src/components/Note/ReactionEmojiDisplay.tsx @@ -1,4 +1,5 @@ import Emoji from '@/components/Emoji' +import { ExtendedKind } from '@/constants' import { resolveReactionEmojiSync } from '@/lib/reaction-display' import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { cn } from '@/lib/utils' @@ -38,7 +39,8 @@ export default function ReactionEmojiDisplay({ }, [initial, event.id]) useEffect(() => { - if (sync.mode !== 'profile' || event.kind !== kinds.Reaction) return + if (sync.mode !== 'profile' || (event.kind !== kinds.Reaction && event.kind !== ExtendedKind.EXTERNAL_REACTION)) + return let cancelled = false replaceableEventService.fetchReplaceableEvent(event.pubkey, kinds.Metadata).then((pe) => { if (cancelled || !pe) return @@ -51,7 +53,10 @@ export default function ReactionEmojiDisplay({ } }, [event.pubkey, event.kind, sync]) - if (event.kind !== kinds.Reaction || (sync.mode === 'display' && sync.value === '')) { + if ( + (event.kind !== kinds.Reaction && event.kind !== ExtendedKind.EXTERNAL_REACTION) || + (sync.mode === 'display' && sync.value === '') + ) { return null } diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 65f14562..23de97a2 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -1,7 +1,7 @@ import { useSmartNoteNavigationOptional } from '@/PageManager' import { ExtendedKind } from '@/constants' import { isRenderableNoteKind } from '@/lib/note-renderable-kinds' -import { getHttpUrlFromITags, getParentBech32Id, isNsfwEvent } from '@/lib/event' +import { getHttpUrlFromITags, getParentBech32Id, isNip25ReactionKind, isNsfwEvent } from '@/lib/event' import { toNote } from '@/lib/link' import { cn } from '@/lib/utils' import { @@ -21,7 +21,7 @@ import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import { Event, kinds } from 'nostr-tools' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { isRssThreadSyntheticParentEvent } from '@/lib/rss-article' +import { getWebExternalReactionTargetUrl, isRssThreadSyntheticParentEvent } from '@/lib/rss-article' import { CreateHighlightContext } from './CreateHighlightContext' import SelectionHighlightTrigger from './SelectionHighlightTrigger' import AudioPlayer from '../AudioPlayer' @@ -101,6 +101,11 @@ export default function Note({ const [publicMessageTo, setPublicMessageTo] = useState(null) const [callInviteContent, setCallInviteContent] = useState(null) const reactionDisplay = useNotificationReactionDisplay(event) + const webReactionParentUrl = useMemo( + () => + event.kind === ExtendedKind.EXTERNAL_REACTION ? getWebExternalReactionTargetUrl(event) : undefined, + [event] + ) const openHighlight = useCallback((data: HighlightData, eventContent?: string) => { setHighlightData(data) @@ -145,7 +150,7 @@ export default function Note({ content = setShowMuted(true)} /> } else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) { content = setShowNsfw(true)} /> - } else if (event.kind === kinds.Reaction) { + } else if (isNip25ReactionKind(event.kind)) { content = null } else if (event.kind === kinds.Repost || event.kind === ExtendedKind.POLL_RESPONSE) { content = @@ -289,7 +294,7 @@ export default function Note({ >
- {event.kind === kinds.Reaction ? ( + {isNip25ReactionKind(event.kind) ? (
{reactionDisplay.status === 'pending' ? (
- {parentEventId && ( + {webReactionParentUrl ? ( +
+ +
+ ) : parentEventId ? ( - )} + ) : null} {wrappedContent}
diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index acb2d0ee..2db06fd5 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -1,4 +1,5 @@ import { useSmartNoteNavigation } from '@/PageManager' +import { ExtendedKind } from '@/constants' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { @@ -9,12 +10,13 @@ import { DISCUSSION_DOWNVOTE_DISPLAY, DISCUSSION_UPVOTE_DISPLAY } from '@/lib/discussion-votes' -import { isMentioningMutedUsers } from '@/lib/event' +import { isMentioningMutedUsers, isNip25ReactionKind } from '@/lib/event' +import { getWebExternalReactionTargetUrl } from '@/lib/rss-article' import { toNote } from '@/lib/link' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/contexts/mute-list-context' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { Event, kinds } from 'nostr-tools' +import { Event } from 'nostr-tools' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ClientTag from '../ClientTag' @@ -26,6 +28,7 @@ import Nip05 from '../Nip05' import NoteOptions from '../NoteOptions' import NoteStats from '../NoteStats' import ParentNotePreview from '../ParentNotePreview' +import WebPreview from '../WebPreview' import UserAvatar from '../UserAvatar' import Username from '../Username' @@ -49,6 +52,11 @@ export default function ReplyNote({ const { hideContentMentioningMutedUsers } = useContentPolicy() const [showMuted, setShowMuted] = useState(false) const reactionDisplay = useNotificationReactionDisplay(event) + const webReactionParentUrl = useMemo( + () => + event.kind === ExtendedKind.EXTERNAL_REACTION ? getWebExternalReactionTargetUrl(event) : undefined, + [event] + ) const show = useMemo(() => { if (showMuted) { return true @@ -106,7 +114,11 @@ export default function ReplyNote({
- {parentEventId && ( + {webReactionParentUrl ? ( +
+ +
+ ) : parentEventId ? ( - )} + ) : null} {show ? ( - event.kind === kinds.Reaction ? ( + isNip25ReactionKind(event.kind) ? (
{reactionDisplay.status === 'pending' ? ( @@ -152,7 +164,7 @@ export default function ReplyNote({
- {show && event.kind !== kinds.Reaction && ( + {show && !isNip25ReactionKind(event.kind) && ( { if (replyIdSet.has(evt.id)) return - if (evt.kind === kinds.Reaction) return + if (isNip25ReactionKind(evt.kind)) return if (mutePubkeySet.has(evt.pubkey)) { return } @@ -166,7 +167,7 @@ function ReplyNoteList({ // Prevent infinite loops by tracking processed event IDs const newParentEventKeys = events - .filter((evt) => evt.kind !== kinds.Reaction) + .filter((evt) => !isNip25ReactionKind(evt.kind)) .map((evt) => evt.id) .filter((id) => !processedEventIds.has(id)) diff --git a/src/constants.ts b/src/constants.ts index 5e5ae969..c29a9706 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -289,6 +289,8 @@ export const ExtendedKind = { RSS_FEED_LIST: 10895, /** Client-only synthetic "parent" for RSS article threads; never published to relays */ RSS_THREAD_ROOT: 99999, + /** NIP-25: reaction to external content (NIP-73 `k` + `i`), e.g. http(s) URLs */ + EXTERNAL_REACTION: 17, // NIP-89 Application Handlers APPLICATION_HANDLER_RECOMMENDATION: 31989, APPLICATION_HANDLER_INFO: 31990, diff --git a/src/hooks/useNotificationReactionDisplay.ts b/src/hooks/useNotificationReactionDisplay.ts index 07655367..3a0bb688 100644 --- a/src/hooks/useNotificationReactionDisplay.ts +++ b/src/hooks/useNotificationReactionDisplay.ts @@ -31,6 +31,10 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti ) useEffect(() => { + if (event.kind === ExtendedKind.EXTERNAL_REACTION) { + setState({ status: 'default' }) + return + } if (event.kind !== kinds.Reaction) { setState({ status: 'default' }) return diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index ed935b5f..36700b3b 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -24,7 +24,11 @@ import { isProtectedEvent, isReplaceableEvent } from './event' -import { canonicalizeRssArticleUrl, NIP22_URL_SCOPE_KIND } from '@/lib/rss-article' +import { + canonicalizeRssArticleUrl, + getArticleUrlFromCommentITags, + NIP22_URL_SCOPE_KIND +} from '@/lib/rss-article' import { cleanUrl } from '@/lib/url' import { randomString } from './random' import { generateBech32IdFromETag, tagNameEquals } from './tag' @@ -69,7 +73,30 @@ function generateDraftEventCacheKey(draft: Omit) { // https://github.com/nostr-protocol/nips/blob/master/25.md export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = '+'): TDraftEvent { + let content: string const tags: string[][] = [] + + if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { + const rawUrl = getArticleUrlFromCommentITags(event) + if (!rawUrl || (!rawUrl.startsWith('http://') && !rawUrl.startsWith('https://'))) { + throw new Error('RSS thread root is missing a valid http(s) article URL for reactions') + } + const canonical = canonicalizeRssArticleUrl(rawUrl) + tags.push(['k', NIP22_URL_SCOPE_KIND], ['i', canonical]) + if (typeof emoji === 'string') { + content = emoji + } else { + content = `:${emoji.shortcode}:` + tags.push(buildEmojiTag(emoji)) + } + return { + kind: ExtendedKind.EXTERNAL_REACTION, + content, + tags, + created_at: dayjs().unix() + } + } + tags.push(buildETag(event.id, event.pubkey)) tags.push(buildPTag(event.pubkey)) if (event.kind !== kinds.ShortTextNote) { @@ -80,7 +107,6 @@ export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = tags.push(buildATag(event)) } - let content: string if (typeof emoji === 'string') { content = emoji } else { diff --git a/src/lib/event.ts b/src/lib/event.ts index 18d3b442..aa487027 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -15,6 +15,11 @@ import { tagNameEquals } from './tag' +/** NIP-25: kind 7 (nostr target) or kind 17 (external / NIP-73 `k`+`i`). */ +export function isNip25ReactionKind(kind: number): boolean { + return kind === kinds.Reaction || kind === ExtendedKind.EXTERNAL_REACTION +} + const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache({ max: 10000 }) const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache({ max: 10000 }) const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache({ max: 10000 }) diff --git a/src/lib/note-renderable-kinds.ts b/src/lib/note-renderable-kinds.ts index 8fff0019..88d343f3 100644 --- a/src/lib/note-renderable-kinds.ts +++ b/src/lib/note-renderable-kinds.ts @@ -5,6 +5,7 @@ import { kinds } from 'nostr-tools' const RENDERABLE_NOTE_KINDS = new Set([ ...SUPPORTED_KINDS, kinds.Reaction, + ExtendedKind.EXTERNAL_REACTION, ExtendedKind.POLL_RESPONSE, kinds.CommunityDefinition, kinds.LiveEvent, diff --git a/src/lib/notification.ts b/src/lib/notification.ts index caa41809..1527dcc2 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -28,7 +28,7 @@ export function notificationFilter( return false } - if (pubkey && event.kind === kinds.Reaction) { + if (pubkey && (event.kind === kinds.Reaction || event.kind === ExtendedKind.EXTERNAL_REACTION)) { const targetPubkey = event.tags.findLast(tagNameEquals('p'))?.[1] if (!targetPubkey || !hexPubkeysEqual(targetPubkey, pubkey)) return false } diff --git a/src/lib/reaction-display.ts b/src/lib/reaction-display.ts index 5ffd0bb0..dbea88d9 100644 --- a/src/lib/reaction-display.ts +++ b/src/lib/reaction-display.ts @@ -1,7 +1,8 @@ import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' +import { isNip25ReactionKind } from '@/lib/event' import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { TEmoji } from '@/types' -import { Event, kinds } from 'nostr-tools' +import { Event } from 'nostr-tools' /** Whole-string :shortcode: (NIP-style); matches content-patterns rules. */ const WHOLE_SHORTCODE = /^:([a-zA-Z0-9_\-][^:]{0,19}):$/ @@ -15,7 +16,7 @@ export type TReactionEmojiSync = * or defer to profile (reactor kind 0) for custom shortcodes. */ export function resolveReactionEmojiSync(event: Event, maxRawLength: number): TReactionEmojiSync { - if (event.kind !== kinds.Reaction) { + if (!isNip25ReactionKind(event.kind)) { return { mode: 'display', value: '' } } diff --git a/src/lib/rss-article.ts b/src/lib/rss-article.ts index a54c2db4..04c581a4 100644 --- a/src/lib/rss-article.ts +++ b/src/lib/rss-article.ts @@ -1,11 +1,11 @@ +import { bytesToHex } from '@noble/hashes/utils' +import { sha256 } from '@noble/hashes/sha256' import { ExtendedKind } from '@/constants' import { cleanUrl } from '@/lib/url' +import type { Event } from 'nostr-tools' /** NIP-22: `K` / `k` value for http(s) URL comment scopes (web pages, articles). */ export const NIP22_URL_SCOPE_KIND = 'web' -import { bytesToHex } from '@noble/hashes/utils' -import { sha256 } from '@noble/hashes/sha256' -import type { Event } from 'nostr-tools' /** Encode article URL for a single path segment (UTF-8 → base64url, no padding). */ export function encodeRssArticlePathSegment(articleUrl: string): string { @@ -81,6 +81,30 @@ export function getArticleUrlFromCommentITags(event: Event): string | undefined return event.tags.find((t) => t[0] === 'i')?.[1] } +/** + * NIP-25 kind 17 + NIP-73: resolve http(s) target URL for a `k: web` external reaction. + * Stops at the next `k` tag so podcast-style multi-scope reactions are not mis-parsed as web. + */ +export function getWebExternalReactionTargetUrl(event: Pick): string | undefined { + if (event.kind !== ExtendedKind.EXTERNAL_REACTION) return undefined + const tags = event.tags + for (let i = 0; i < tags.length; i++) { + const row = tags[i] + if (row[0] !== 'k' || row[1] !== NIP22_URL_SCOPE_KIND) continue + for (let j = i + 1; j < tags.length; j++) { + const t = tags[j] + if (t[0] === 'k') break + if (t[0] === 'i' && t[1]) { + const url = t[1] + if (url.startsWith('http://') || url.startsWith('https://')) { + return canonicalizeRssArticleUrl(url) + } + } + } + } + return undefined +} + /** Client-only RSS thread parent (non-standard kind); not a real relay event. */ export function isRssThreadSyntheticParentEvent(event: Pick): boolean { return event.kind === ExtendedKind.RSS_THREAD_ROOT diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index c06d2d08..231781cc 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -57,6 +57,7 @@ export const NOTIFICATION_SPELL_KINDS = [ kinds.ShortTextNote, kinds.Repost, kinds.Reaction, + ExtendedKind.EXTERNAL_REACTION, kinds.Zap, ExtendedKind.COMMENT, ExtendedKind.POLL_RESPONSE, diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 63125c85..8bad8929 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -188,6 +188,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: return 'Note: Boost' case 7: // kinds.Reaction return 'Note: Reaction' + case 17: // ExtendedKind.EXTERNAL_REACTION (NIP-25 external) + return 'Note: Reaction' case 1111: // ExtendedKind.COMMENT return 'Note: Comment' case 1222: // ExtendedKind.VOICE diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 63bd988e..5c239175 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -731,7 +731,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const events = await queryService.fetchEvents(relayList.write.slice(0, 4), [ { authors: [pubkey], - kinds: [kinds.Reaction, kinds.Repost], + kinds: [kinds.Reaction, ExtendedKind.EXTERNAL_REACTION, kinds.Repost], limit: 100 }, { diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index afc9e5b7..8812ca0b 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -4,7 +4,8 @@ import { getParentETag, getQuotedEventHexIdFromQTags, getRootATag, - getRootETag + getRootETag, + isNip25ReactionKind } from '@/lib/event' import { Event, kinds } from 'nostr-tools' import { createContext, useCallback, useContext, useState } from 'react' @@ -34,7 +35,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) { const newReplyEventMap = new Map() replies.forEach((reply) => { if (newReplyIdSet.has(reply.id)) return - if (reply.kind === kinds.Reaction) return + if (isNip25ReactionKind(reply.kind)) return newReplyIdSet.add(reply.id) let rootId: string | undefined diff --git a/src/services/discussion-feed-cache.service.ts b/src/services/discussion-feed-cache.service.ts index d60618fd..1889c5f6 100644 --- a/src/services/discussion-feed-cache.service.ts +++ b/src/services/discussion-feed-cache.service.ts @@ -1,4 +1,5 @@ -import { Event as NEvent, kinds } from 'nostr-tools' +import { isNip25ReactionKind } from '@/lib/event' +import { Event as NEvent } from 'nostr-tools' import logger from '@/lib/logger' interface CachedThreadData { @@ -94,7 +95,7 @@ class DiscussionFeedCacheService { logger.debug('[DiscussionFeedCache] Cache hit (fresh) for thread:', cacheKey, 'replies:', cachedData.replies.length) } - return cachedData.replies.filter((r) => r.kind !== kinds.Reaction) + return cachedData.replies.filter((r) => !isNip25ReactionKind(r.kind)) } /** @@ -135,12 +136,12 @@ class DiscussionFeedCacheService { const existingReplyIds = new Set(existingData.replies.map(r => r.id)) const newReplies = replies.filter(r => !existingReplyIds.has(r.id)) mergedReplies = [...existingData.replies, ...newReplies].filter( - (r) => r.kind !== kinds.Reaction + (r) => !isNip25ReactionKind(r.kind) ) logger.debug('[DiscussionFeedCache] Merged replies for thread:', cacheKey, 'existing:', existingData.replies.length, 'new:', newReplies.length, 'total:', mergedReplies.length) } else { // No existing cache or rootInfo mismatch, use new replies - mergedReplies = replies.filter((r) => r.kind !== kinds.Reaction) + mergedReplies = replies.filter((r) => !isNip25ReactionKind(r.kind)) logger.debug('[DiscussionFeedCache] Cached new replies for thread:', cacheKey, 'replies:', replies.length) } diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 827701f4..8892ccbc 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -8,6 +8,12 @@ import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getZapInfoFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' +import { + canonicalizeRssArticleUrl, + getArticleUrlFromCommentITags, + getWebExternalReactionTargetUrl, + rssArticleStableEventId +} from '@/lib/rss-article' import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { normalizeUrl } from '@/lib/url' import client, { eventService } from '@/services/client.service' @@ -276,6 +282,18 @@ class NoteStatsService { } ] + if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { + const url = getArticleUrlFromCommentITags(event) + if (url && (url.startsWith('http://') || url.startsWith('https://'))) { + const canonical = canonicalizeRssArticleUrl(url) + filters.push({ + '#i': [canonical], + kinds: [ExtendedKind.EXTERNAL_REACTION], + limit: reactionLimit + }) + } + } + if (replaceableCoordinate) { filters.push( { @@ -392,6 +410,12 @@ class NoteStatsService { if (evt.kind === kinds.Reaction) { updatedEventId = this.addLikeByEvent(evt, originalEventAuthor, mergeOpts?.interactionTargetNoteId) + } else if (evt.kind === ExtendedKind.EXTERNAL_REACTION) { + updatedEventId = this.addLikeByExternalWebReactionEvent( + evt, + originalEventAuthor, + mergeOpts?.interactionTargetNoteId + ) } else if (evt.kind === kinds.Repost) { updatedEventId = this.addRepostByEvent(evt, originalEventAuthor, mergeOpts?.interactionTargetNoteId) } else if (evt.kind === kinds.Zap) { @@ -412,19 +436,7 @@ class NoteStatsService { return updatedEventId } - private addLikeByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) { - const targetEventId = forcedTargetEventId ?? getFirstHexEventIdFromETags(evt.tags) - if (!targetEventId) return - - const old = this.noteStatsMap.get(targetEventId) || {} - const likeIdSet = old.likeIdSet || new Set() - const likes = old.likes || [] - if (likeIdSet.has(evt.id)) return - - if (originalEventAuthor && originalEventAuthor === evt.pubkey) { - return - } - + private reactionEmojiFromEvent(evt: Event): TEmoji | string { let emoji: TEmoji | string = evt.content.trim() if (!emoji) { const fromTags = getEmojiInfosFromEmojiTags(evt.tags) @@ -451,6 +463,53 @@ class NoteStatsService { } } + return emoji + } + + private addLikeByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) { + const targetEventId = forcedTargetEventId ?? getFirstHexEventIdFromETags(evt.tags) + if (!targetEventId) return + + const old = this.noteStatsMap.get(targetEventId) || {} + const likeIdSet = old.likeIdSet || new Set() + const likes = old.likes || [] + if (likeIdSet.has(evt.id)) return + + if (originalEventAuthor && originalEventAuthor === evt.pubkey) { + return + } + + const emoji = this.reactionEmojiFromEvent(evt) + + likeIdSet.add(evt.id) + likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji }) + this.noteStatsMap.set(targetEventId, { ...old, likeIdSet, likes }) + return targetEventId + } + + /** NIP-25 kind 17 reactions to http(s) URLs; stats key matches synthetic RSS thread root id. */ + private addLikeByExternalWebReactionEvent( + evt: Event, + originalEventAuthor?: string, + forcedTargetEventId?: string + ) { + const url = getWebExternalReactionTargetUrl(evt) + if (!url) return + + const targetEventId = + forcedTargetEventId ?? rssArticleStableEventId(canonicalizeRssArticleUrl(url)) + + const old = this.noteStatsMap.get(targetEventId) || {} + const likeIdSet = old.likeIdSet || new Set() + const likes = old.likes || [] + if (likeIdSet.has(evt.id)) return + + if (originalEventAuthor && originalEventAuthor === evt.pubkey) { + return + } + + const emoji = this.reactionEmojiFromEvent(evt) + likeIdSet.add(evt.id) likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji }) this.noteStatsMap.set(targetEventId, { ...old, likeIdSet, likes })