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()