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