diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 3b42acf2..2b0dd23d 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -239,6 +239,7 @@ function EmbeddedNoteContent({ > diff --git a/src/components/Note/Poll.tsx b/src/components/Note/Poll.tsx index 0c8f4b09..217a524c 100644 --- a/src/components/Note/Poll.tsx +++ b/src/components/Note/Poll.tsx @@ -24,6 +24,8 @@ export default function Poll({ event, className }: { event: Event; className?: s const startLogin = nostr?.startLogin ?? (() => {}) const [isVoting, setIsVoting] = useState(false) const [selectedOptionIds, setSelectedOptionIds] = useState([]) + /** User chose to view vote breakdown without voting first (card UX). */ + const [resultsRevealed, setResultsRevealed] = useState(false) const pollResults = useFetchPollResults(event.id) const [isLoadingResults, setIsLoadingResults] = useState(false) const poll = useMemo(() => getPollMetadataFromEvent(event), [event]) @@ -38,8 +40,8 @@ export default function Poll({ event, className }: { event: Event; className?: s const isMultipleChoice = useMemo(() => poll?.pollType === POLL_TYPE.MULTIPLE_CHOICE, [poll]) const canVote = useMemo(() => !isExpired && !votedOptionIds.length, [isExpired, votedOptionIds]) const showResults = useMemo(() => { - return event.pubkey === pubkey || !canVote - }, [event, pubkey, canVote]) + return resultsRevealed || event.pubkey === pubkey || !canVote + }, [resultsRevealed, event.pubkey, pubkey, canVote]) const [containerElement, setContainerElement] = useState(null) useEffect(() => { @@ -228,6 +230,22 @@ export default function Poll({ event, className }: { event: Event; className?: s })} + {canVote && !resultsRevealed && ( + + )} + {/* Results Summary */}
{t('{{number}} votes', { number: pollResults?.totalVotes ?? 0 })}
diff --git a/src/components/Note/UnknownNote.tsx b/src/components/Note/UnknownNote.tsx index 6dcd123b..1db089eb 100644 --- a/src/components/Note/UnknownNote.tsx +++ b/src/components/Note/UnknownNote.tsx @@ -11,16 +11,132 @@ import EventViewer from './EventViewer' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { ChevronDown, ChevronRight } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import PubkeyCopy from '@/components/PubkeyCopy' +import UserAvatar from '@/components/UserAvatar' +import Username from '@/components/Username' +import { hexPubkeysEqual, isValidPubkey, userIdToPubkey } from '@/lib/pubkey' const CONTENT_PREVIEW_MAX = 800 +/** Tag names we render in structured sections (hidden from the flat tag list). */ +const ELEVATED_TAG_NAMES = new Set([ + 'title', + 't', + 'summary', + 'description', + 'image', + 'thumb', + 'banner', + 'content', + 'kind', + 'pubkey' +]) + function truncatePreview(text: string, max: number): string { const t = text.trim() if (t.length <= max) return t return `${t.slice(0, max).trimEnd()}…` } -export default function UnknownNote({ event, className }: { event: Event; className?: string }) { +function normText(s: string): string { + return s.trim().replace(/\s+/g, ' ') +} + +function joinTagRest(tag: string[]): string { + return tag.slice(1).join(' ').trim() +} + +function isHttpUrl(s: string): boolean { + return /^https?:\/\//i.test(s.trim()) +} + +type ElevatedTags = { + title?: string + topics: string[] + summary?: string + description?: string + imageUrls: string[] + tagContent?: string + declaredKind?: string + taggedPubkey?: string +} + +function extractElevatedTags(tags: string[][]): ElevatedTags { + let title: string | undefined + const topics: string[] = [] + const summaryParts: string[] = [] + const descriptionParts: string[] = [] + const imageUrls: string[] = [] + const contentParts: string[] = [] + let declaredKind: string | undefined + let taggedPubkey: string | undefined + + for (const tag of tags) { + const name = tag[0] + const rest = tag.slice(1) + if (name === 't') { + const v = rest[0]?.trim() + if (v) topics.push(v) + continue + } + if (name === 'title' && rest.length) { + const j = joinTagRest(tag) + if (j) title = title ? `${title} ${j}` : j + continue + } + if (name === 'summary' && rest.length) { + summaryParts.push(joinTagRest(tag)) + continue + } + if (name === 'description' && rest.length) { + descriptionParts.push(joinTagRest(tag)) + continue + } + if ((name === 'image' || name === 'thumb' || name === 'banner') && rest.length) { + const u = rest[0].trim() + if (isHttpUrl(u) && !imageUrls.includes(u)) imageUrls.push(u) + continue + } + if (name === 'content' && rest.length) { + const j = joinTagRest(tag) + if (j) contentParts.push(j) + continue + } + if (name === 'kind' && rest.length && !declaredKind) { + declaredKind = joinTagRest(tag) + continue + } + if (name === 'pubkey' && rest.length && !taggedPubkey) { + const raw = rest[0].trim() + const pk = userIdToPubkey(raw) + if (isValidPubkey(pk)) taggedPubkey = pk.toLowerCase() + continue + } + } + + return { + title, + topics, + summary: summaryParts.length ? summaryParts.join('\n') : undefined, + description: descriptionParts.length ? descriptionParts.join('\n') : undefined, + imageUrls, + tagContent: contentParts.length ? contentParts.join('\n') : undefined, + declaredKind, + taggedPubkey + } +} + +export default function UnknownNote({ + event, + className, + showAuthorSummary +}: { + event: Event + className?: string + /** When the parent does not render an author header (e.g. embedded unsupported notes). */ + showAuthorSummary?: boolean +}) { const { t } = useTranslation() const [technicalOpen, setTechnicalOpen] = useState(false) const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) @@ -44,6 +160,44 @@ export default function UnknownNote({ event, className }: { event: Event; classN const kindLabel = getKindDescription(event.kind) const contentRaw = event.content?.trim() ?? '' + const elevated = useMemo(() => extractElevatedTags(event.tags), [event.tags]) + const remainderTags = useMemo( + () => event.tags.filter(tag => tag[0] && !ELEVATED_TAG_NAMES.has(tag[0])), + [event.tags] + ) + + const headline = elevated.title?.trim() || kindLabel.description + const showKindAsSubtitle = !!elevated.title?.trim() + + const contentNorm = contentRaw ? normText(contentRaw) : '' + const elevatedBlocksNorm = [elevated.summary, elevated.description, elevated.tagContent] + .filter(Boolean) + .map(s => normText(s!)) + const showMainContent = + !!contentRaw && + !elevatedBlocksNorm.some(b => b === contentNorm) && + !(elevated.title && normText(elevated.title) === contentNorm) + + const declaredKindTrimmed = elevated.declaredKind?.trim() + const showDeclaredKindTag = + !!declaredKindTrimmed && declaredKindTrimmed !== String(event.kind) + + const showTaggedPubkey = + !!elevated.taggedPubkey && + isValidPubkey(elevated.taggedPubkey) && + (!isValidPubkey(event.pubkey) || !hexPubkeysEqual(elevated.taggedPubkey, event.pubkey)) + + const hasAnyElevatedCopy = + !!elevated.summary || + !!elevated.description || + !!elevated.tagContent || + elevated.imageUrls.length > 0 + + const showNoTextPlaceholder = + !contentRaw && !hasAnyElevatedCopy && !isBookstrEvent + + const proseClass = 'text-sm leading-relaxed whitespace-pre-wrap break-words text-foreground/95' + return (
{t('Unsupported event preview')}

+ + {showAuthorSummary && isValidPubkey(event.pubkey) ? ( +
+ + +
+ ) : null} +
-

- {kindLabel.description} -

-

- {t('Event kind label', { kind: event.kind })} +

{headline}

+

+ {showKindAsSubtitle ? ( + {kindLabel.description} + ) : null} + {showKindAsSubtitle ? · : null} + {t('Event kind label', { kind: event.kind })}

+ {showDeclaredKindTag ? ( +

{t('Unknown note declared kind tag', { value: declaredKindTrimmed })}

+ ) : null}
+ {showTaggedPubkey ? ( +
+ + {t('Unknown note tagged pubkey')} + + +
+ ) : null} + + {elevated.topics.length > 0 ? ( +
+

+ {t('Topics')} +

+
+ {elevated.topics.map((topic, i) => ( + + {topic} + + ))} +
+
+ ) : null} + + {elevated.imageUrls.length > 0 ? ( +
+ {elevated.imageUrls.slice(0, 4).map((url, i) => ( + + ))} +
+ ) : null} + + {elevated.summary ? ( +
+

+ {t('Summary')} +

+

{truncatePreview(elevated.summary, CONTENT_PREVIEW_MAX)}

+
+ ) : null} + + {elevated.description ? ( +
+

+ {t('Description')} +

+

{truncatePreview(elevated.description, CONTENT_PREVIEW_MAX)}

+
+ ) : null} + + {elevated.tagContent && normText(elevated.tagContent) !== contentNorm ? ( +
+

+ {t('Unknown note tagged content')} +

+

{truncatePreview(elevated.tagContent, CONTENT_PREVIEW_MAX)}

+
+ ) : null} + {isBookstrEvent && (
{bookMetadata.type && {t('Type')}: {bookMetadata.type}} @@ -74,21 +311,21 @@ export default function UnknownNote({ event, className }: { event: Event; classN
)} - {contentRaw ? ( -

- {truncatePreview(contentRaw, CONTENT_PREVIEW_MAX)} -

- ) : ( + {showMainContent ? ( +

{truncatePreview(contentRaw, CONTENT_PREVIEW_MAX)}

+ ) : null} + + {showNoTextPlaceholder ? (

{t('No text content in event')}

- )} + ) : null} - {event.tags.length > 0 ? ( + {remainderTags.length > 0 ? (

{t('Tags')}

    - {event.tags.map((tag, i) => ( + {remainderTags.map((tag, i) => (
  • {tag[0]} diff --git a/src/components/Note/ZapPoll.tsx b/src/components/Note/ZapPoll.tsx index 5b452ac1..849742d9 100644 --- a/src/components/Note/ZapPoll.tsx +++ b/src/components/Note/ZapPoll.tsx @@ -45,6 +45,8 @@ export default function ZapPoll({ const [optionIndex, setOptionIndex] = useState(null) const [sats, setSats] = useState(21) const [zapping, setZapping] = useState(false) + /** Show sat/zap breakdown without having voted (card UX). */ + const [tallyRevealed, setTallyRevealed] = useState(false) useEffect(() => { if (meta?.valueMinimum != null) { @@ -62,7 +64,8 @@ export default function ZapPoll({ const myVoteOption = pubkey && meta ? userZapPollVoteOption(event.id, pubkey, receipts) : undefined - const showTally = !!meta && (closed || viewerZapped || event.pubkey === pubkey) + const showTally = + !!meta && (closed || viewerZapped || event.pubkey === pubkey || tallyRevealed) const satsBounds = useMemo(() => { if (!meta) return { min: 1, max: undefined as number | undefined } @@ -151,6 +154,21 @@ export default function ZapPoll({

    {t('Loading tally…')}

    )} {error &&

    {error}

    } + {meta && !closed && !showTally && ( + + )}
    {meta.options.map((opt) => { const satsOpt = tally?.satsByOption.get(opt.index) ?? 0 diff --git a/src/components/RssUrlThreadEventsPreview/index.tsx b/src/components/RssUrlThreadEventsPreview/index.tsx index ab5ba036..3b764b32 100644 --- a/src/components/RssUrlThreadEventsPreview/index.tsx +++ b/src/components/RssUrlThreadEventsPreview/index.tsx @@ -3,7 +3,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { - buildRssArticleUrlThreadInteractionFilters, + buildRssArticleUrlThreadInteractionFilterGroups, isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' import { queryService } from '@/services/client.service' @@ -28,14 +28,25 @@ export default function RssUrlThreadEventsPreview({ canonicalUrl }: { canonicalU useEffect(() => { let cancelled = false setLoading(true) - const filters = buildRssArticleUrlThreadInteractionFilters(canonicalUrl, FETCH_LIMIT) - void queryService - .fetchEvents(relayUrls, filters) - .then((all) => { + const { nonSocial, social } = buildRssArticleUrlThreadInteractionFilterGroups( + canonicalUrl, + FETCH_LIMIT + ) + const fetchOpts = { + eoseTimeout: 12_000, + globalTimeout: 26_000, + firstRelayResultGraceMs: false as const + } + void Promise.all([ + nonSocial.length > 0 ? queryService.fetchEvents(relayUrls, nonSocial, fetchOpts) : Promise.resolve([]), + social.length > 0 ? queryService.fetchEvents(relayUrls, social, fetchOpts) : Promise.resolve([]) + ]) + .then(([a, b]) => { if (cancelled) return + const all = [...a, ...b] const seen = new Set() const merged: Event[] = [] - for (const e of [...all].sort((a, b) => b.created_at - a.created_at)) { + for (const e of [...all].sort((x, y) => y.created_at - x.created_at)) { if (seen.has(e.id)) continue if (!isRssArticleUrlThreadInteraction(e, canonicalUrl)) continue seen.add(e.id) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a931da59..e9dbdae9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -396,6 +396,9 @@ export default { 'Technical details': 'Technical details', 'Event kind and time': 'Kind {{kind}} · {{time}}', 'Event kind label': 'Kind {{kind}}', + 'Unknown note declared kind tag': 'Tagged kind: {{value}}', + 'Unknown note tagged pubkey': 'Tagged pubkey', + 'Unknown note tagged content': 'Content', 'Copy JSON': 'Copy JSON', Verse: 'Verse', 'Notification reaction summary': 'reacted to this note.', @@ -638,6 +641,7 @@ export default { 'Relay URLs (optional, comma-separated)': 'Relay URLs (optional, comma-separated)', 'Remove poll': 'Remove poll', 'Refresh results': 'Refresh results', + 'See results': 'See results', 'Zap poll (paid votes)': 'Zap poll (paid votes)', 'Invalid zap poll': 'Invalid zap poll', 'You voted on this poll (zap receipt)': 'You voted on this poll (zap receipt)', diff --git a/src/lib/rss-article.ts b/src/lib/rss-article.ts index df2609dc..73ed3dc1 100644 --- a/src/lib/rss-article.ts +++ b/src/lib/rss-article.ts @@ -2,7 +2,7 @@ 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' +import { kinds, 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' @@ -113,6 +113,12 @@ export function getHighlightSourceHttpUrl(event: Pick): string | return undefined } +/** NIP-73: kind 7 reaction targeting an http(s) page via `r` tags (same disambiguation as highlights). */ +export function getReactionPageUrlFromRTags(event: Pick): string | undefined { + if (event.kind !== kinds.Reaction) return undefined + return getHighlightSourceHttpUrl(event) +} + /** * Values for a REQ `#r` filter on kind 9802 when the thread key is a canonical article URL. * Relay matching is exact on the tag string, so we include common variants (slash, stripped query). diff --git a/src/lib/rss-web-feed.ts b/src/lib/rss-web-feed.ts index 1381c615..ed51a3c0 100644 --- a/src/lib/rss-web-feed.ts +++ b/src/lib/rss-web-feed.ts @@ -7,6 +7,7 @@ import { computeRTagFilterValuesForArticleThread, getArticleUrlFromCommentITags, getHighlightSourceHttpUrl, + getReactionPageUrlFromRTags, getWebBookmarkArticleUrl, getWebExternalReactionTargetUrl } from '@/lib/rss-article' @@ -173,30 +174,60 @@ export function isRssWebUnifiedClutterUrl(url: string): boolean { return false } -/** REQ filters for Nostr comments, voice comments, and highlights on one article URL (synthetic RSS thread). */ -export function buildRssArticleUrlThreadInteractionFilters( +/** + * Split filters: kind 1/1111 in `social` strip aggregator relays from the whole REQ; reactions and + * `#r` queries stay in `nonSocial` so aggr and similar still answer. + */ +export function buildRssArticleUrlThreadInteractionFilterGroups( canonicalArticleUrl: string, limit: number -): Filter[] { +): { nonSocial: Filter[]; social: Filter[] } { const canonical = canonicalizeRssArticleUrl(canonicalArticleUrl) const rVals = computeRTagFilterValuesForArticleThread(canonical) - const filters: Filter[] = [ + const social: Filter[] = [ { '#i': [canonical], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit }, { '#I': [canonical], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit } ] + const nonSocial: Filter[] = [ + { '#i': [canonical], kinds: [ExtendedKind.EXTERNAL_REACTION], limit }, + { '#I': [canonical], kinds: [ExtendedKind.EXTERNAL_REACTION], limit } + ] if (rVals.length > 0) { - filters.push({ '#r': rVals, kinds: [kinds.Highlights], limit }) + nonSocial.push( + { '#r': rVals, kinds: [kinds.Highlights], limit }, + { '#r': rVals, kinds: [kinds.Reaction], limit } + ) } - return filters + return { nonSocial, social } +} + +/** REQ filters for Nostr comments, reactions, and highlights on one article URL (synthetic RSS thread). */ +export function buildRssArticleUrlThreadInteractionFilters( + canonicalArticleUrl: string, + limit: number +): Filter[] { + const { nonSocial, social } = buildRssArticleUrlThreadInteractionFilterGroups( + canonicalArticleUrl, + limit + ) + return [...nonSocial, ...social] } -/** Whether `evt` belongs to the URL-scoped article thread (comments / voice / highlight of this page). */ +/** Whether `evt` belongs to the URL-scoped article thread (comments / voice / highlight / reactions on this page). */ export function isRssArticleUrlThreadInteraction(evt: Event, canonicalArticleUrl: string): boolean { const key = canonicalizeRssArticleUrl(canonicalArticleUrl) if (evt.kind === kinds.Highlights) { const hu = getHighlightSourceHttpUrl(evt) return !!hu && canonicalizeRssArticleUrl(hu) === key } + if (evt.kind === ExtendedKind.EXTERNAL_REACTION) { + const u = getWebExternalReactionTargetUrl(evt) + return !!u && canonicalizeRssArticleUrl(u) === key + } + if (evt.kind === kinds.Reaction) { + const u = getReactionPageUrlFromRTags(evt) + return !!u && canonicalizeRssArticleUrl(u) === key + } if (!isReplyNoteEvent(evt)) return false const u = getArticleUrlFromCommentITags(evt) return !!u && canonicalizeRssArticleUrl(u) === key @@ -352,11 +383,12 @@ export async function buildRssWebNostrQueryRelayUrls(options: { return dedupeRelayUrlsForRssWeb([...inboxAndFavorites, ...FAST_READ_RELAY_URLS]) } -/** Kinds 1111, 17, 9802, 1244, 39701 — one REQ each in {@link fetchDiscoveredWebUrlsFromRelays}. */ +/** One REQ per kind in {@link fetchDiscoveredWebUrlsFromRelays} (includes kind 7 with page `r` tags). */ const RSS_WEB_RELAY_DISCOVERY_KINDS: number[] = [ ExtendedKind.COMMENT, ExtendedKind.EXTERNAL_REACTION, kinds.Highlights, + kinds.Reaction, ExtendedKind.VOICE_COMMENT, ExtendedKind.WEB_BOOKMARK ] @@ -367,6 +399,10 @@ function extractArticleUrlFromWebActivityEvent(evt: Event): string | undefined { if (!u || !isHttpArticleUrl(u)) return undefined return canonicalizeRssArticleUrl(u) } + if (evt.kind === kinds.Reaction) { + const u = getReactionPageUrlFromRTags(evt) + return u && isHttpArticleUrl(u) ? canonicalizeRssArticleUrl(u) : undefined + } if (evt.kind === ExtendedKind.EXTERNAL_REACTION) { const u = getWebExternalReactionTargetUrl(evt) return u && isHttpArticleUrl(u) ? canonicalizeRssArticleUrl(u) : undefined diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 94cc8ee1..0d16a055 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -13,6 +13,7 @@ import { computeRTagFilterValuesForArticleThread, getArticleUrlFromCommentITags, getHighlightSourceHttpUrl, + getReactionPageUrlFromRTags, getWebExternalReactionTargetUrl, rssArticleStableEventId } from '@/lib/rss-article' @@ -179,18 +180,33 @@ class NoteStatsService { ? getReplaceableCoordinateFromEvent(event) : undefined - const filters: Filter[] = this.buildFilters(event, replaceableCoordinate) + const { nonSocial, social } = this.buildFilterGroups(event, replaceableCoordinate) + const fetchOpts = { + eoseTimeout: 10_000, + globalTimeout: 28_000, + firstRelayResultGraceMs: false as const + } const events: Event[] = [] logger.debug('[NoteStats] Fetching stats for event', event.id.substring(0, 8), 'from', finalRelayUrls.length, 'relays') - + const { queryService } = await import('@/services/client.service') - await queryService.fetchEvents(finalRelayUrls, filters, { - onevent: (evt) => { - this.updateNoteStatsByEvents([evt], event.pubkey) - events.push(evt) - } - }) + const onStatsEvent = (evt: Event) => { + this.updateNoteStatsByEvents([evt], event.pubkey) + events.push(evt) + } + if (nonSocial.length > 0) { + await queryService.fetchEvents(finalRelayUrls, nonSocial, { + ...fetchOpts, + onevent: onStatsEvent + }) + } + if (social.length > 0) { + await queryService.fetchEvents(finalRelayUrls, social, { + ...fetchOpts, + onevent: onStatsEvent + }) + } logger.debug('[NoteStats] Fetched', events.length, 'events for stats') @@ -261,31 +277,34 @@ class NoteStatsService { } /** - * Reactions must not share one `limit` with replies — relays often return newest notes first and - * fill the bucket with kind 1/1111, dropping kind 7 entirely. - * Do not use `since` from last fetch `updatedAt`: reaction `created_at` is usually far in the past, - * so incremental filters would return nothing and leave stats stuck empty. + * Split REQ batches so “social” kinds (1 / 11 / 1111) do not strip aggregator relays from the + * same subscription as reactions and zaps ({@link relayFilterIncludesSocialKindBlockedKind}). + * RSS URL threads also need `#r` + kind 7 for NIP-73 page-targeted likes. */ - private buildFilters(event: Event, replaceableCoordinate?: string): Filter[] { + private buildFilterGroups( + event: Event, + replaceableCoordinate?: string + ): { nonSocial: Filter[]; social: Filter[] } { const reactionLimit = 300 const interactionLimit = 80 - const filters: Filter[] = [ - { - '#e': [event.id], - kinds: [kinds.Reaction], - limit: reactionLimit - }, + const nonSocial: Filter[] = [ + { '#e': [event.id], kinds: [kinds.Reaction], limit: reactionLimit }, + { '#e': [event.id], kinds: [kinds.Zap], limit: 100 } + ] + + const social: Filter[] = [ { '#e': [event.id], - kinds: [kinds.Repost, kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Highlights], + kinds: [ + kinds.Repost, + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + kinds.Highlights + ], limit: interactionLimit }, - { - '#e': [event.id], - kinds: [kinds.Zap], - limit: 100 - }, { '#q': [event.id], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], @@ -297,58 +316,42 @@ class NoteStatsService { const url = getArticleUrlFromCommentITags(event) if (url) { const canonical = canonicalizeRssArticleUrl(url) - filters.push( - { - '#i': [canonical], - kinds: [ExtendedKind.EXTERNAL_REACTION], - limit: reactionLimit - }, - { - '#I': [canonical], - kinds: [ExtendedKind.EXTERNAL_REACTION], - limit: reactionLimit - }, - { - '#i': [canonical], - kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], - limit: interactionLimit - }, - { - '#I': [canonical], - kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], - limit: interactionLimit - }, - { - '#r': computeRTagFilterValuesForArticleThread(canonical), - kinds: [kinds.Highlights], - limit: interactionLimit - }, - { - kinds: [kinds.BookmarkList], - '#e': [event.id], - limit: 200 - } + const rVals = computeRTagFilterValuesForArticleThread(canonical) + nonSocial.push( + { '#i': [canonical], kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit }, + { '#I': [canonical], kinds: [ExtendedKind.EXTERNAL_REACTION], limit: reactionLimit }, + { kinds: [kinds.BookmarkList], '#e': [event.id], limit: 200 } + ) + if (rVals.length > 0) { + nonSocial.push( + { '#r': rVals, kinds: [kinds.Highlights], limit: interactionLimit }, + { '#r': rVals, kinds: [kinds.Reaction], limit: reactionLimit } + ) + } + social.push( + { '#i': [canonical], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: interactionLimit }, + { '#I': [canonical], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: interactionLimit } ) } } if (replaceableCoordinate) { - filters.push( - { - '#a': [replaceableCoordinate], - kinds: [kinds.Reaction], - limit: reactionLimit - }, + nonSocial.push( + { '#a': [replaceableCoordinate], kinds: [kinds.Reaction], limit: reactionLimit }, + { '#a': [replaceableCoordinate], kinds: [kinds.Zap], limit: 100 } + ) + social.push( { '#a': [replaceableCoordinate], - kinds: [kinds.Repost, kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Highlights], + kinds: [ + kinds.Repost, + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + kinds.Highlights + ], limit: interactionLimit }, - { - '#a': [replaceableCoordinate], - kinds: [kinds.Zap], - limit: 100 - }, { '#q': [replaceableCoordinate], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], @@ -357,7 +360,7 @@ class NoteStatsService { ) } - return filters + return { nonSocial, social } } @@ -507,7 +510,13 @@ class NoteStatsService { } private addLikeByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) { - const targetEventId = forcedTargetEventId ?? getFirstHexEventIdFromETags(evt.tags) + let targetEventId = forcedTargetEventId ?? getFirstHexEventIdFromETags(evt.tags) + if (!targetEventId && evt.kind === kinds.Reaction) { + const pageUrl = getReactionPageUrlFromRTags(evt) + if (pageUrl) { + targetEventId = rssArticleStableEventId(canonicalizeRssArticleUrl(pageUrl)) + } + } if (!targetEventId) return const old = this.noteStatsMap.get(targetEventId) || {}