Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
1c0b429892
  1. 32
      public/payto_logos/LBTC.svg
  2. 7
      src/components/PaytoLink/index.tsx
  3. 34
      src/components/Profile/index.tsx
  4. 11
      src/components/UserAvatar/index.tsx
  5. 6
      src/components/Username/index.tsx
  6. 3
      src/constants.ts
  7. 16
      src/lib/payto.ts
  8. 15
      src/lib/profile-navigation-seed.ts
  9. 19
      src/lib/relay-list-sanitize.ts
  10. 82
      src/services/client-replaceable-events.service.ts
  11. 16
      src/services/client.service.ts

32
public/payto_logos/LBTC.svg

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2000 2000">
<defs>
<style>
.cls-1 {
fill: none;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke-width: 0px;
}
.cls-2 {
fill: #0d1437;
}
.cls-3 {
fill: #14909c;
}
.cls-4 {
fill: #22e1c9;
}
</style>
</defs>
<rect class="cls-1" x="1000.3" y="36.6" width="497.9" height="329.9"/>
<g>
<circle class="cls-2" cx="1000" cy="1000" r="1000"/>
<path class="cls-3" d="M930.3,828.5c47.5-284.6,351.6-246.9,387.8-241.3,37.7,11.4,74.5-26,53.2-62.1-83.8-143.2-250.7-236.6-377.5-243.2,31.5-24.9,137.2-38.6,249.3-34,39.5,1.4,56.1-50.5,22.6-71.7-2.8-1.8-5.7-3.8-9.8-5.2-153.2-47.1-317.6-51.4-474.1-10.3-116,30.1-228.1,84.8-327.5,165.4-41.5,34-79.7,70.8-113.1,109.8-249.3,289.8-280,715.9-67.4,1039.5,4.3,6.6,8.5,12.8,12.8,19.4,3.8,4.8,5.2,7.1,6.6,8.9,9.8,14.6,20.8,28.8,32,42.9,12.3,15.1,25.4,29.7,38.6,43.8,3.2,3.8,6.6,7.1,10.3,10.8,12.3,12.8,24.9,25.4,38.1,37.2.9.9,2.3,1.8,3.2,3.2,14.2,12.8,28.8,24.9,43.4,36.8,3.2,2.8,7.1,5.7,10.8,8.5,13.7,10.3,27.4,20.3,41.1,30.1,1.8.9,3.2,2.3,5.2,3.8,15.5,10.3,31.5,20.8,48,30.1,3.2,1.8,6.6,3.8,9.4,5.7,15.1,8.5,30.1,16.5,45.2,24,1.8.9,3.2,1.8,5.2,2.3,17.4,8.5,34.9,16,52.8,23.1,2.3.9,4.8,1.8,7.1,2.8,16.9,6.6,34,12.8,50.9,17.8,1.4,0,2.8.9,3.8,1.4,18.9,6.2,37.7,11.4,57.1,16,.9,0,1.8,0,2.3.5,18.9,4.8,37.7,8.5,57.1,11.8h2.3c40,6.6,80.1,10.3,120.6,11.8h.9c199.4,5.2,401-57.5,568.4-192.7,102.3-82.9,180.9-183.8,234.7-295,2.8-6.2,6.2-12.8,8.9-18.9,15.5-34.5,29.2-70.1,40-106-523.2,197-959.5-71.2-900.2-426.5l-.5-.9.2.5Z"/>
<path class="cls-4" d="M623.9,915.7c101.8-137.2,148.9-113.1,158.7-155.5,13.2-58-198.4-108.4-363.3,18.3,229-286.6,585.8-352.6,889.7-195,40.5,21.2,85.8-19.4,62.6-58.5-83.8-143.2-250.7-236.6-377.5-243.2,31.5-24.9,137.2-38.6,249.3-34,39.5,1.4,56.1-50.5,22.6-71.7-2.8-1.8-5.7-3.8-9.8-5.2-153.2-47.1-317.6-51.4-474.1-10.3-116,30.1-228.1,84.8-327.5,165.4-41.5,34-79.7,70.8-113.1,109.8-249.3,289.8-280,715.9-67.4,1039.5,4.3,6.6,8.5,12.8,12.8,19.4,3.8,4.8,5.2,7.1,6.6,8.9,9.8,14.6,20.8,28.8,32,42.9,222,274.3,576.4,377.5,896.3,293.2-818.2-121.1-693.3-795.6-598.1-923.7v-.5.2Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

