Browse Source

implement kind 17 reactions on external/web content

imwald
Silberengel 1 month ago
parent
commit
879d05c0ac
  1. 4
      src/components/ContentPreview/index.tsx
  2. 9
      src/components/Note/ReactionEmojiDisplay.tsx
  3. 21
      src/components/Note/index.tsx
  4. 24
      src/components/ReplyNote/index.tsx
  5. 5
      src/components/ReplyNoteList/index.tsx
  6. 2
      src/constants.ts
  7. 4
      src/hooks/useNotificationReactionDisplay.ts
  8. 30
      src/lib/draft-event.ts
  9. 5
      src/lib/event.ts
  10. 1
      src/lib/note-renderable-kinds.ts
  11. 2
      src/lib/notification.ts
  12. 5
      src/lib/reaction-display.ts
  13. 30
      src/lib/rss-article.ts
  14. 1
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  15. 2
      src/pages/secondary/NotePage/index.tsx
  16. 2
      src/providers/NostrProvider/index.tsx
  17. 5
      src/providers/ReplyProvider.tsx
  18. 9
      src/services/discussion-feed-cache.service.ts
  19. 85
      src/services/note-stats.service.ts

4
src/components/ContentPreview/index.tsx

@ -4,7 +4,7 @@ import { @@ -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({ @@ -150,7 +150,7 @@ export default function ContentPreview({
return <FollowPackPreview event={event} className={className} />
}
if (event.kind === kinds.Reaction) {
if (isNip25ReactionKind(event.kind)) {
return (
<div className={cn('pointer-events-none flex items-center gap-1.5 text-sm text-muted-foreground', className)}>
{reactionDisplay.status === 'pending' ? (

9
src/components/Note/ReactionEmojiDisplay.tsx

@ -1,4 +1,5 @@ @@ -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({ @@ -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({ @@ -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
}

21
src/components/Note/index.tsx

@ -1,7 +1,7 @@ @@ -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' @@ -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({ @@ -101,6 +101,11 @@ export default function Note({
const [publicMessageTo, setPublicMessageTo] = useState<string | null>(null)
const [callInviteContent, setCallInviteContent] = useState<string | null>(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({ @@ -145,7 +150,7 @@ export default function Note({
content = <MutedNote show={() => setShowMuted(true)} />
} else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) {
content = <NsfwNote show={() => 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 = <NotificationEventCard className="mt-2" event={event} />
@ -289,7 +294,7 @@ export default function Note({ @@ -289,7 +294,7 @@ export default function Note({
>
<div className="flex justify-between items-start gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{event.kind === kinds.Reaction ? (
{isNip25ReactionKind(event.kind) ? (
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2">
{reactionDisplay.status === 'pending' ? (
<Skeleton
@ -424,7 +429,11 @@ export default function Note({ @@ -424,7 +429,11 @@ export default function Note({
)}
</div>
</div>
{parentEventId && (
{webReactionParentUrl ? (
<div className="mt-2 not-prose max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" />
</div>
) : parentEventId ? (
<ParentNotePreview
eventId={parentEventId}
className="mt-2"
@ -433,7 +442,7 @@ export default function Note({ @@ -433,7 +442,7 @@ export default function Note({
navigateToNote(toNote(parentEventId))
}}
/>
)}
) : null}
<IValue event={event} className="mt-2" />
{wrappedContent}
</div>

24
src/components/ReplyNote/index.tsx

@ -1,4 +1,5 @@ @@ -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 { @@ -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' @@ -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({ @@ -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({ @@ -106,7 +114,11 @@ export default function ReplyNote({
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div>
</div>
{parentEventId && (
{webReactionParentUrl ? (
<div className="mt-2 not-prose max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" />
</div>
) : parentEventId ? (
<ParentNotePreview
className="mt-2"
eventId={parentEventId}
@ -115,9 +127,9 @@ export default function ReplyNote({ @@ -115,9 +127,9 @@ export default function ReplyNote({
onClickParent()
}}
/>
)}
) : null}
{show ? (
event.kind === kinds.Reaction ? (
isNip25ReactionKind(event.kind) ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{reactionDisplay.status === 'pending' ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
@ -152,7 +164,7 @@ export default function ReplyNote({ @@ -152,7 +164,7 @@ export default function ReplyNote({
</div>
</div>
</Collapsible>
{show && event.kind !== kinds.Reaction && (
{show && !isNip25ReactionKind(event.kind) && (
<NoteStats
className="ml-14 pl-1 mr-4 mt-2"
event={event}

5
src/components/ReplyNoteList/index.tsx

@ -9,6 +9,7 @@ import { @@ -9,6 +9,7 @@ import {
getRootETag,
getRootEventHexId,
isMentioningMutedUsers,
isNip25ReactionKind,
isReplaceableEvent,
isReplyNoteEvent
} from '@/lib/event'
@ -152,7 +153,7 @@ function ReplyNoteList({ @@ -152,7 +153,7 @@ function ReplyNoteList({
events.forEach((evt) => {
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({ @@ -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))

2
src/constants.ts

@ -289,6 +289,8 @@ export const ExtendedKind = { @@ -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,

4
src/hooks/useNotificationReactionDisplay.ts

@ -31,6 +31,10 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti @@ -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

30
src/lib/draft-event.ts

@ -24,7 +24,11 @@ import { @@ -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<TDraftEvent, 'created_at'>) { @@ -69,7 +73,30 @@ function generateDraftEventCacheKey(draft: Omit<TDraftEvent, 'created_at'>) {
// 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 = @@ -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 {

5
src/lib/event.ts

@ -15,6 +15,11 @@ import { @@ -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<string, string[]>({ max: 10000 })
const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 })
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ max: 10000 })

1
src/lib/note-renderable-kinds.ts

@ -5,6 +5,7 @@ import { kinds } from 'nostr-tools' @@ -5,6 +5,7 @@ import { kinds } from 'nostr-tools'
const RENDERABLE_NOTE_KINDS = new Set<number>([
...SUPPORTED_KINDS,
kinds.Reaction,
ExtendedKind.EXTERNAL_REACTION,
ExtendedKind.POLL_RESPONSE,
kinds.CommunityDefinition,
kinds.LiveEvent,

2
src/lib/notification.ts

@ -28,7 +28,7 @@ export function notificationFilter( @@ -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
}

5
src/lib/reaction-display.ts

@ -1,7 +1,8 @@ @@ -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 = @@ -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: '' }
}

30
src/lib/rss-article.ts

@ -1,11 +1,11 @@ @@ -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 @@ -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<Event, 'kind' | 'tags'>): 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<Event, 'kind'>): boolean {
return event.kind === ExtendedKind.RSS_THREAD_ROOT

1
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -57,6 +57,7 @@ export const NOTIFICATION_SPELL_KINDS = [ @@ -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,

2
src/pages/secondary/NotePage/index.tsx

@ -188,6 +188,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -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

2
src/providers/NostrProvider/index.tsx

@ -731,7 +731,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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
},
{

5
src/providers/ReplyProvider.tsx

@ -4,7 +4,8 @@ import { @@ -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 }) { @@ -34,7 +35,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
const newReplyEventMap = new Map<string, Event[]>()
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

9
src/services/discussion-feed-cache.service.ts

@ -1,4 +1,5 @@ @@ -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 { @@ -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 { @@ -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)
}

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

@ -8,6 +8,12 @@ import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 })

Loading…
Cancel
Save