Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
fd177eb911
  1. 84
      src/components/ReplyNoteList/index.tsx
  2. 4
      src/components/TurnIntoSuperchatButton/index.tsx
  3. 72
      src/hooks/usePaymentAttestationStatus.tsx
  4. 14
      src/lib/event.ts
  5. 8
      src/lib/feed-local-event-match.test.ts
  6. 2
      src/lib/feed-local-event-match.ts
  7. 12
      src/lib/op-reference-tags.ts
  8. 137
      src/lib/payment-attestation-cache.ts
  9. 8
      src/lib/superchat.ts
  10. 14
      src/lib/thread-interaction-req.ts
  11. 46
      src/services/client-events.service.ts
  12. 83
      src/services/client-replaceable-events.service.ts
  13. 6
      src/services/indexed-db.service.ts

84
src/components/ReplyNoteList/index.tsx

@ -18,7 +18,6 @@ import {
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
buildAttestedPaymentIdSet,
getPaymentAttestationTargetId, getPaymentAttestationTargetId,
partitionAttestedSuperchats, partitionAttestedSuperchats,
replyFeedSuperchatsFirst replyFeedSuperchatsFirst
@ -50,7 +49,11 @@ import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator' import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req' import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req'
import {
readKnownAttestedPaymentTargetsSync,
resolveAttestedPaymentIdSet
} from '@/lib/payment-attestation-cache'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags'
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match'
@ -58,7 +61,7 @@ import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from
import type { TProfile, TSubRequestFilter } from '@/types' import type { TProfile, TSubRequestFilter } from '@/types'
import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
@ -440,9 +443,37 @@ function ReplyNoteList({
return out.length ? out : undefined return out.length ? out : undefined
}, [duplicateWebPreviewCleanedUrlHints, rootInfo]) }, [duplicateWebPreviewCleanedUrlHints, rootInfo])
useLayoutEffect(() => {
const pk = event.pubkey
if (!pk) return
const syncIds = readKnownAttestedPaymentTargetsSync(pk)
if (syncIds.size === 0) return
setAttestedPaymentIds((prev) => {
const next = new Set(prev)
for (const id of syncIds) next.add(id)
return next.size === prev.size ? prev : next
})
}, [event.pubkey, event.id])
useEffect(() => { useEffect(() => {
setAttestedPaymentIds(new Set()) const pk = event.pubkey
}, [event.id]) if (!pk) return
let cancelled = false
void (async () => {
const ids = await resolveAttestedPaymentIdSet(pk)
if (cancelled) return
setAttestedPaymentIds(ids)
const relayHints = threadRelayUrlsRef.current.length
? threadRelayUrlsRef.current
: browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean)
const targets = await hydrateAttestedSuperchatTargets(ids, relayHints)
if (cancelled) return
if (targets.length > 0) addReplies(targets)
})()
return () => {
cancelled = true
}
}, [event.pubkey, event.id, addReplies, browsingRelayUrls])
useEffect(() => { useEffect(() => {
const handleAttestation = (data: Event) => { const handleAttestation = (data: Event) => {
@ -1184,6 +1215,8 @@ function ReplyNoteList({
allowThirdPartyLocalRelays: false allowThirdPartyLocalRelays: false
}) })
) )
threadRelayUrlsRef.current = relayUrlsForThreadReq
const recipientPubkey = event.pubkey
// Stream replies as relays return them (aggr is first in the list) instead of waiting for full EOSE. // Stream replies as relays return them (aggr is first in the list) instead of waiting for full EOSE.
const streamThreadReply = (evt: NEvent) => { const streamThreadReply = (evt: NEvent) => {
@ -1197,13 +1230,41 @@ function ReplyNoteList({
if (!hasCache) setLoading(false) if (!hasCache) setLoading(false)
} }
const allReplies = await queryService.fetchEvents(relayUrlsForThreadReq, filters, { const superchatFilters = buildThreadSuperchatPriorityFilters({
root: rootInfo,
opEventKind: event.kind,
limit: LIMIT
})
if (superchatFilters.length > 0) {
void queryService
.fetchEvents(relayUrlsForThreadReq, superchatFilters, {
onevent: streamThreadReply,
foreground: true,
firstRelayResultGraceMs: 400,
globalTimeout: 8000,
relayOpSource: 'ReplyNoteList.threadSuperchats'
})
.catch(() => {
/* optional early wave */
})
}
const attestationTask = recipientPubkey
? fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, {
foreground: statsForeground
})
: Promise.resolve([] as NEvent[])
const [allReplies, relayAttestations] = await Promise.all([
queryService.fetchEvents(relayUrlsForThreadReq, filters, {
onevent: streamThreadReply, onevent: streamThreadReply,
foreground: true, foreground: true,
firstRelayResultGraceMs: 900, firstRelayResultGraceMs: 900,
globalTimeout: 12_000, globalTimeout: 12_000,
relayOpSource: 'ReplyNoteList.thread' relayOpSource: 'ReplyNoteList.thread'
}) }),
attestationTask
])
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
@ -1242,15 +1303,10 @@ function ReplyNoteList({
const repliesForStatsPrime = mergedForUi const repliesForStatsPrime = mergedForUi
addReplies(mergedForUi) addReplies(mergedForUi)
const recipientPubkey = event.pubkey
threadRelayUrlsRef.current = relayUrlsForThreadReq
if (recipientPubkey) { if (recipientPubkey) {
void fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, { void resolveAttestedPaymentIdSet(recipientPubkey, relayAttestations)
foreground: statsForeground .then(async (attestedIds) => {
})
.then(async (attestations) => {
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
const attestedIds = buildAttestedPaymentIdSet(attestations, recipientPubkey)
setAttestedPaymentIds(attestedIds) setAttestedPaymentIds(attestedIds)
const targets = await hydrateAttestedSuperchatTargets( const targets = await hydrateAttestedSuperchatTargets(
attestedIds, attestedIds,

4
src/components/TurnIntoSuperchatButton/index.tsx

@ -11,6 +11,7 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { requestProfileWallRefresh } from '@/hooks/useProfileWall' import { requestProfileWallRefresh } from '@/hooks/useProfileWall'
import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus' import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus'
import { markLocalAttestationTarget } from '@/lib/payment-attestation-cache'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -84,8 +85,11 @@ function TurnIntoSuperchatButtonInner({
try { try {
const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true }) const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true })
const published = await publish(draft, { disableFallbacks: true }) const published = await publish(draft, { disableFallbacks: true })
markLocalAttestationTarget(recipientPubkey, event.id)
if (published) { if (published) {
markAttested(published) markAttested(published)
} else {
markAttested({ ...draft, id: event.id, pubkey: recipientPubkey, sig: '' } as Event)
} }
requestProfileWallRefresh(recipientPubkey) requestProfileWallRefresh(recipientPubkey)
showSimplePublishSuccess(t('Superchat attested')) showSimplePublishSuccess(t('Superchat attested'))

72
src/hooks/usePaymentAttestationStatus.tsx

@ -3,8 +3,12 @@ import {
getPaymentAttestationTargetId, getPaymentAttestationTargetId,
getSuperchatPaymentRecipientPubkey getSuperchatPaymentRecipientPubkey
} from '@/lib/superchat' } from '@/lib/superchat'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { import {
hydrateAttestationsForAuthor,
isLocallyMarkedAttested,
loadPaymentAttestationLocal, loadPaymentAttestationLocal,
markLocalAttestationTarget,
peekCachedPaymentAttestation, peekCachedPaymentAttestation,
refreshPaymentAttestationFromRelays, refreshPaymentAttestationFromRelays,
rememberPaymentAttestationFromPublish rememberPaymentAttestationFromPublish
@ -17,11 +21,26 @@ function attestationFilter(recipientPubkey: string, targetEventId: string) {
return { return {
kinds: [ExtendedKind.PAYMENT_ATTESTATION], kinds: [ExtendedKind.PAYMENT_ATTESTATION],
authors: [recipientPubkey], authors: [recipientPubkey],
'#e': [targetEventId], '#e': [targetEventId.trim().toLowerCase()],
limit: 5 limit: 5
} }
} }
function readAttestedFromLocalSources(
targetEventId: string | undefined,
recipientPubkey: string | null
): { attested: boolean; attestationEvent: NostrEvent | null } {
if (!targetEventId || !recipientPubkey) {
return { attested: false, attestationEvent: null }
}
const hit = peekCachedPaymentAttestation(targetEventId, recipientPubkey)
const locallyMarked = isLocallyMarkedAttested(recipientPubkey, targetEventId)
return {
attested: Boolean(hit) || locallyMarked,
attestationEvent: hit ?? null
}
}
export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) { export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) {
const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null
const targetId = targetEvent?.id?.toLowerCase() const targetId = targetEvent?.id?.toLowerCase()
@ -34,16 +53,15 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
[targetEvent?.id, recipientPubkey] [targetEvent?.id, recipientPubkey]
) )
const cached = useMemo( const localSnapshot = useMemo(
() => () => readAttestedFromLocalSources(targetEvent?.id, recipientPubkey),
targetEvent?.id && recipientPubkey
? peekCachedPaymentAttestation(targetEvent.id, recipientPubkey)
: undefined,
[targetEvent?.id, recipientPubkey, targetId] [targetEvent?.id, recipientPubkey, targetId]
) )
const [attested, setAttested] = useState(Boolean(cached)) const [attested, setAttested] = useState(localSnapshot.attested)
const [attestationEvent, setAttestationEvent] = useState<NostrEvent | null>(cached ?? null) const [attestationEvent, setAttestationEvent] = useState<NostrEvent | null>(
localSnapshot.attestationEvent
)
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const applyMatch = useCallback((match: NostrEvent | undefined) => { const applyMatch = useCallback((match: NostrEvent | undefined) => {
@ -55,10 +73,14 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
const markAttested = useCallback( const markAttested = useCallback(
(attestation: NostrEvent) => { (attestation: NostrEvent) => {
if (!targetEvent?.id || !recipientPubkey) return if (!targetEvent?.id || !recipientPubkey) return
if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return markLocalAttestationTarget(recipientPubkey, targetEvent.id)
if (attestation.pubkey.toLowerCase() !== recipientPubkey.toLowerCase()) return if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) {
setAttested(true)
return
}
if (!hexPubkeysEqual(attestation.pubkey, recipientPubkey)) return
const attestedId = getPaymentAttestationTargetId(attestation) const attestedId = getPaymentAttestationTargetId(attestation)
if (attestedId?.toLowerCase() !== targetEvent.id.toLowerCase()) return if (!attestedId || attestedId.toLowerCase() !== targetEvent.id.toLowerCase()) return
rememberPaymentAttestationFromPublish(attestation) rememberPaymentAttestationFromPublish(attestation)
applyMatch(attestation) applyMatch(attestation)
}, },
@ -66,21 +88,23 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
) )
useLayoutEffect(() => { useLayoutEffect(() => {
if (!targetEvent?.id || !recipientPubkey) { const next = readAttestedFromLocalSources(targetEvent?.id, recipientPubkey)
setAttested(false) setAttestationEvent(next.attestationEvent)
setAttestationEvent(null) setAttested(next.attested)
return }, [recipientPubkey, targetEvent?.id, targetId])
}
const hit = peekCachedPaymentAttestation(targetEvent.id, recipientPubkey) useEffect(() => {
if (hit) { if (!recipientPubkey) return
setAttestationEvent(hit) void hydrateAttestationsForAuthor(recipientPubkey)
setAttested(true) }, [recipientPubkey])
}
}, [recipientPubkey, targetEvent?.id])
useEffect(() => { useEffect(() => {
if (!targetEvent?.id || !recipientPubkey || !filter) return if (!targetEvent?.id || !recipientPubkey || !filter) return
if (isLocallyMarkedAttested(recipientPubkey, targetEvent.id)) {
setAttested(true)
}
let cancelled = false let cancelled = false
setChecking(true) setChecking(true)
@ -92,6 +116,10 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
applyMatch(local) applyMatch(local)
return return
} }
if (isLocallyMarkedAttested(recipientPubkey, targetEvent.id)) {
setAttested(true)
return
}
const relay = await refreshPaymentAttestationFromRelays( const relay = await refreshPaymentAttestationFromRelays(
targetEvent.id, targetEvent.id,
recipientPubkey, recipientPubkey,

14
src/lib/event.ts

@ -1,4 +1,5 @@
import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' import { ExtendedKind, isNip52CalendarCardKind } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns' import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns'
import { cleanUrl, normalizeUrl } from '@/lib/url' import { cleanUrl, normalizeUrl } from '@/lib/url'
@ -293,12 +294,23 @@ export function getRootETag(event?: Event) {
// Kind 9735: thread root for note zaps is the zapped event id on `e` / `E` // Kind 9735: thread root for note zaps is the zapped event id on `e` / `E`
if (event.kind === kinds.Zap) { if (event.kind === kinds.Zap) {
const firstHex = getFirstHexEventIdFromETags(event.tags) const firstHex = getFirstHexEventIdFromETags(event.tags)
if (!firstHex) return undefined if (firstHex) {
return ( return (
event.tags.find((t) => t[0] === 'e' && t[1] === firstHex) ?? event.tags.find((t) => t[0] === 'e' && t[1] === firstHex) ??
event.tags.find((t) => t[0] === 'E' && t[1] === firstHex) event.tags.find((t) => t[0] === 'E' && t[1] === firstHex)
) )
} }
const zapped = getZapInfoFromEvent(event)?.originalEventId
if (zapped && /^[0-9a-f]{64}$/i.test(zapped)) {
const hex = zapped.toLowerCase()
return (
event.tags.find((t) => t[0] === 'e' && t[1]?.toLowerCase() === hex) ??
event.tags.find((t) => t[0] === 'E' && t[1]?.toLowerCase() === hex) ??
['e', hex]
)
}
return undefined
}
if (event.kind !== kinds.ShortTextNote) return undefined if (event.kind !== kinds.ShortTextNote) return undefined

8
src/lib/feed-local-event-match.test.ts

@ -43,6 +43,14 @@ describe('eventMatchesLocalFeedFilter', () => {
).toBe(true) ).toBe(true)
}) })
it('matches uppercase E tags when filter uses #e', () => {
expect(
eventMatchesLocalFeedFilter(event({ tags: [['E', 'e'.repeat(64)]] }), {
'#e': ['e'.repeat(64)]
})
).toBe(true)
})
it('rejects events outside any filter constraint', () => { it('rejects events outside any filter constraint', () => {
expect(eventMatchesLocalFeedFilter(event({ kind: 6 }), { kinds: [1] })).toBe(false) expect(eventMatchesLocalFeedFilter(event({ kind: 6 }), { kinds: [1] })).toBe(false)
expect(eventMatchesLocalFeedFilter(event(), { since: 1001 })).toBe(false) expect(eventMatchesLocalFeedFilter(event(), { since: 1001 })).toBe(false)

2
src/lib/feed-local-event-match.ts

@ -34,7 +34,7 @@ export function eventMatchesLocalFeedFilter(event: Event, filter: Filter): boole
if (!Array.isArray(values) || values.length === 0) continue if (!Array.isArray(values) || values.length === 0) continue
const tagName = key.slice(1) const tagName = key.slice(1)
const eventValues = event.tags const eventValues = event.tags
.filter((tag) => tag[0] === tagName && typeof tag[1] === 'string') .filter((tag) => tag[0]?.toLowerCase() === tagName.toLowerCase() && typeof tag[1] === 'string')
.map((tag) => tag[1] as string) .map((tag) => tag[1] as string)
if (eventValues.length === 0) return false if (eventValues.length === 0) return false
if (!valuesMatchTag(tagName, eventValues, values)) return false if (!valuesMatchTag(tagName, eventValues, values)) return false

12
src/lib/op-reference-tags.ts

@ -4,10 +4,12 @@ import {
isReplaceableEvent, isReplaceableEvent,
normalizeReplaceableCoordinateString normalizeReplaceableCoordinateString
} from '@/lib/event' } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import type { TThreadRootRef } from '@/lib/thread-reply-root-match' import type { TThreadRootRef } from '@/lib/thread-reply-root-match'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
const REF_TAG_NAMES = new Set(['e', 'E', 'a', 'A', 'q', 'Q']) const REF_TAG_NAMES = new Set(['e', 'E', 'a', 'A', 'q', 'Q'])
@ -33,6 +35,12 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b
if (normalizeReplaceableCoordinateString(v) === coordNorm) return true if (normalizeReplaceableCoordinateString(v) === coordNorm) return true
} }
} }
if (evt.kind === kinds.Zap) {
const zapped = getZapInfoFromEvent(evt)?.originalEventId
if (zapped && /^[0-9a-f]{64}$/i.test(zapped) && zapped.toLowerCase() === eventHex) return true
const coord = getZapInfoFromEvent(evt)?.eventId
if (coord && normalizeReplaceableCoordinateString(coord) === coordNorm) return true
}
return false return false
} }
const hex = root.id.trim().toLowerCase() const hex = root.id.trim().toLowerCase()
@ -44,6 +52,10 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b
if (!v) continue if (!v) continue
if (/^[0-9a-f]{64}$/i.test(v) && v.toLowerCase() === hex) return true if (/^[0-9a-f]{64}$/i.test(v) && v.toLowerCase() === hex) return true
} }
if (evt.kind === kinds.Zap) {
const zapped = getZapInfoFromEvent(evt)?.originalEventId
if (zapped && /^[0-9a-f]{64}$/i.test(zapped) && zapped.toLowerCase() === hex) return true
}
return false return false
} }

137
src/lib/payment-attestation-cache.ts

@ -1,16 +1,101 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { findPaymentAttestationForTarget } from '@/lib/superchat' import { buildAttestedPaymentIdSet, findPaymentAttestationForTarget } from '@/lib/superchat'
import { normalizeHexPubkey } from '@/lib/pubkey'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import type { Event as NostrEvent, Filter } from 'nostr-tools' import type { Event as NostrEvent, Filter } from 'nostr-tools'
const attestationByTargetKey = new Map<string, NostrEvent>() const attestationByTargetKey = new Map<string, NostrEvent>()
const relayFetchByTargetKey = new Map<string, Promise<NostrEvent[]>>() const relayFetchByTargetKey = new Map<string, Promise<NostrEvent[]>>()
const authorHydrateByPubkey = new Map<string, Promise<void>>()
const LOCAL_ATTESTED_KEY_PREFIX = 'jumble:attested-payment-ids:'
export function paymentAttestationCacheKey(targetEventId: string, recipientPubkey: string): string { export function paymentAttestationCacheKey(targetEventId: string, recipientPubkey: string): string {
return `${targetEventId.trim().toLowerCase()}:${recipientPubkey.trim().toLowerCase()}` return `${targetEventId.trim().toLowerCase()}:${recipientPubkey.trim().toLowerCase()}`
} }
function readLocalAttestedIds(recipientPubkey: string): Set<string> {
const pk = normalizeHexPubkey(recipientPubkey)
if (!/^[0-9a-f]{64}$/.test(pk)) return new Set()
try {
const raw = localStorage.getItem(`${LOCAL_ATTESTED_KEY_PREFIX}${pk}`)
if (!raw) return new Set()
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return new Set()
return new Set(
parsed
.filter((id): id is string => typeof id === 'string')
.map((id) => id.trim().toLowerCase())
.filter((id) => /^[0-9a-f]{64}$/.test(id))
)
} catch {
return new Set()
}
}
/** Durable local record that this payment was attested (survives reloads and relay failures). */
export function markLocalAttestationTarget(recipientPubkey: string, targetEventId: string): void {
const pk = normalizeHexPubkey(recipientPubkey)
const targetId = targetEventId.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk) || !/^[0-9a-f]{64}$/.test(targetId)) return
const ids = readLocalAttestedIds(pk)
if (ids.has(targetId)) return
ids.add(targetId)
try {
localStorage.setItem(`${LOCAL_ATTESTED_KEY_PREFIX}${pk}`, JSON.stringify([...ids]))
} catch {
/* quota */
}
}
export function isLocallyMarkedAttested(recipientPubkey: string, targetEventId: string): boolean {
return readLocalAttestedIds(recipientPubkey).has(targetEventId.trim().toLowerCase())
}
/** Synchronous attested payment ids from durable local storage (no network). */
export function readKnownAttestedPaymentTargetsSync(recipientPubkey: string): Set<string> {
return new Set(readLocalAttestedIds(recipientPubkey))
}
/** Attested payment target ids from local storage, IndexedDB, session, and optional relay batch. */
export async function resolveAttestedPaymentIdSet(
recipientPubkey: string,
relayAttestations: NostrEvent[] = []
): Promise<Set<string>> {
const pk = normalizeHexPubkey(recipientPubkey)
if (!/^[0-9a-f]{64}$/.test(pk)) return new Set()
const out = new Set(readLocalAttestedIds(pk))
await hydrateAttestationsForAuthor(pk)
const attestations: NostrEvent[] = []
const seen = new Set<string>()
const push = (ev: NostrEvent) => {
if (seen.has(ev.id)) return
seen.add(ev.id)
attestations.push(ev)
}
for (const attestation of await indexedDb.getPaymentAttestationsForAuthor(pk, 1000)) {
push(attestation)
}
for (const attestation of client.eventService.getSessionEventsMatchingFilters(
[{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pk], limit: 500 }],
500
)) {
push(attestation)
}
for (const attestation of relayAttestations) {
push(attestation)
}
for (const id of buildAttestedPaymentIdSet(attestations, pk)) {
out.add(id)
}
return out
}
export function peekCachedPaymentAttestation( export function peekCachedPaymentAttestation(
targetEventId: string, targetEventId: string,
recipientPubkey: string recipientPubkey: string
@ -27,6 +112,7 @@ export function rememberPaymentAttestation(
paymentAttestationCacheKey(targetEventId, recipientPubkey), paymentAttestationCacheKey(targetEventId, recipientPubkey),
attestation attestation
) )
markLocalAttestationTarget(recipientPubkey, targetEventId)
} }
export function resolvePaymentAttestationFromEvents( export function resolvePaymentAttestationFromEvents(
@ -41,6 +127,36 @@ export function resolvePaymentAttestationFromEvents(
return match return match
} }
/** Load all known attestations by this author into the in-memory cache (once per session). */
export async function hydrateAttestationsForAuthor(authorPubkey: string): Promise<void> {
const pk = normalizeHexPubkey(authorPubkey)
if (!/^[0-9a-f]{64}$/.test(pk)) return
let inflight = authorHydrateByPubkey.get(pk)
if (!inflight) {
inflight = (async () => {
const idbAttestations = await indexedDb.getPaymentAttestationsForAuthor(pk, 1000)
for (const attestation of idbAttestations) {
rememberPaymentAttestationFromPublish(attestation)
}
const sessionHits = client.eventService.getSessionEventsMatchingFilters(
[{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pk], limit: 500 }],
500
)
for (const attestation of sessionHits) {
rememberPaymentAttestationFromPublish(attestation)
}
})().finally(() => {
if (authorHydrateByPubkey.get(pk) === inflight) {
authorHydrateByPubkey.delete(pk)
}
})
authorHydrateByPubkey.set(pk, inflight)
}
await inflight
}
export async function loadPaymentAttestationLocal( export async function loadPaymentAttestationLocal(
targetEventId: string, targetEventId: string,
recipientPubkey: string, recipientPubkey: string,
@ -49,6 +165,16 @@ export async function loadPaymentAttestationLocal(
const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey) const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey)
if (cached) return cached if (cached) return cached
if (isLocallyMarkedAttested(recipientPubkey, targetEventId)) {
await hydrateAttestationsForAuthor(recipientPubkey)
const hydrated = peekCachedPaymentAttestation(targetEventId, recipientPubkey)
if (hydrated) return hydrated
}
await hydrateAttestationsForAuthor(recipientPubkey)
const afterHydrate = peekCachedPaymentAttestation(targetEventId, recipientPubkey)
if (afterHydrate) return afterHydrate
const sessionHits = client.eventService.getSessionEventsMatchingFilters([filter], 5) const sessionHits = client.eventService.getSessionEventsMatchingFilters([filter], 5)
const fromSession = resolvePaymentAttestationFromEvents(sessionHits, targetEventId, recipientPubkey) const fromSession = resolvePaymentAttestationFromEvents(sessionHits, targetEventId, recipientPubkey)
if (fromSession) return fromSession if (fromSession) return fromSession
@ -66,6 +192,10 @@ export async function refreshPaymentAttestationFromRelays(
const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey) const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey)
if (cached) return cached if (cached) return cached
if (isLocallyMarkedAttested(recipientPubkey, targetEventId)) {
return undefined
}
const key = paymentAttestationCacheKey(targetEventId, recipientPubkey) const key = paymentAttestationCacheKey(targetEventId, recipientPubkey)
let inflight = relayFetchByTargetKey.get(key) let inflight = relayFetchByTargetKey.get(key)
if (!inflight) { if (!inflight) {
@ -89,7 +219,10 @@ export async function refreshPaymentAttestationFromRelays(
export function rememberPaymentAttestationFromPublish(attestation: NostrEvent): void { export function rememberPaymentAttestationFromPublish(attestation: NostrEvent): void {
if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return
const targetId = attestation.tags.find(([name]) => name === 'e' || name === 'E')?.[1]?.trim().toLowerCase() const targetId = attestation.tags
.find(([name]) => name === 'e' || name === 'E')?.[1]
?.trim()
.toLowerCase()
if (!targetId || !/^[0-9a-f]{64}$/.test(targetId)) return if (!targetId || !/^[0-9a-f]{64}$/.test(targetId)) return
rememberPaymentAttestation(targetId, attestation.pubkey, attestation) rememberPaymentAttestation(targetId, attestation.pubkey, attestation)
} }

8
src/lib/superchat.ts

@ -69,13 +69,11 @@ export function findPaymentAttestationForTarget(
recipientPubkey: string recipientPubkey: string
): Event | undefined { ): Event | undefined {
const target = targetEventId.trim().toLowerCase() const target = targetEventId.trim().toLowerCase()
const recipient = recipientPubkey.trim().toLowerCase()
for (const attestation of attestations) { for (const attestation of attestations) {
if (attestation.pubkey.toLowerCase() !== recipient) continue if (!hexPubkeysEqual(attestation.pubkey, recipientPubkey)) continue
const attestedId = getPaymentAttestationTargetId(attestation) const attestedId = getPaymentAttestationTargetId(attestation)
const targetKind = getPaymentAttestationTargetKind(attestation) if (!attestedId || attestedId.toLowerCase() !== target) continue
if (!attestedId || !targetKind) continue return attestation
if (attestedId.toLowerCase() === target) return attestation
} }
return undefined return undefined
} }

14
src/lib/thread-interaction-req.ts

@ -92,3 +92,17 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte
} }
return filters return filters
} }
/** Zap / payment filters only — run first so paid thread replies appear before regular replies. */
export function buildThreadSuperchatPriorityFilters(
input: BuildThreadInteractionFiltersInput
): Filter[] {
const superchatKinds = new Set<number>([kinds.Zap, ExtendedKind.PAYMENT_NOTIFICATION])
const out: Filter[] = []
for (const filter of buildThreadInteractionFilters(input)) {
const kindsList = filter.kinds?.filter((k) => superchatKinds.has(k))
if (!kindsList?.length) continue
out.push({ ...filter, kinds: kindsList })
}
return out
}

46
src/services/client-events.service.ts

@ -200,6 +200,14 @@ export class EventService {
* Session cache is keyed by event `id` (hex). `fetchEvent("naddr1…")` has no hex until a REQ returns; * Session cache is keyed by event `id` (hex). `fetchEvent("naddr1…")` has no hex until a REQ returns;
* scan for a replaceable whose `kind`/`pubkey`/`d` matches the naddr (e.g. live 30311 already loaded from ticker/embed). * scan for a replaceable whose `kind`/`pubkey`/`d` matches the naddr (e.g. live 30311 already loaded from ticker/embed).
*/ */
findSessionReplaceableByNaddr(data: {
pubkey: string
kind: number
identifier: string
}): NEvent | undefined {
return this.getSessionEventIfMatchingNaddr(data)
}
private getSessionEventIfMatchingNaddr(data: { private getSessionEventIfMatchingNaddr(data: {
pubkey: string pubkey: string
kind: number kind: number
@ -376,12 +384,26 @@ export class EventService {
pointerHasFetchHints = Boolean(data.author || data.relays?.length) pointerHasFetchHints = Boolean(data.author || data.relays?.length)
break break
case 'naddr': { case 'naddr': {
const ident = data.identifier ?? ''
const fromSession = this.getSessionEventIfMatchingNaddr({ const fromSession = this.getSessionEventIfMatchingNaddr({
pubkey: data.pubkey, pubkey: data.pubkey,
kind: data.kind, kind: data.kind,
identifier: data.identifier ?? '' identifier: ident
}) })
if (fromSession) return fromSession if (fromSession) return fromSession
try {
const fromIdb = await indexedDb.getReplaceableEvent(
data.pubkey.toLowerCase(),
data.kind,
ident
)
if (fromIdb && fromIdb.kind === data.kind && !shouldDropEventOnIngest(fromIdb)) {
this.addEventToCache(fromIdb)
return fromIdb
}
} catch {
/* optional */
}
break break
} }
} }
@ -1286,6 +1308,28 @@ export class EventService {
} }
} }
if (
filter.authors?.length === 1 &&
filter.kinds?.length === 1 &&
Array.isArray(filter['#d']) &&
filter['#d'].length >= 1
) {
const pk = filter.authors[0]!.trim().toLowerCase()
const kind = filter.kinds[0]!
const dTag = String(filter['#d'][0] ?? '').trim()
if (pk && dTag) {
try {
const cached = await indexedDb.getReplaceableEvent(pk, kind, dTag)
if (cached && cached.kind === kind && !shouldDropEventOnIngest(cached, ingestOpts)) {
this.addEventToCache(cached, ingestOpts)
return cached
}
} catch {
/* optional */
}
}
}
if (relays.length > 0) { if (relays.length > 0) {
const hintedEvents = await this.queryService.query(relays, filter, undefined, { const hintedEvents = await this.queryService.query(relays, filter, undefined, {
immediateReturn: true, immediateReturn: true,

83
src/services/client-replaceable-events.service.ts

@ -1,5 +1,6 @@
import { import {
AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS, AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS,
DOCUMENT_RELAY_URLS,
ExtendedKind, ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS, FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS,
@ -9,7 +10,8 @@ import {
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS,
PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS, PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS,
PROFILE_RELAY_URLS, PROFILE_RELAY_URLS,
RECOMMENDED_BLOSSOM_SERVERS RECOMMENDED_BLOSSOM_SERVERS,
isDocumentRelayKind
} from '@/constants' } from '@/constants'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import type { Event as NEvent, Filter } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools'
@ -831,38 +833,74 @@ export class ReplaceableEventService {
private async replaceableEventBatchLoadFn( private async replaceableEventBatchLoadFn(
params: readonly { pubkey: string; kind: number; d?: string }[] params: readonly { pubkey: string; kind: number; d?: string }[]
): Promise<(NEvent | null)[]> { ): Promise<(NEvent | null)[]> {
const groups = new Map<string, { pubkey: string; kind: number; d?: string }[]>() const results: (NEvent | null)[] = new Array(params.length).fill(null)
params.forEach(({ pubkey, kind, d }) => { const missing: { pubkey: string; kind: number; d: string; index: number }[] = []
const key = `${kind}:${d ?? ''}`
if (!groups.has(key)) { await Promise.allSettled(
groups.set(key, []) params.map(async ({ pubkey, kind, d }, index) => {
if (!d) {
results[index] = null
return
} }
groups.get(key)!.push({ pubkey, kind, d }) try {
const idb = await indexedDb.getReplaceableEvent(pubkey, kind, d)
if (idb && idb.kind === kind && !shouldDropEventOnIngest(idb)) {
results[index] = idb
return
}
} catch {
/* optional */
}
const session = client.eventService.findSessionReplaceableByNaddr({
pubkey,
kind,
identifier: d
}) })
if (session && session.kind === kind && !shouldDropEventOnIngest(session)) {
results[index] = session
return
}
missing.push({ pubkey, kind, d, index })
})
)
if (missing.length === 0) {
return results
}
const eventsMap = new Map<string, NEvent>() const eventsMap = new Map<string, NEvent>()
const groups = new Map<string, typeof missing>()
for (const item of missing) {
const key = `${item.kind}:${item.d}`
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(item)
}
await Promise.allSettled( await Promise.allSettled(
Array.from(groups.entries()).map(async ([, items]) => { Array.from(groups.values()).map(async (items) => {
const { kind, d } = items[0]! const { kind, d } = items[0]!
const pubkeys = items.map(item => item.pubkey) const pubkeys = items.map((item) => item.pubkey)
const relayUrls = FAST_READ_RELAY_URLS const relayUrls = stripLocalNetworkRelaysForWssReq(
isDocumentRelayKind(kind)
? [...new Set([...FAST_READ_RELAY_URLS, ...DOCUMENT_RELAY_URLS])]
: [...FAST_READ_RELAY_URLS]
)
const filter: Filter = { const filter: Filter = {
authors: pubkeys, authors: pubkeys,
kinds: [kind] kinds: [kind],
} '#d': [d]
if (d) {
filter['#d'] = [d]
} }
const events = await this.queryService.query(relayUrls, filter, undefined, { const events = await this.queryService.query(relayUrls, filter, undefined, {
replaceableRace: true, replaceableRace: true,
eoseTimeout: 100, // Reduced from 200ms for faster early returns eoseTimeout: isDocumentRelayKind(kind) ? 2500 : 100,
globalTimeout: 2000 // Reduced from 3000ms to prevent long waits when many relays are slow globalTimeout: isDocumentRelayKind(kind) ? 8000 : 2000
}) })
for (const event of events) { for (const event of events) {
const eventKey = `${event.pubkey}:${event.kind}:${d ?? ''}` if (event.kind !== kind || shouldDropEventOnIngest(event)) continue
const eventKey = `${event.pubkey}:${event.kind}:${d}`
const existing = eventsMap.get(eventKey) const existing = eventsMap.get(eventKey)
if (!existing || existing.created_at < event.created_at) { if (!existing || existing.created_at < event.created_at) {
eventsMap.set(eventKey, event) eventsMap.set(eventKey, event)
@ -871,15 +909,16 @@ export class ReplaceableEventService {
}) })
) )
return params.map(({ pubkey, kind, d }) => { for (const { pubkey, kind, d, index } of missing) {
const eventKey = `${pubkey}:${kind}:${d ?? ''}` const eventKey = `${pubkey}:${kind}:${d}`
const event = eventsMap.get(eventKey) const event = eventsMap.get(eventKey)
if (event) { if (event) {
results[index] = event
void indexedDb.putReplaceableEvent(event) void indexedDb.putReplaceableEvent(event)
return event
} }
return null }
})
return results
} }
/** Persist kind 10133 rows returned alongside a kind-0 REQ (same filter, separate cache slots). */ /** Persist kind 10133 rows returned alongside a kind-0 REQ (same filter, separate cache slots). */

6
src/services/indexed-db.service.ts

@ -668,6 +668,12 @@ class IndexedDbService {
// This prevents the "no profile" state when cache exists but is just old // This prevents the "no profile" state when cache exists but is just old
} }
transaction.commit() transaction.commit()
if (!row.value) {
return resolve(undefined)
}
if (row.value.kind !== kind) {
return resolve(undefined)
}
resolve(row.value) resolve(row.value)
} }

Loading…
Cancel
Save