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