Browse Source

fix web feed

imwald
Silberengel 1 month ago
parent
commit
45660d1c38
  1. 1
      src/components/Embedded/EmbeddedNote.tsx
  2. 22
      src/components/Note/Poll.tsx
  3. 265
      src/components/Note/UnknownNote.tsx
  4. 20
      src/components/Note/ZapPoll.tsx
  5. 23
      src/components/RssUrlThreadEventsPreview/index.tsx
  6. 4
      src/i18n/locales/en.ts
  7. 8
      src/lib/rss-article.ts
  8. 52
      src/lib/rss-web-feed.ts
  9. 147
      src/services/note-stats.service.ts

1
src/components/Embedded/EmbeddedNote.tsx

@ -239,6 +239,7 @@ function EmbeddedNoteContent({ @@ -239,6 +239,7 @@ function EmbeddedNoteContent({
>
<UnknownNote
event={finalEvent}
showAuthorSummary
className={cn('my-0 p-2 sm:p-3 border rounded-lg w-full', className)}
/>
</div>

22
src/components/Note/Poll.tsx

@ -24,6 +24,8 @@ export default function Poll({ event, className }: { event: Event; className?: s @@ -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<string[]>([])
/** 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 @@ -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<HTMLDivElement | null>(null)
useEffect(() => {
@ -228,6 +230,22 @@ export default function Poll({ event, className }: { event: Event; className?: s @@ -228,6 +230,22 @@ export default function Poll({ event, className }: { event: Event; className?: s
})}
</div>
{canVote && !resultsRevealed && (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation()
setResultsRevealed(true)
void fetchResults()
}}
>
{t('See results')}
</Button>
)}
{/* Results Summary */}
<div className="flex justify-between items-center text-sm text-muted-foreground">
<div>{t('{{number}} votes', { number: pollResults?.totalVotes ?? 0 })}</div>

265
src/components/Note/UnknownNote.tsx

@ -11,16 +11,132 @@ import EventViewer from './EventViewer' @@ -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 @@ -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 (
<div
className={cn(
@ -55,15 +209,98 @@ export default function UnknownNote({ event, className }: { event: Event; classN @@ -55,15 +209,98 @@ export default function UnknownNote({ event, className }: { event: Event; classN
<p className="text-sm text-muted-foreground leading-snug">
{t('Unsupported event preview')}
</p>
{showAuthorSummary && isValidPubkey(event.pubkey) ? (
<div className="flex min-w-0 items-center gap-2 border-b border-border/60 pb-3">
<UserAvatar userId={event.pubkey} size="medium" className="shrink-0" />
<Username
userId={event.pubkey}
className="min-w-0 truncate font-semibold text-sm"
skeletonClassName="h-4"
/>
</div>
) : null}
<div>
<h3 className="text-base font-semibold leading-tight text-foreground">
{kindLabel.description}
</h3>
<p className="mt-0.5 text-xs text-muted-foreground font-mono tabular-nums">
{t('Event kind label', { kind: event.kind })}
<h3 className="text-base font-semibold leading-tight text-foreground">{headline}</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
{showKindAsSubtitle ? (
<span className="text-foreground/80">{kindLabel.description}</span>
) : null}
{showKindAsSubtitle ? <span className="mx-1.5 text-border">·</span> : null}
<span className="font-mono tabular-nums">{t('Event kind label', { kind: event.kind })}</span>
</p>
{showDeclaredKindTag ? (
<p className="mt-1 text-xs text-muted-foreground">{t('Unknown note declared kind tag', { value: declaredKindTrimmed })}</p>
) : null}
</div>
{showTaggedPubkey ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground shrink-0">
{t('Unknown note tagged pubkey')}
</span>
<PubkeyCopy pubkey={elevated.taggedPubkey!} />
</div>
) : null}
{elevated.topics.length > 0 ? (
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2">
{t('Topics')}
</p>
<div className="flex flex-wrap gap-1.5">
{elevated.topics.map((topic, i) => (
<Badge key={`${topic}-${i}`} variant="secondary" className="font-normal">
{topic}
</Badge>
))}
</div>
</div>
) : null}
{elevated.imageUrls.length > 0 ? (
<div className="space-y-2">
{elevated.imageUrls.slice(0, 4).map((url, i) => (
<img
key={`${url}-${i}`}
src={url}
alt=""
className="max-h-52 w-full rounded-md border border-border object-cover bg-muted"
loading="lazy"
referrerPolicy="no-referrer"
/>
))}
</div>
) : null}
{elevated.summary ? (
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
{t('Summary')}
</p>
<p className={cn(proseClass, 'text-muted-foreground')}>{truncatePreview(elevated.summary, CONTENT_PREVIEW_MAX)}</p>
</div>
) : null}
{elevated.description ? (
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
{t('Description')}
</p>
<p className={proseClass}>{truncatePreview(elevated.description, CONTENT_PREVIEW_MAX)}</p>
</div>
) : null}
{elevated.tagContent && normText(elevated.tagContent) !== contentNorm ? (
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
{t('Unknown note tagged content')}
</p>
<p className={proseClass}>{truncatePreview(elevated.tagContent, CONTENT_PREVIEW_MAX)}</p>
</div>
) : null}
{isBookstrEvent && (
<div className="text-xs text-muted-foreground flex flex-wrap gap-x-3 gap-y-1">
{bookMetadata.type && <span>{t('Type')}: {bookMetadata.type}</span>}
@ -74,21 +311,21 @@ export default function UnknownNote({ event, className }: { event: Event; classN @@ -74,21 +311,21 @@ export default function UnknownNote({ event, className }: { event: Event; classN
</div>
)}
{contentRaw ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words text-foreground/95">
{truncatePreview(contentRaw, CONTENT_PREVIEW_MAX)}
</p>
) : (
{showMainContent ? (
<p className={proseClass}>{truncatePreview(contentRaw, CONTENT_PREVIEW_MAX)}</p>
) : null}
{showNoTextPlaceholder ? (
<p className="text-sm text-muted-foreground italic">{t('No text content in event')}</p>
)}
) : null}
{event.tags.length > 0 ? (
{remainderTags.length > 0 ? (
<div className="border-t border-border/80 pt-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2">
{t('Tags')}
</p>
<ul className="space-y-1.5 text-sm">
{event.tags.map((tag, i) => (
{remainderTags.map((tag, i) => (
<li key={i} className="flex gap-2 rounded-md bg-muted/40 px-2 py-1.5">
<span className="shrink-0 font-medium text-foreground/90">{tag[0]}</span>
<span className="min-w-0 break-all text-muted-foreground">

20
src/components/Note/ZapPoll.tsx

@ -45,6 +45,8 @@ export default function ZapPoll({ @@ -45,6 +45,8 @@ export default function ZapPoll({
const [optionIndex, setOptionIndex] = useState<number | null>(null)
const [sats, setSats] = useState<number>(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({ @@ -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({ @@ -151,6 +154,21 @@ export default function ZapPoll({
<p className="text-xs text-muted-foreground">{t('Loading tally…')}</p>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{meta && !closed && !showTally && (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation()
setTallyRevealed(true)
void reload()
}}
>
{t('See results')}
</Button>
)}
<div className="space-y-2">
{meta.options.map((opt) => {
const satsOpt = tally?.satsByOption.get(opt.index) ?? 0

23
src/components/RssUrlThreadEventsPreview/index.tsx

@ -3,7 +3,7 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -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 @@ -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<string>()
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)

4
src/i18n/locales/en.ts

@ -396,6 +396,9 @@ export default { @@ -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 { @@ -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)',

8
src/lib/rss-article.ts

@ -2,7 +2,7 @@ import { bytesToHex } from '@noble/hashes/utils' @@ -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<Event, 'tags'>): string | @@ -113,6 +113,12 @@ export function getHighlightSourceHttpUrl(event: Pick<Event, 'tags'>): 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<Event, 'kind' | 'tags'>): 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).

52
src/lib/rss-web-feed.ts

@ -7,6 +7,7 @@ import { @@ -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 { @@ -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: { @@ -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 { @@ -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

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

@ -13,6 +13,7 @@ import { @@ -13,6 +13,7 @@ import {
computeRTagFilterValuesForArticleThread,
getArticleUrlFromCommentITags,
getHighlightSourceHttpUrl,
getReactionPageUrlFromRTags,
getWebExternalReactionTargetUrl,
rssArticleStableEventId
} from '@/lib/rss-article'
@ -179,18 +180,33 @@ class NoteStatsService { @@ -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 { @@ -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 { @@ -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 { @@ -357,7 +360,7 @@ class NoteStatsService {
)
}
return filters
return { nonSocial, social }
}
@ -507,7 +510,13 @@ class NoteStatsService { @@ -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) || {}

Loading…
Cancel
Save