7
src/components/PaytoLink/index.tsx

@ -76,7 +76,12 @@ export default function PaytoLink({
} }
const displayLabel = info?.label ?? type const displayLabel = info?.label ?? type
const categoryLabel = info?.category ? info.category.charAt(0).toUpperCase() + info.category.slice(1) : '' const categoryLabel = (() => {
const c = info?.category
if (!c) return ''
if (c === 'bitcoin-layer') return 'Bitcoin layer'
return c.charAt(0).toUpperCase() + c.slice(1)
})()
const logoPath = getPaytoLogoPath(type) const logoPath = getPaytoLogoPath(type)
const iconChar = getPaytoIconChar(type) const iconChar = getPaytoIconChar(type)
const profileUrl = getPaytoProfileUrl(type, authority) const profileUrl = getPaytoProfileUrl(type, authority)

34
src/components/Profile/index.tsx

@ -163,16 +163,38 @@ function mergePaymentMethods(
if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network') if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network')
}) })
// Kind-0 `w` tags: on-chain / liquid (lightning rows are already in lightningAddressList) // Kind-0 `w` tags: ["w", currency, address, network] — NIP-19-style multi-wallet (lightning via lud*/list above)
profile?.wWalletTags?.forEach((w) => { profile?.wWalletTags?.forEach((w) => {
const net = w.network.toLowerCase() const net = w.network.toLowerCase()
if (net === 'lightning') return if (net === 'lightning') return
const addr = w.address?.trim() const addr = w.address?.trim()
if (!addr) return if (!addr) return
const cur = (w.currency || '').trim().toLowerCase()
if (net === 'bitcoin') { if (net === 'bitcoin') {
add('bitcoin', addr, buildPaytoUri('bitcoin', addr), 'Bitcoin', { currency: w.currency }) add('bitcoin', addr, buildPaytoUri('bitcoin', addr), 'Bitcoin', { currency: w.currency })
} else if (net === 'liquid') { return
add('liquid', addr, buildPaytoUri('liquid', addr), 'Liquid', { currency: w.currency }) }
if (cur === 'usdt' || cur === 'usd₮' || cur === 'tether' || net === 'usdt') {
add('usdt', addr, buildPaytoUri('usdt', addr), 'Tether (USDT)', { currency: w.currency || 'USDT' })
return
}
if (net === 'liquid') {
if (cur === 'lbtc' || cur === 'l-btc' || cur === 'liquid btc') {
add('lbtc', addr, buildPaytoUri('lbtc', addr), 'Liquid Bitcoin (LBTC)', { currency: w.currency })
} else {
add('liquid', addr, buildPaytoUri('liquid', addr), cur ? `Liquid (${w.currency})` : 'Liquid', {
currency: w.currency
})
}
return
}
if (cur === 'lbtc' || cur === 'l-btc') {
add('lbtc', addr, buildPaytoUri('lbtc', addr), 'Liquid Bitcoin (LBTC)', { currency: w.currency })
return
} }
}) })
@ -236,14 +258,16 @@ export default function Profile({
const mergedPaymentMethods = useMemo(() => { const mergedPaymentMethods = useMemo(() => {
const list = mergePaymentMethods(paymentInfo, profile ?? null) const list = mergePaymentMethods(paymentInfo, profile ?? null)
return [...list].sort((a, b) => { return [...list].sort((a, b) => {
const rank = (type: string) => (type === 'lightning' ? 0 : type === 'bitcoin' ? 1 : 2) const rank = (type: string) =>
type === 'lightning' || type === 'liquid' || type === 'lbtc' ? 0 : type === 'bitcoin' ? 1 : 2
return rank(a.type) - rank(b.type) return rank(a.type) - rank(b.type)
}) })
}, [paymentInfo, profile]) }, [paymentInfo, profile])
/** Group payment methods by displayType so same-type addresses render under one heading */ /** Group payment methods by displayType so same-type addresses render under one heading */
const paymentMethodsByType = useMemo(() => { const paymentMethodsByType = useMemo(() => {
const rank = (type: string) => (type === 'lightning' ? 0 : type === 'bitcoin' ? 1 : 2) const rank = (type: string) =>
type === 'lightning' || type === 'liquid' || type === 'lbtc' ? 0 : type === 'bitcoin' ? 1 : 2
const groups = new Map<string, MergedPaymentMethod[]>() const groups = new Map<string, MergedPaymentMethod[]>()
for (const method of mergedPaymentMethods) { for (const method of mergedPaymentMethods) {
const key = method.displayType || method.type const key = method.displayType || method.type

11
src/components/UserAvatar/index.tsx

@ -5,7 +5,10 @@ import { toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isImage, isMedia, isVideo } from '@/lib/url' import { isImage, isMedia, isVideo } from '@/lib/url'
import { generateImageByPubkey, isValidPubkey, userIdToPubkey } from '@/lib/pubkey' import { generateImageByPubkey, isValidPubkey, userIdToPubkey } from '@/lib/pubkey'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { seedProfileForNavigation } from '@/lib/profile-navigation-seed' import {
seedProfileForNavigation,
seedProfileForNavigationFromSessionIfKnown
} from '@/lib/profile-navigation-seed'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartProfileNavigationOptional } from '@/PageManager' import { useSmartProfileNavigationOptional } from '@/PageManager'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
@ -341,7 +344,11 @@ export default function UserAvatar({
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!profileNavTarget) return if (!profileNavTarget) return
if (profile) seedProfileForNavigation(profile) if (profile) {
seedProfileForNavigation(profile)
} else if (pubkey) {
seedProfileForNavigationFromSessionIfKnown(pubkey)
}
navigateToProfile(toProfile(profileNavTarget)) navigateToProfile(toProfile(profileNavTarget))
}} }}
> >

6
src/components/Username/index.tsx

@ -1,7 +1,10 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { seedProfileForNavigation } from '@/lib/profile-navigation-seed' import {
seedProfileForNavigation,
seedProfileForNavigationFromSessionIfKnown
} from '@/lib/profile-navigation-seed'
import { formatPubkey, userIdToPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey' import { formatPubkey, userIdToPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartProfileNavigationOptional } from '@/PageManager' import { useSmartProfileNavigationOptional } from '@/PageManager'
@ -88,6 +91,7 @@ export default function Username({
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onNavigate?.() onNavigate?.()
seedProfileForNavigationFromSessionIfKnown(pubkey)
navigateToProfile(toProfile(pubkey)) navigateToProfile(toProfile(pubkey))
}} }}
> >

3
src/constants.ts

@ -251,9 +251,10 @@ export const PROFILE_FETCH_PROMISE_TIMEOUT_MS = 20000
/** /**
* Public Blossom (BUD) upload bases: presets in post settings and merged after the users * Public Blossom (BUD) upload bases: presets in post settings and merged after the users
* kind-10063 URLs when resolving the default Blossom server list. * kind-10063 URLs when resolving the default Blossom server list.
* @see https://0x0.happytavern.co/ — Lotus-style ephemeral Blossom (0x0 backend). * @see https://blossom.happytavern.co/ — Lotus-style ephemeral Blossom (0x0 backend).
*/ */
export const STANDARD_BLOSSOM_UPLOAD_HOSTS = [ export const STANDARD_BLOSSOM_UPLOAD_HOSTS = [
{ url: 'https://blossom.happytavern.co', labelKey: 'BlossomUploadOptionHappyTavern' },
{ url: 'https://0x0.happytavern.co', labelKey: 'BlossomUploadOptionHappyTavern' }, { url: 'https://0x0.happytavern.co', labelKey: 'BlossomUploadOptionHappyTavern' },
{ url: 'https://blossom.band', labelKey: 'BlossomUploadOptionBand' }, { url: 'https://blossom.band', labelKey: 'BlossomUploadOptionBand' },
{ url: 'https://blossom.primal.net', labelKey: 'BlossomUploadOptionPrimal' }, { url: 'https://blossom.primal.net', labelKey: 'BlossomUploadOptionPrimal' },

16
src/lib/payto.ts

@ -39,11 +39,18 @@ export function buildPaytoUri(type: string, authority: string): string {
/** Known payment types: NIP-A3 recommended + common extras (crypto, fiat, tipping) */ /** Known payment types: NIP-A3 recommended + common extras (crypto, fiat, tipping) */
export const PAYTO_KNOWN_TYPES: Record< export const PAYTO_KNOWN_TYPES: Record<
string, string,
{ label: string; symbol?: string; category: 'bitcoin' | 'crypto' | 'stablecoin' | 'fiat' | 'lightning' | 'tip' } { label: string; symbol?: string; category: 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip' }
> = { > = {
bitcoin: { label: 'Bitcoin', symbol: '₿', category: 'bitcoin' }, bitcoin: { label: 'Bitcoin', symbol: '₿', category: 'bitcoin' },
/**
* Liquid sidechain Bitcoin L3 (settlement layer), analogous in role to Lightning (L2) as a
* Bitcoin-native extension; not an alt-L1 crypto bucket.
*/
liquid: { label: 'Liquid', symbol: '⛓', category: 'bitcoin-layer' },
/** Confidential Bitcoin on Liquid (L-BTC). */
lbtc: { label: 'Liquid Bitcoin', symbol: '₿', category: 'bitcoin-layer' },
sats: { label: 'Satoshis', symbol: '丰', category: 'bitcoin' }, sats: { label: 'Satoshis', symbol: '丰', category: 'bitcoin' },
lightning: { label: 'Lightning Network', symbol: '⚡', category: 'lightning' }, lightning: { label: 'Lightning Network', symbol: '⚡', category: 'bitcoin-layer' },
ethereum: { label: 'Ethereum', symbol: 'Ξ', category: 'crypto' }, ethereum: { label: 'Ethereum', symbol: 'Ξ', category: 'crypto' },
monero: { label: 'Monero', symbol: 'ɱ', category: 'crypto' }, monero: { label: 'Monero', symbol: 'ɱ', category: 'crypto' },
nano: { label: 'Nano', symbol: 'Ӿ', category: 'crypto' }, nano: { label: 'Nano', symbol: 'Ӿ', category: 'crypto' },
@ -81,11 +88,10 @@ export const PAYTO_KNOWN_TYPES: Record<
/** /**
* Short labels accepted after payto:// that map to a canonical type. * Short labels accepted after payto:// that map to a canonical type.
* e.g. payto://BTC/..., payto://LBTC/..., payto://DOGE/... are recognized as bitcoin, lightning, dogecoin. * e.g. payto://BTC/... maps to bitcoin; payto://LBTC/... maps to Liquid Bitcoin (not Lightning).
*/ */
const PAYTO_TYPE_ALIASES: Record<string, string> = { const PAYTO_TYPE_ALIASES: Record<string, string> = {
btc: 'bitcoin', btc: 'bitcoin',
lbtc: 'lightning',
doge: 'dogecoin', doge: 'dogecoin',
eth: 'ethereum', eth: 'ethereum',
xmr: 'monero', xmr: 'monero',
@ -108,6 +114,8 @@ export function getPaytoIconChar(type: string): string | null {
/** Logo filename in /payto_logos/ for types that have an asset. Any image format works: .svg, .gif, .jpg, .png, .webp, etc. */ /** Logo filename in /payto_logos/ for types that have an asset. Any image format works: .svg, .gif, .jpg, .png, .webp, etc. */
const PAYTO_LOGO_FILES: Record<string, string> = { const PAYTO_LOGO_FILES: Record<string, string> = {
liquid: 'LBTC.svg',
lbtc: 'LBTC.svg',
ethereum: 'ethereum-eth-logo.svg', ethereum: 'ethereum-eth-logo.svg',
monero: 'Monero.png', monero: 'Monero.png',
litecoin: 'Litecoin.png', litecoin: 'Litecoin.png',

15
src/lib/profile-navigation-seed.ts

@ -1,3 +1,6 @@
import { getProfileFromEvent } from '@/lib/event-metadata'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { eventService } from '@/services/client.service'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
const seeds = new Map<string, TProfile>() const seeds = new Map<string, TProfile>()
@ -16,6 +19,18 @@ export function seedProfileForNavigation(profile: TProfile): void {
seeds.set(normPubkey(profile.pubkey), profile) seeds.set(normPubkey(profile.pubkey), profile)
} }
/**
* Feed rows often have pubkey only (no `TProfile` yet). If kind-0 is already in the session LRU
* (from notes in the feed), seed it so {@link useFetchProfile} on `/users/…` paints immediately.
*/
export function seedProfileForNavigationFromSessionIfKnown(pubkeyHex: string): void {
const pk = pubkeyHex.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return
const ev = eventService.getSessionMetadataForPubkey(pk)
if (!ev || shouldDropEventOnIngest(ev)) return
seedProfileForNavigation(getProfileFromEvent(ev))
}
/** Instant paint for `useFetchProfile` when opening the profile route from a seeded navigation. */ /** Instant paint for `useFetchProfile` when opening the profile route from a seeded navigation. */
export function getSeededProfileForNavigation(pubkey: string): TProfile | undefined { export function getSeededProfileForNavigation(pubkey: string): TProfile | undefined {
const pk = normPubkey(pubkey) const pk = normPubkey(pubkey)

19
src/lib/relay-list-sanitize.ts

@ -50,6 +50,25 @@ export function stripLocalNetworkRelaysFromRelayList(list: TRelayList): TRelayLi
} }
} }
/**
* Drop loopback/LAN WebSocket relay URLs before REQ they burn {@link MAX_CONCURRENT_RELAY_CONNECTIONS}
* slots and time out in the browser, delaying public relays (e.g. Damus) that actually hold kind 0.
*/
export function stripLocalNetworkRelaysForWssReq(urls: readonly string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const raw of urls) {
if (isHttpRelayUrl(raw)) continue
const n = normalizeAnyRelayUrl(raw) || raw.trim()
if (!n || isLocalNetworkUrl(n)) continue
const key = (normalizeUrl(n) || n).toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push(n)
}
return out
}
const normRelayKey = (u: string): string => { const normRelayKey = (u: string): string => {
const t = typeof u === 'string' ? u.trim() : '' const t = typeof u === 'string' ? u.trim() : ''
if (!t) return '' if (!t) return ''

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

@ -24,6 +24,7 @@ import logger from '@/lib/logger'
import client from './client.service' import client from './client.service'
import { buildComprehensiveRelayList, buildExploreProfileAndUserRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList, buildExploreProfileAndUserRelayList } from '@/lib/relay-list-builder'
import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility' import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility'
import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
export class ReplaceableEventService { export class ReplaceableEventService {
@ -53,35 +54,6 @@ export class ReplaceableEventService {
if (next) next() if (next) next()
} }
/**
* After a full profile fetch (cache + defaults + NIP-65 + comprehensive) returns nothing,
* skip repeating that expensive work for a few minutes. Cleared when we index kind 0 or user forces refresh.
*/
private static profileFetchMissUntil = new Map<string, number>()
private static readonly PROFILE_FETCH_MISS_TTL_MS = 10 * 60 * 1000
private static isProfileFetchMissCached(pubkey: string): boolean {
const k = pubkey.trim().toLowerCase()
const until = ReplaceableEventService.profileFetchMissUntil.get(k)
if (until == null) return false
if (Date.now() >= until) {
ReplaceableEventService.profileFetchMissUntil.delete(k)
return false
}
return true
}
private static rememberProfileFetchMiss(pubkey: string): void {
ReplaceableEventService.profileFetchMissUntil.set(
pubkey.trim().toLowerCase(),
Date.now() + ReplaceableEventService.PROFILE_FETCH_MISS_TTL_MS
)
}
private static clearProfileFetchMiss(pubkey: string): void {
ReplaceableEventService.profileFetchMissUntil.delete(pubkey.trim().toLowerCase())
}
/** True when kind 10002 exists locally — {@link client.fetchRelayList} would mostly merge IDB anyway. */ /** True when kind 10002 exists locally — {@link client.fetchRelayList} would mostly merge IDB anyway. */
private static async hasRelayListInLocalCache(pubkey: string): Promise<boolean> { private static async hasRelayListInLocalCache(pubkey: string): Promise<boolean> {
try { try {
@ -198,15 +170,6 @@ export class ReplaceableEventService {
} }
} }
if (
kind === kinds.Metadata &&
!d &&
containingEventRelays.length === 0 &&
ReplaceableEventService.isProfileFetchMissCached(pubkey)
) {
return undefined
}
// Kind 3 / NIP-65: IndexedDB + session LRU before DataLoader (newest wins); then background network refresh. // Kind 3 / NIP-65: IndexedDB + session LRU before DataLoader (newest wins); then background network refresh.
if (!d && (kind === kinds.Contacts || kind === kinds.RelayList)) { if (!d && (kind === kinds.Contacts || kind === kinds.RelayList)) {
let idbEv: NEvent | undefined | null let idbEv: NEvent | undefined | null
@ -252,7 +215,9 @@ export class ReplaceableEventService {
} }
// Not in IndexedDB, fetch from network with custom relay list // Not in IndexedDB, fetch from network with custom relay list
const relayUrls = await this.buildComprehensiveRelayListForAuthor(pubkey, kind, containingEventRelays, []) const relayUrls = stripLocalNetworkRelaysForWssReq(
await this.buildComprehensiveRelayListForAuthor(pubkey, kind, containingEventRelays, [])
)
const events = await this.queryService.query( const events = await this.queryService.query(
relayUrls, relayUrls,
{ {
@ -261,7 +226,7 @@ export class ReplaceableEventService {
}, },
undefined, undefined,
{ {
replaceableRace: true, replaceableRace: kind !== kinds.Metadata,
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
} }
@ -509,9 +474,6 @@ export class ReplaceableEventService {
eventsMap.set(`${m.pubkey}:${m.kind}`, ev) eventsMap.set(`${m.pubkey}:${m.kind}`, ev)
continue continue
} }
if (ReplaceableEventService.isProfileFetchMissCached(m.pubkey)) {
continue
}
} }
networkMissing.push(m) networkMissing.push(m)
} }
@ -614,7 +576,9 @@ export class ReplaceableEventService {
} else { } else {
relayUrls = [...FAST_READ_RELAY_URLS] relayUrls = [...FAST_READ_RELAY_URLS]
} }
relayUrls = prependAggrNostrLandIfViewerEligible(relayUrls) relayUrls = prependAggrNostrLandIfViewerEligible(
stripLocalNetworkRelaysForWssReq(relayUrls)
)
// Contacts + NIP-65 need the same patience as pins/payment: 100ms EOSE loses the race on slow relays // Contacts + NIP-65 need the same patience as pins/payment: 100ms EOSE loses the race on slow relays
// and multi-author batches must not use replaceableRace (first EVENT may not be the latest per author). // and multi-author batches must not use replaceableRace (first EVENT may not be the latest per author).
const isSlowReplaceableBatch = const isSlowReplaceableBatch =
@ -628,8 +592,10 @@ export class ReplaceableEventService {
const multiAuthorBatch = pubkeys.length > 1 const multiAuthorBatch = pubkeys.length > 1
// replaceableRace + default grace closes the REQ shortly after the first EVENT. For batched kind-0 // replaceableRace + default grace closes the REQ shortly after the first EVENT. For batched kind-0
// (many `authors` in one filter) that stops the subscription while most profiles are still in flight. // (many `authors` in one filter) that stops the subscription while most profiles are still in flight.
// Kind 0: never race — first relay may answer without Damus/mirrors; wait for EOSE window so the
// newest metadata across relays is collected (same as multi-author batches).
const useReplaceableRace = const useReplaceableRace =
!isSlowReplaceableBatch || !multiAuthorBatch kind === kinds.Metadata ? false : !isSlowReplaceableBatch || !multiAuthorBatch
const queryOpts = { const queryOpts = {
replaceableRace: useReplaceableRace, replaceableRace: useReplaceableRace,
eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
@ -643,7 +609,8 @@ export class ReplaceableEventService {
const slice = missingItems.slice(off, off + METADATA_BATCH_AUTHORS_CHUNK) const slice = missingItems.slice(off, off + METADATA_BATCH_AUTHORS_CHUNK)
const chunkPubkeys = slice.map((m) => m.pubkey) const chunkPubkeys = slice.map((m) => m.pubkey)
const chunkMulti = chunkPubkeys.length > 1 const chunkMulti = chunkPubkeys.length > 1
const chunkRace = !isSlowReplaceableBatch || !chunkMulti const chunkRace =
kind === kinds.Metadata ? false : !isSlowReplaceableBatch || !chunkMulti
const evts = await this.queryService.query( const evts = await this.queryService.query(
relayUrls, relayUrls,
{ authors: chunkPubkeys, kinds: [kind] }, { authors: chunkPubkeys, kinds: [kind] },
@ -881,7 +848,6 @@ export class ReplaceableEventService {
) )
await this.indexProfile(sessionEv) await this.indexProfile(sessionEv)
void indexedDb.putReplaceableEvent(sessionEv).catch(() => {}) void indexedDb.putReplaceableEvent(sessionEv).catch(() => {})
ReplaceableEventService.clearProfileFetchMiss(pubkey)
sessionFallback = sessionEv sessionFallback = sessionEv
} }
} }
@ -889,10 +855,6 @@ export class ReplaceableEventService {
// Relay hints from bech32 (nprofile, etc.) — highest priority in later steps // Relay hints from bech32 (nprofile, etc.) — highest priority in later steps
const relayHints = relays.length > 0 ? [...relays] : [] const relayHints = relays.length > 0 ? [...relays] : []
if (!_skipCache && relayHints.length === 0 && ReplaceableEventService.isProfileFetchMissCached(pubkey)) {
return sessionFallback
}
// CRITICAL: Always use relay hints from bech32 addresses (nprofile, naddr, nevent) when available // CRITICAL: Always use relay hints from bech32 addresses (nprofile, naddr, nevent) when available
// Relay hints should have highest priority and always be included // Relay hints should have highest priority and always be included
@ -944,6 +906,7 @@ export class ReplaceableEventService {
: [] : []
const expandedRelays = prependAggrNostrLandIfViewerEligible( const expandedRelays = prependAggrNostrLandIfViewerEligible(
stripLocalNetworkRelaysForWssReq(
Array.from( Array.from(
new Set([ new Set([
...relayHints, ...relayHints,
@ -953,6 +916,7 @@ export class ReplaceableEventService {
]) ])
) )
) )
)
const profileFromExpanded = await this.fetchReplaceableEvent( const profileFromExpanded = await this.fetchReplaceableEvent(
pubkey, pubkey,
@ -982,7 +946,9 @@ export class ReplaceableEventService {
}) })
if (comprehensiveRelays.length > 0) { if (comprehensiveRelays.length > 0) {
const relaysForQuery = prependAggrNostrLandIfViewerEligible(comprehensiveRelays) const relaysForQuery = prependAggrNostrLandIfViewerEligible(
stripLocalNetworkRelaysForWssReq(comprehensiveRelays)
)
const events = await this.queryService.query( const events = await this.queryService.query(
relaysForQuery, relaysForQuery,
{ {
@ -991,9 +957,9 @@ export class ReplaceableEventService {
}, },
undefined, undefined,
{ {
replaceableRace: true, replaceableRace: false,
eoseTimeout: 220, eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
globalTimeout: 3500 globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
} }
) )
@ -1014,9 +980,6 @@ export class ReplaceableEventService {
ReplaceableEventService.releaseProfileFallbackNetworkSlot() ReplaceableEventService.releaseProfileFallbackNetworkSlot()
} }
if (!_skipCache && relayHints.length === 0 && !sessionFallback) {
ReplaceableEventService.rememberProfileFetchMiss(pubkey)
}
return sessionFallback return sessionFallback
} }
@ -1165,9 +1128,6 @@ export class ReplaceableEventService {
* Index profile for search (calls callback if provided) * Index profile for search (calls callback if provided)
*/ */
private async indexProfile(profileEvent: NEvent): Promise<void> { private async indexProfile(profileEvent: NEvent): Promise<void> {
if (profileEvent.kind === kinds.Metadata) {
ReplaceableEventService.clearProfileFetchMiss(profileEvent.pubkey)
}
if (this.onProfileIndexed) { if (this.onProfileIndexed) {
await this.onProfileIndexed(profileEvent) await this.onProfileIndexed(profileEvent)
} }

16
src/services/client.service.ts

@ -187,7 +187,7 @@ import {
RelayPublishOpBatch, RelayPublishOpBatch,
RelaySubscribeOpBatch RelaySubscribeOpBatch
} from '@/services/relay-operation-log.service' } from '@/services/relay-operation-log.service'
import { QueryService } from './client-query.service' import { NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS, QueryService } from './client-query.service'
import { EventService } from './client-events.service' import { EventService } from './client-events.service'
import { ReplaceableEventService } from './client-replaceable-events.service' import { ReplaceableEventService } from './client-replaceable-events.service'
import { MacroService, createBookstrService } from './client-macro.service' import { MacroService, createBookstrService } from './client-macro.service'
@ -3562,11 +3562,19 @@ class ClientService extends EventTarget {
})() })()
: { ...filter, kinds: [kinds.Metadata] } : { ...filter, kinds: [kinds.Metadata] }
/** NIP-50 text on many index relays: per-relay EOSE can be ~38s; global cap was 9s so subs were torn down early. */
const filtersArr = Array.isArray(queryFilter) ? queryFilter : [queryFilter]
const usesNip50TextSearch = filtersArr.some(
(f) => typeof f.search === 'string' && f.search.trim().length > 0
)
const events = await this.queryService.query(urls, queryFilter, undefined, { const events = await this.queryService.query(urls, queryFilter, undefined, {
replaceableRace: false, replaceableRace: false,
eoseTimeout: 4500, eoseTimeout: usesNip50TextSearch ? 10_000 : 4500,
globalTimeout: 9000, globalTimeout: usesNip50TextSearch
relayOpSource: 'ClientService.searchProfiles' ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000
: 9000,
relayOpSource: 'ClientService.searchProfiles',
foreground: usesNip50TextSearch
}) })
const byPk = new Map<string, NEvent>() const byPk = new Map<string, NEvent>()

Loading…
Cancel
Save