From 1c0b429892160c8320a0d020234c6227249e0c3e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 14 May 2026 23:19:47 +0200 Subject: [PATCH] bug-fixes --- public/payto_logos/LBTC.svg | 32 +++++++ src/components/PaytoLink/index.tsx | 7 +- src/components/Profile/index.tsx | 34 ++++++- src/components/UserAvatar/index.tsx | 11 ++- src/components/Username/index.tsx | 6 +- src/constants.ts | 3 +- src/lib/payto.ts | 16 +++- src/lib/profile-navigation-seed.ts | 15 +++ src/lib/relay-list-sanitize.ts | 19 ++++ .../client-replaceable-events.service.ts | 96 ++++++------------- src/services/client.service.ts | 16 +++- 11 files changed, 169 insertions(+), 86 deletions(-) create mode 100644 public/payto_logos/LBTC.svg diff --git a/public/payto_logos/LBTC.svg b/public/payto_logos/LBTC.svg new file mode 100644 index 00000000..332cd405 --- /dev/null +++ b/public/payto_logos/LBTC.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/PaytoLink/index.tsx b/src/components/PaytoLink/index.tsx index 41d62e83..2a94ee00 100644 --- a/src/components/PaytoLink/index.tsx +++ b/src/components/PaytoLink/index.tsx @@ -76,7 +76,12 @@ export default function PaytoLink({ } 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 iconChar = getPaytoIconChar(type) const profileUrl = getPaytoProfileUrl(type, authority) diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 28640ac4..2cb02ba3 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -163,16 +163,38 @@ function mergePaymentMethods( 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) => { const net = w.network.toLowerCase() if (net === 'lightning') return const addr = w.address?.trim() if (!addr) return + const cur = (w.currency || '').trim().toLowerCase() + if (net === 'bitcoin') { add('bitcoin', addr, buildPaytoUri('bitcoin', addr), 'Bitcoin', { currency: w.currency }) - } else if (net === 'liquid') { - add('liquid', addr, buildPaytoUri('liquid', addr), 'Liquid', { currency: w.currency }) + return + } + + 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 list = mergePaymentMethods(paymentInfo, profile ?? null) 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) }) }, [paymentInfo, profile]) /** Group payment methods by displayType so same-type addresses render under one heading */ 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() for (const method of mergedPaymentMethods) { const key = method.displayType || method.type diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index e46216c6..486b7e45 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -5,7 +5,10 @@ import { toNostrBuildThumbUrl } from '@/lib/nostr-build' import { isImage, isMedia, isVideo } from '@/lib/url' import { generateImageByPubkey, isValidPubkey, userIdToPubkey } from '@/lib/pubkey' 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 { useSmartProfileNavigationOptional } from '@/PageManager' import type { TProfile } from '@/types' @@ -341,7 +344,11 @@ export default function UserAvatar({ onClick={(e) => { e.stopPropagation() if (!profileNavTarget) return - if (profile) seedProfileForNavigation(profile) + if (profile) { + seedProfileForNavigation(profile) + } else if (pubkey) { + seedProfileForNavigationFromSessionIfKnown(pubkey) + } navigateToProfile(toProfile(profileNavTarget)) }} > diff --git a/src/components/Username/index.tsx b/src/components/Username/index.tsx index a4cc8e8e..629d1e0c 100644 --- a/src/components/Username/index.tsx +++ b/src/components/Username/index.tsx @@ -1,7 +1,10 @@ import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' 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 { cn } from '@/lib/utils' import { useSmartProfileNavigationOptional } from '@/PageManager' @@ -88,6 +91,7 @@ export default function Username({ onClick={(e) => { e.stopPropagation() onNavigate?.() + seedProfileForNavigationFromSessionIfKnown(pubkey) navigateToProfile(toProfile(pubkey)) }} > diff --git a/src/constants.ts b/src/constants.ts index 467a970d..d48fe82e 100644 --- a/src/constants.ts +++ b/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 user’s * 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 = [ + { url: 'https://blossom.happytavern.co', labelKey: 'BlossomUploadOptionHappyTavern' }, { url: 'https://0x0.happytavern.co', labelKey: 'BlossomUploadOptionHappyTavern' }, { url: 'https://blossom.band', labelKey: 'BlossomUploadOptionBand' }, { url: 'https://blossom.primal.net', labelKey: 'BlossomUploadOptionPrimal' }, diff --git a/src/lib/payto.ts b/src/lib/payto.ts index 2b00e86f..175177fc 100644 --- a/src/lib/payto.ts +++ b/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) */ export const PAYTO_KNOWN_TYPES: Record< 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' }, + /** + * 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' }, - lightning: { label: 'Lightning Network', symbol: '⚡', category: 'lightning' }, + lightning: { label: 'Lightning Network', symbol: '⚡', category: 'bitcoin-layer' }, ethereum: { label: 'Ethereum', symbol: 'Ξ', category: 'crypto' }, monero: { label: 'Monero', 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. - * 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 = { btc: 'bitcoin', - lbtc: 'lightning', doge: 'dogecoin', eth: 'ethereum', 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. */ const PAYTO_LOGO_FILES: Record = { + liquid: 'LBTC.svg', + lbtc: 'LBTC.svg', ethereum: 'ethereum-eth-logo.svg', monero: 'Monero.png', litecoin: 'Litecoin.png', diff --git a/src/lib/profile-navigation-seed.ts b/src/lib/profile-navigation-seed.ts index 2bcf5cab..f37a489f 100644 --- a/src/lib/profile-navigation-seed.ts +++ b/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' const seeds = new Map() @@ -16,6 +19,18 @@ export function seedProfileForNavigation(profile: TProfile): void { 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. */ export function getSeededProfileForNavigation(pubkey: string): TProfile | undefined { const pk = normPubkey(pubkey) diff --git a/src/lib/relay-list-sanitize.ts b/src/lib/relay-list-sanitize.ts index 456aa18b..13cb04f3 100644 --- a/src/lib/relay-list-sanitize.ts +++ b/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() + 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 t = typeof u === 'string' ? u.trim() : '' if (!t) return '' diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 15e96bb6..6f93c55d 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -24,6 +24,7 @@ import logger from '@/lib/logger' import client from './client.service' import { buildComprehensiveRelayList, buildExploreProfileAndUserRelayList } from '@/lib/relay-list-builder' import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility' +import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' export class ReplaceableEventService { @@ -53,35 +54,6 @@ export class ReplaceableEventService { 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() - 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. */ private static async hasRelayListInLocalCache(pubkey: string): Promise { 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. if (!d && (kind === kinds.Contacts || kind === kinds.RelayList)) { let idbEv: NEvent | undefined | null @@ -252,7 +215,9 @@ export class ReplaceableEventService { } // 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( relayUrls, { @@ -261,7 +226,7 @@ export class ReplaceableEventService { }, undefined, { - replaceableRace: true, + replaceableRace: kind !== kinds.Metadata, eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } @@ -509,9 +474,6 @@ export class ReplaceableEventService { eventsMap.set(`${m.pubkey}:${m.kind}`, ev) continue } - if (ReplaceableEventService.isProfileFetchMissCached(m.pubkey)) { - continue - } } networkMissing.push(m) } @@ -614,7 +576,9 @@ export class ReplaceableEventService { } else { 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 // and multi-author batches must not use replaceableRace (first EVENT may not be the latest per author). const isSlowReplaceableBatch = @@ -628,8 +592,10 @@ export class ReplaceableEventService { const multiAuthorBatch = pubkeys.length > 1 // 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. + // 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 = - !isSlowReplaceableBatch || !multiAuthorBatch + kind === kinds.Metadata ? false : !isSlowReplaceableBatch || !multiAuthorBatch const queryOpts = { replaceableRace: useReplaceableRace, 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 chunkPubkeys = slice.map((m) => m.pubkey) const chunkMulti = chunkPubkeys.length > 1 - const chunkRace = !isSlowReplaceableBatch || !chunkMulti + const chunkRace = + kind === kinds.Metadata ? false : !isSlowReplaceableBatch || !chunkMulti const evts = await this.queryService.query( relayUrls, { authors: chunkPubkeys, kinds: [kind] }, @@ -881,7 +848,6 @@ export class ReplaceableEventService { ) await this.indexProfile(sessionEv) void indexedDb.putReplaceableEvent(sessionEv).catch(() => {}) - ReplaceableEventService.clearProfileFetchMiss(pubkey) sessionFallback = sessionEv } } @@ -889,10 +855,6 @@ export class ReplaceableEventService { // Relay hints from bech32 (nprofile, etc.) — highest priority in later steps 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 // Relay hints should have highest priority and always be included @@ -944,13 +906,15 @@ export class ReplaceableEventService { : [] const expandedRelays = prependAggrNostrLandIfViewerEligible( - Array.from( - new Set([ - ...relayHints, - ...authorRelays, - ...PROFILE_FETCH_RELAY_URLS, - ...FAST_READ_RELAY_URLS - ]) + stripLocalNetworkRelaysForWssReq( + Array.from( + new Set([ + ...relayHints, + ...authorRelays, + ...PROFILE_FETCH_RELAY_URLS, + ...FAST_READ_RELAY_URLS + ]) + ) ) ) @@ -982,7 +946,9 @@ export class ReplaceableEventService { }) if (comprehensiveRelays.length > 0) { - const relaysForQuery = prependAggrNostrLandIfViewerEligible(comprehensiveRelays) + const relaysForQuery = prependAggrNostrLandIfViewerEligible( + stripLocalNetworkRelaysForWssReq(comprehensiveRelays) + ) const events = await this.queryService.query( relaysForQuery, { @@ -991,9 +957,9 @@ export class ReplaceableEventService { }, undefined, { - replaceableRace: true, - eoseTimeout: 220, - globalTimeout: 3500 + replaceableRace: false, + eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, + globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } ) @@ -1014,9 +980,6 @@ export class ReplaceableEventService { ReplaceableEventService.releaseProfileFallbackNetworkSlot() } - if (!_skipCache && relayHints.length === 0 && !sessionFallback) { - ReplaceableEventService.rememberProfileFetchMiss(pubkey) - } return sessionFallback } @@ -1165,9 +1128,6 @@ export class ReplaceableEventService { * Index profile for search (calls callback if provided) */ private async indexProfile(profileEvent: NEvent): Promise { - if (profileEvent.kind === kinds.Metadata) { - ReplaceableEventService.clearProfileFetchMiss(profileEvent.pubkey) - } if (this.onProfileIndexed) { await this.onProfileIndexed(profileEvent) } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 65651b5f..ab6b9b8e 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -187,7 +187,7 @@ import { RelayPublishOpBatch, RelaySubscribeOpBatch } 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 { ReplaceableEventService } from './client-replaceable-events.service' import { MacroService, createBookstrService } from './client-macro.service' @@ -3562,11 +3562,19 @@ class ClientService extends EventTarget { })() : { ...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, { replaceableRace: false, - eoseTimeout: 4500, - globalTimeout: 9000, - relayOpSource: 'ClientService.searchProfiles' + eoseTimeout: usesNip50TextSearch ? 10_000 : 4500, + globalTimeout: usesNip50TextSearch + ? NIP50_QUERY_GLOBAL_TIMEOUT_FLOOR_MS + 18_000 + : 9000, + relayOpSource: 'ClientService.searchProfiles', + foreground: usesNip50TextSearch }) const byPk = new Map()