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 { @@ -18,7 +18,6 @@ import {
} from '@/lib/event'
import logger from '@/lib/logger'
import {
buildAttestedPaymentIdSet,
getPaymentAttestationTargetId,
partitionAttestedSuperchats,
replyFeedSuperchatsFirst
@ -50,7 +49,11 @@ import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' @@ -50,7 +49,11 @@ import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
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 { eventReferencesThreadTarget } from '@/lib/op-reference-tags'
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match'
@ -58,7 +61,7 @@ import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from @@ -58,7 +61,7 @@ import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from
import type { TProfile, TSubRequestFilter } from '@/types'
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
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 { useTranslation } from 'react-i18next'
import { LoadingBar } from '../LoadingBar'
@ -440,9 +443,37 @@ function ReplyNoteList({ @@ -440,9 +443,37 @@ function ReplyNoteList({
return out.length ? out : undefined
}, [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(() => {
setAttestedPaymentIds(new Set())
}, [event.id])
const pk = event.pubkey
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(() => {
const handleAttestation = (data: Event) => {
@ -1184,6 +1215,8 @@ function ReplyNoteList({ @@ -1184,6 +1215,8 @@ function ReplyNoteList({
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.
const streamThreadReply = (evt: NEvent) => {
@ -1197,13 +1230,41 @@ function ReplyNoteList({ @@ -1197,13 +1230,41 @@ function ReplyNoteList({
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,
foreground: true,
firstRelayResultGraceMs: 900,
globalTimeout: 12_000,
relayOpSource: 'ReplyNoteList.thread'
})
}),
attestationTask
])
if (fetchGeneration !== replyFetchGenRef.current) return
@ -1242,15 +1303,10 @@ function ReplyNoteList({ @@ -1242,15 +1303,10 @@ function ReplyNoteList({
const repliesForStatsPrime = mergedForUi
addReplies(mergedForUi)
const recipientPubkey = event.pubkey
threadRelayUrlsRef.current = relayUrlsForThreadReq
if (recipientPubkey) {
void fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, {
foreground: statsForeground
})
.then(async (attestations) => {
void resolveAttestedPaymentIdSet(recipientPubkey, relayAttestations)
.then(async (attestedIds) => {
if (fetchGeneration !== replyFetchGenRef.current) return
const attestedIds = buildAttestedPaymentIdSet(attestations, recipientPubkey)
setAttestedPaymentIds(attestedIds)
const targets = await hydrateAttestedSuperchatTargets(
attestedIds,

4
src/components/TurnIntoSuperchatButton/index.tsx

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

72
src/hooks/usePaymentAttestationStatus.tsx

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

14
src/lib/event.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { ExtendedKind, isNip52CalendarCardKind } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { muteSetHas } from '@/lib/mute-set'
import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns'
import { cleanUrl, normalizeUrl } from '@/lib/url'
@ -293,12 +294,23 @@ export function getRootETag(event?: Event) { @@ -293,12 +294,23 @@ export function getRootETag(event?: Event) {
// Kind 9735: thread root for note zaps is the zapped event id on `e` / `E`
if (event.kind === kinds.Zap) {
const firstHex = getFirstHexEventIdFromETags(event.tags)
if (!firstHex) return undefined
if (firstHex) {
return (
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

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

@ -43,6 +43,14 @@ describe('eventMatchesLocalFeedFilter', () => { @@ -43,6 +43,14 @@ describe('eventMatchesLocalFeedFilter', () => {
).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', () => {
expect(eventMatchesLocalFeedFilter(event({ kind: 6 }), { kinds: [1] })).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 @@ -34,7 +34,7 @@ export function eventMatchesLocalFeedFilter(event: Event, filter: Filter): boole
if (!Array.isArray(values) || values.length === 0) continue
const tagName = key.slice(1)
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)
if (eventValues.length === 0) return false
if (!valuesMatchTag(tagName, eventValues, values)) return false

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

@ -4,10 +4,12 @@ import { @@ -4,10 +4,12 @@ import {
isReplaceableEvent,
normalizeReplaceableCoordinateString
} from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import type { TThreadRootRef } from '@/lib/thread-reply-root-match'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
const REF_TAG_NAMES = new Set(['e', 'E', 'a', 'A', 'q', 'Q'])
@ -33,6 +35,12 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b @@ -33,6 +35,12 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b
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
}
const hex = root.id.trim().toLowerCase()
@ -44,6 +52,10 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b @@ -44,6 +52,10 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b
if (!v) continue
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
}

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

@ -1,16 +1,101 @@ @@ -1,16 +1,101 @@
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 indexedDb from '@/services/indexed-db.service'
import type { Event as NostrEvent, Filter } from 'nostr-tools'
const attestationByTargetKey = new Map<string, 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 {
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(
targetEventId: string,
recipientPubkey: string
@ -27,6 +112,7 @@ export function rememberPaymentAttestation( @@ -27,6 +112,7 @@ export function rememberPaymentAttestation(
paymentAttestationCacheKey(targetEventId, recipientPubkey),
attestation
)
markLocalAttestationTarget(recipientPubkey, targetEventId)
}
export function resolvePaymentAttestationFromEvents(
@ -41,6 +127,36 @@ export function resolvePaymentAttestationFromEvents( @@ -41,6 +127,36 @@ export function resolvePaymentAttestationFromEvents(
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(
targetEventId: string,
recipientPubkey: string,
@ -49,6 +165,16 @@ export async function loadPaymentAttestationLocal( @@ -49,6 +165,16 @@ export async function loadPaymentAttestationLocal(
const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey)
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 fromSession = resolvePaymentAttestationFromEvents(sessionHits, targetEventId, recipientPubkey)
if (fromSession) return fromSession
@ -66,6 +192,10 @@ export async function refreshPaymentAttestationFromRelays( @@ -66,6 +192,10 @@ export async function refreshPaymentAttestationFromRelays(
const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey)
if (cached) return cached
if (isLocallyMarkedAttested(recipientPubkey, targetEventId)) {
return undefined
}
const key = paymentAttestationCacheKey(targetEventId, recipientPubkey)
let inflight = relayFetchByTargetKey.get(key)
if (!inflight) {
@ -89,7 +219,10 @@ export async function refreshPaymentAttestationFromRelays( @@ -89,7 +219,10 @@ export async function refreshPaymentAttestationFromRelays(
export function rememberPaymentAttestationFromPublish(attestation: NostrEvent): void {
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
rememberPaymentAttestation(targetId, attestation.pubkey, attestation)
}

8
src/lib/superchat.ts

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

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

@ -92,3 +92,17 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte @@ -92,3 +92,17 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte
}
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 { @@ -200,6 +200,14 @@ export class EventService {
* 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).
*/
findSessionReplaceableByNaddr(data: {
pubkey: string
kind: number
identifier: string
}): NEvent | undefined {
return this.getSessionEventIfMatchingNaddr(data)
}
private getSessionEventIfMatchingNaddr(data: {
pubkey: string
kind: number
@ -376,12 +384,26 @@ export class EventService { @@ -376,12 +384,26 @@ export class EventService {
pointerHasFetchHints = Boolean(data.author || data.relays?.length)
break
case 'naddr': {
const ident = data.identifier ?? ''
const fromSession = this.getSessionEventIfMatchingNaddr({
pubkey: data.pubkey,
kind: data.kind,
identifier: data.identifier ?? ''
identifier: ident
})
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
}
}
@ -1286,6 +1308,28 @@ export class EventService { @@ -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) {
const hintedEvents = await this.queryService.query(relays, filter, undefined, {
immediateReturn: true,

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

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

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

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

Loading…
Cancel
Save