diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx
index 5ab75cef..8367fc83 100644
--- a/src/components/NoteList/index.tsx
+++ b/src/components/NoteList/index.tsx
@@ -5,7 +5,6 @@ import {
FAST_READ_RELAY_URLS,
FIRST_RELAY_RESULT_GRACE_MS,
PROFILE_MEDIA_TAB_KINDS,
- PROFILE_RELAY_URLS,
SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS,
SINGLE_RELAY_KINDLESS_REQ_LIMIT
} from '@/constants'
@@ -24,7 +23,6 @@ import {
isSpellSubRequestsSameFiltersDifferentRelays
} from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger'
-import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
@@ -81,8 +79,8 @@ import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/prov
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { buildFeedFullSearchRelayUrls } from '@/lib/feed-full-search-relays'
import {
- getProfileAuthorWarmupRelayUrls,
getProfileAuthorWarmupSpec,
+ getProfileTimelineFetchRelayUrls,
isProfileTimelineSubscriptionKey
} from '@/lib/profile-author-warmup-spec'
import type { TProfile } from '@/types'
@@ -1988,11 +1986,20 @@ const NoteList = forwardRef(
feedTimelineScopePrevRef.current = undefined
}
+ const profileRelayStackRefinement =
+ preserveTimelineOnSubRequestsChange &&
+ mergeTimelineWhenSubRequestFiltersMatch &&
+ !userPulledRefresh &&
+ !feedScopeChanged &&
+ prevSubKey != null &&
+ (isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) ||
+ isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey))
+
const keepExistingTimelineEvents =
preserveTimelineOnSubRequestsChange &&
!userPulledRefresh &&
!feedScopeChanged &&
- eventsRef.current.length > 0 &&
+ (eventsRef.current.length > 0 || profileRelayStackRefinement) &&
(prevSubKey === subRequestsKey ||
isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) ||
(mergeTimelineWhenSubRequestFiltersMatch &&
@@ -2004,13 +2011,15 @@ const NoteList = forwardRef(
async function init() {
if (timelineEffectStale()) return undefined
- timelineMergeBootstrapRef.current = null
- feedPaintSessionPendingRef.current = false
- feedPaintRelayPendingRef.current = false
- feedPaintRelayMetaRef.current = null
- feedPaintLiveRelayDoneRef.current = false
- feedRelayReturnedAnyEventRef.current = false
- singleRelayKindlessFallbackAttemptedRef.current = false
+ if (!profileRelayStackRefinement) {
+ timelineMergeBootstrapRef.current = null
+ feedPaintSessionPendingRef.current = false
+ feedPaintRelayPendingRef.current = false
+ feedPaintRelayMetaRef.current = null
+ feedPaintLiveRelayDoneRef.current = false
+ feedRelayReturnedAnyEventRef.current = false
+ singleRelayKindlessFallbackAttemptedRef.current = false
+ }
// Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton.
const keepRowsVisible =
@@ -2122,18 +2131,15 @@ const NoteList = forwardRef(
? ALGO_LIMIT
: LIMIT
- /** Manual refresh on profile feeds: bounded fetch in parallel with subscribe (don't wait for EOSE outcomes). */
- if (userPulledRefresh && profileAuthorWarmSpecForRefresh && profileMappedForRefresh) {
+ /** Profile feeds: bounded fetch in parallel with subscribe (do not wait for EOSE / outcomes). */
+ const runProfileTimelineNetworkFetch = (variant: string) => {
+ if (!profileAuthorWarmSpecForRefresh || !profileMappedForRefresh) return
publicReadFallbackAttemptedRef.current = true
- const pullRefreshRelays = dedupeNormalizeRelayUrlsOrdered([
- ...getProfileAuthorWarmupRelayUrls(profileMappedForRefresh),
- ...FAST_READ_RELAY_URLS,
- ...PROFILE_RELAY_URLS
- ]).slice(0, 24)
+ const primeRelays = getProfileTimelineFetchRelayUrls(profileMappedForRefresh)
void (async () => {
try {
const fetched = await client.fetchEvents(
- pullRefreshRelays,
+ primeRelays,
{
authors: [profileAuthorWarmSpecForRefresh.author],
kinds: profileAuthorWarmSpecForRefresh.kinds,
@@ -2166,17 +2172,23 @@ const NoteList = forwardRef(
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
- variant: 'profile_pull_refresh',
+ variant,
mergedCount: narrowedFetch.length
}
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
} catch (e) {
- logger.warn('[NoteList] Profile pull refresh network fetch failed', { error: e })
+ logger.warn('[NoteList] Profile timeline network fetch failed', { variant, error: e })
}
})()
}
+ if (isProfileTimelineFeed && profileAuthorWarmSpecForRefresh && profileMappedForRefresh) {
+ runProfileTimelineNetworkFetch(
+ userPulledRefresh ? 'profile_pull_refresh' : 'profile_initial_fetch'
+ )
+ }
+
const isSpellPageLocalWarmup =
hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0
@@ -2448,7 +2460,8 @@ const NoteList = forwardRef(
if (
isProfileTimelineFeed &&
profileAuthorWarmSpec &&
- !timelineEffectStale()
+ !timelineEffectStale() &&
+ !profileRelayStackRefinement
) {
const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800))
const sessionHits = client.eventService.listSessionEventsAuthoredBy(
@@ -2523,7 +2536,7 @@ const NoteList = forwardRef(
setFeedTimelineEmptyUiReady(true)
}
- const relayUrls = getProfileAuthorWarmupRelayUrls(profileMapped)
+ const relayUrls = getProfileTimelineFetchRelayUrls(profileMapped)
if (relayUrls.length > 0) {
const fetched = await client.fetchEvents(
relayUrls,
@@ -2566,12 +2579,14 @@ const NoteList = forwardRef(
})()
}
}
- if (!primedFromDisk) {
+ if (!primedFromDisk && !profileRelayStackRefinement) {
if (!keepRowsVisible) setLoading(true)
timelineMergeBootstrapRef.current = []
setEvents([])
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
+ } else if (!keepRowsVisible && !profileRelayStackRefinement) {
+ setLoading(true)
}
}
} else if (!keepRowsVisible) {
@@ -3700,13 +3715,6 @@ const NoteList = forwardRef(
mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
)
: null
- const profileRelayUrls =
- profileWarm != null
- ? getProfileAuthorWarmupRelayUrls(
- mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
- )
- : []
-
/** EOSE with zero hits still counts as success; profile feeds need fallback until rows are visible. */
if (!profileWarm && uiStatuses.some((s) => s.success)) return
if (profileWarm && eventsRef.current.length > 0) return
@@ -3730,11 +3738,9 @@ const NoteList = forwardRef(
: LIMIT
const fallbackRelays = profileWarm
- ? dedupeNormalizeRelayUrlsOrdered([
- ...profileRelayUrls,
- ...FAST_READ_RELAY_URLS,
- ...PROFILE_RELAY_URLS
- ]).slice(0, 24)
+ ? getProfileTimelineFetchRelayUrls(
+ mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
+ )
: FAST_READ_RELAY_URLS
void (async () => {
diff --git a/src/components/PaytoDialog/index.tsx b/src/components/PaytoDialog/index.tsx
index ff38d5bd..82b698ef 100644
--- a/src/components/PaytoDialog/index.tsx
+++ b/src/components/PaytoDialog/index.tsx
@@ -6,11 +6,14 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
-import { Copy } from 'lucide-react'
+import { Copy, ExternalLink, Wallet, Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
-import { getPaytoTypeInfo, getPaytoProfileUrl } from '@/lib/payto'
-import { Zap, ExternalLink } from 'lucide-react'
+import {
+ filterPaytoPaymentOpenHandlersForDevice,
+ getPaytoPaymentOpenHandlers,
+ getPaytoTypeInfo
+} from '@/lib/payto'
export default function PaytoDialog({
open,
@@ -29,11 +32,13 @@ export default function PaytoDialog({
const info = getPaytoTypeInfo(type)
const label = info?.label ?? type
const isLightning = type.toLowerCase() === 'lightning'
- const profileUrl = getPaytoProfileUrl(type, authority)
+ const openHandlers = filterPaytoPaymentOpenHandlersForDevice(
+ getPaytoPaymentOpenHandlers(type, authority)
+ )
- const handleCopy = (text: string, label?: string) => {
+ const handleCopy = (text: string, copyLabel?: string) => {
navigator.clipboard.writeText(text)
- toast.success(label ? t('Copied {{label}} address', { label }) : t('Copied to clipboard'))
+ toast.success(copyLabel ? t('Copied {{label}} address', { label: copyLabel }) : t('Copied to clipboard'))
onOpenChange(false)
}
@@ -51,21 +56,13 @@ export default function PaytoDialog({
: t('Payment address – copy to use in your wallet or app')}
-
+
{authority}
- {profileUrl && (
-
- )}
+ {openHandlers.length > 0 && (
+
+ )}
diff --git a/src/components/PaytoLink/index.tsx b/src/components/PaytoLink/index.tsx
index fbd2d921..188d68df 100644
--- a/src/components/PaytoLink/index.tsx
+++ b/src/components/PaytoLink/index.tsx
@@ -8,7 +8,6 @@ import {
getPaytoTypeInfo,
getPaytoIconChar,
getPaytoLogoPath,
- getPaytoProfileUrl,
isKnownPaytoType,
isLightningPaytoType,
isZappableLightningPaytoType,
@@ -92,7 +91,6 @@ export default function PaytoLink({
})()
const logoPath = getPaytoLogoPath(type)
const iconChar = getPaytoIconChar(type)
- const profileUrl = getPaytoProfileUrl(type, authority)
const childText = flattenPaytoLinkChildText(children)
const useCompactDisplay =
displayFormat === 'compact' &&
@@ -106,6 +104,11 @@ export default function PaytoLink({
)
const overrideTip = linkTitle?.trim()
const fullAddressTip = `${displayLabel}: ${authority}`
+ const paymentOptionsTip = known
+ ? categoryLabel
+ ? `${displayLabel} (${categoryLabel}): ${t('Click to open payment options')}`
+ : `${displayLabel}: ${t('Click to open payment options')}`
+ : t('Click to copy address')
const iconEl = (
@@ -124,33 +127,6 @@ export default function PaytoLink({
)
- if (profileUrl) {
- return (
-
e.stopPropagation()}
- >
- {iconEl}
- {content}
-
- )
- }
-
return (
<>
- {known && (
+ {known && !canZap && (
normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001')
-}
-
-const MEDIA_LOG = '[ProfileMedia]'
const ProfileMediaFeed = forwardRef(({ pubkey }, ref) => {
- const { t } = useTranslation()
const nostr = useNostrOptional()
const { blockedRelays } = useFavoriteRelays()
- const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays])
const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim()
const pk = pubkey?.trim()
@@ -33,68 +23,31 @@ const ProfileMediaFeed = forwardRef(({ pubkey
}
}, [nostr?.pubkey, pubkey])
- /**
- * Before NIP-65: empty author tier so REQ still uses read-only + fast-read; refine when
- * {@link client.fetchRelayList} returns.
- */
- const provisionalAuthorRelayUrls = useMemo(() => {
- if (!pubkey?.trim()) return [] as string[]
- return buildAuthorInboxOutboxRelayUrls({ read: [], write: [] }, blockedRelays, includeAuthorLocalRelays)
- }, [pubkey, blockedKey, blockedRelays, includeAuthorLocalRelays])
-
- const [refinedAuthorRelayUrls, setRefinedAuthorRelayUrls] = useState(null)
+ const [authorRelayUrls, setAuthorRelayUrls] = useState(null)
useEffect(() => {
const pk = pubkey?.trim()
if (!pk) {
- logger.debug(`${MEDIA_LOG} empty pubkey — no relay resolution`)
- setRefinedAuthorRelayUrls([])
+ setAuthorRelayUrls([])
return
}
let cancelled = false
- setRefinedAuthorRelayUrls(null)
- void (async () => {
- try {
- const peeked = await client.peekRelayListFromStorage(pk)
- if (!cancelled) {
- setRefinedAuthorRelayUrls(
- buildAuthorInboxOutboxRelayUrls(peeked, blockedRelays, includeAuthorLocalRelays)
- )
- }
- } catch {
- /* keep provisionalAuthorRelayUrls */
- }
- const authorRl = await client.fetchRelayList(pk).catch(() => ({
- read: [] as string[],
- write: [] as string[]
- }))
- if (cancelled) return
- const authorStack = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays, includeAuthorLocalRelays)
- const hexPk = normalizeHexPubkey(pk)
- logger.debug(`${MEDIA_LOG} NIP-65 author relays resolved for media tab`, {
- pubkey: hexPk.slice(0, 8),
- authorReadCount: authorRl.read?.length ?? 0,
- authorWriteCount: authorRl.write?.length ?? 0,
- authorRelayCount: authorStack.length,
- authorRelaysSample: authorStack.slice(0, 4)
+ setAuthorRelayUrls(null)
+ void client
+ .fetchRelayList(pk)
+ .catch(() => ({ read: [] as string[], write: [] as string[] }))
+ .then((authorRl) => {
+ if (cancelled) return
+ setAuthorRelayUrls(buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays, includeAuthorLocalRelays))
})
- logger.debug(`${MEDIA_LOG} author inbox/outbox relay list`, { authorRelays: authorStack })
- setRefinedAuthorRelayUrls(authorStack)
- })()
return () => {
cancelled = true
}
- }, [pubkey, blockedKey, blockedRelays, includeAuthorLocalRelays])
-
- /** Empty NIP-65 stack is not “unknown” — fall back to provisional tier so augmented read relays still apply. */
- const authorRelayUrls =
- refinedAuthorRelayUrls != null && refinedAuthorRelayUrls.length > 0
- ? refinedAuthorRelayUrls
- : provisionalAuthorRelayUrls
+ }, [pubkey, blockedRelays, includeAuthorLocalRelays])
const subRequests = useMemo(() => {
const pk = pubkey?.trim()
- if (!pk) return []
+ if (!pk || !authorRelayUrls?.length) return []
return buildProfileMediaSubRequests(authorRelayUrls, blockedRelays, pk)
}, [pubkey, authorRelayUrls, blockedRelays])
@@ -104,43 +57,19 @@ const ProfileMediaFeed = forwardRef(({ pubkey
return `profile-media-${normalizeHexPubkey(pk)}`
}, [pubkey])
- useEffect(() => {
- const pk = pubkey?.trim()
- if (!pk) return
- if (!subRequests.length) {
- logger.debug(`${MEDIA_LOG} buildProfileMediaSubRequests returned no URLs (blocked or empty stacks)`, {
- pubkey: normalizeHexPubkey(pk).slice(0, 8),
- authorRelayCount: authorRelayUrls.length
- })
- return
- }
- const sr = subRequests[0]!
- logger.debug(`${MEDIA_LOG} subRequests ready for NoteList`, {
- pubkey: normalizeHexPubkey(pk).slice(0, 8),
- feedSubscriptionKey,
- relayCount: sr.urls.length,
- filterAuthors: sr.filter.authors,
- filterKinds: sr.filter.kinds,
- filterLimit: sr.filter.limit
- })
- logger.debug(`${MEDIA_LOG} augmented relay URLs`, { urls: sr.urls })
- }, [pubkey, authorRelayUrls, subRequests, feedSubscriptionKey, refinedAuthorRelayUrls])
-
const showKinds = useMemo(() => [...PROFILE_MEDIA_TAB_KINDS], [])
- if (!pubkey?.trim()) {
+ if (authorRelayUrls === null) {
return (
-
- {t('Nothing to load for this feed.')}
+
+ {/* Skeleton while author NIP-65 resolves — avoids provisional→refined subRequest churn */}
)
}
if (!subRequests.length) {
return (
-
- {t('Nothing to load for this feed.')}
-
+
)
}
@@ -153,12 +82,8 @@ const ProfileMediaFeed = forwardRef
(({ pubkey
hostPrimaryPageName="profile"
showKinds={showKinds}
useFilterAsIs
- /**
- * Provisional author tier (empty) then NIP-65 inbox/outbox refinement; REQ filter unchanged — merge rows.
- */
preserveTimelineOnSubRequestsChange
mergeTimelineWhenSubRequestFiltersMatch
- /** Same live {@link client.subscribeTimeline} path as {@link useProfileTimeline} on the Posts tab; filter is native media kinds only. */
revealBatchSize={48}
filterMutedNotes={false}
showKind1OPs
diff --git a/src/components/ProfileAbout/index.tsx b/src/components/ProfileAbout/index.tsx
index 8a7a6784..240a3831 100644
--- a/src/components/ProfileAbout/index.tsx
+++ b/src/components/ProfileAbout/index.tsx
@@ -1,6 +1,7 @@
import {
EmbeddedHashtagParser,
EmbeddedMentionParser,
+ EmbeddedAboutCoinPaytoParser,
EmbeddedPaytoParser,
EmbeddedUrlParser,
EmbeddedWebsocketUrlParser,
@@ -40,6 +41,7 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
if (core) {
const coreNodes = parseContent(core, [
+ EmbeddedAboutCoinPaytoParser,
EmbeddedWebsocketUrlParser,
EmbeddedUrlParser,
EmbeddedPaytoParser,
diff --git a/src/data/payto-types.json b/src/data/payto-types.json
index 5ed50598..f7222a78 100644
--- a/src/data/payto-types.json
+++ b/src/data/payto-types.json
@@ -3,6 +3,45 @@
"logoAssetsDir": "src/assets/payto_logos",
"logoAssetPathNote": "Repo-relative path to the logo file. The app bundles these via Vite and exposes them under /assets/ at runtime (see getPaytoLogoPath)."
},
+ "walletApps": {
+ "cakewallet": {
+ "label": "Cake Wallet",
+ "mobileOnly": true,
+ "uriTemplate": "cakewallet:{coinScheme}?address={authority}"
+ },
+ "phoenix": {
+ "label": "Phoenix",
+ "mobileOnly": true,
+ "uriTemplate": "phoenix:pay?uri={coinScheme}:{authority}"
+ }
+ },
+ "kind0CryptocurrencyAddresses": {
+ "monero": "monero",
+ "xmr": "monero",
+ "bitcoin": "bitcoin",
+ "btc": "bitcoin",
+ "ethereum": "ethereum",
+ "eth": "ethereum",
+ "litecoin": "litecoin",
+ "ltc": "litecoin",
+ "dogecoin": "dogecoin",
+ "doge": "dogecoin",
+ "nano": "nano",
+ "xno": "nano",
+ "solana": "solana",
+ "sol": "solana",
+ "bitcoin-cash": "bitcoin-cash",
+ "bch": "bitcoin-cash"
+ },
+ "kind0RootPaymentFields": {
+ "monero": "monero",
+ "bitcoin": "bitcoin",
+ "ethereum": "ethereum",
+ "litecoin": "litecoin",
+ "dogecoin": "dogecoin",
+ "nano": "nano",
+ "solana": "solana"
+ },
"editorOrder": [
"lightning",
"bitcoin",
@@ -60,6 +99,10 @@
"symbol": "₿",
"category": "bitcoin",
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
+ "walletOpen": {
+ "scheme": "bitcoin",
+ "walletApps": ["cakewallet"]
+ },
"authority": {
"placeholder": "bc1q…",
"hint": "On-chain Bitcoin address (Bech32 bc1… preferred)"
@@ -70,15 +113,25 @@
"symbol": "₿",
"category": "bitcoin",
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
+ "walletOpen": {
+ "scheme": "bolt12",
+ "requirePrefix": "lno1",
+ "walletApps": ["phoenix"]
+ },
"authority": {
"placeholder": "lno1…",
- "hint": "BOLT-12 offer (static offer string, e.g. lno1…)"
+ "hint": "BOLT-12 offer (static offer string, e.g. lno1…). Phoenix 2.3.1+ can pay offers; some legacy offers may be rejected."
}
},
"bip353": {
"label": "DNS Payment Instructions (BIP-353)",
"symbol": "⚡",
"category": "bitcoin-layer",
+ "walletOpen": {
+ "scheme": "lightning",
+ "requireAtSign": true,
+ "walletApps": ["phoenix"]
+ },
"authority": {
"placeholder": "user@example.com",
"hint": "BIP-353 DNS payment instructions (human-readable name@domain, resolves to Lightning)"
@@ -89,6 +142,11 @@
"symbol": "₿",
"category": "bitcoin",
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
+ "walletOpen": {
+ "scheme": "bitcoin",
+ "requirePrefix": "sp1",
+ "walletApps": ["cakewallet"]
+ },
"authority": {
"placeholder": "sp1…",
"hint": "BIP-352 silent payment address (sp1…)"
@@ -118,6 +176,11 @@
"label": "Lightning Network",
"symbol": "⚡",
"category": "bitcoin-layer",
+ "walletOpen": {
+ "scheme": "lightning",
+ "requireAtSign": true,
+ "walletApps": ["phoenix"]
+ },
"authority": {
"placeholder": "user@getalby.com",
"hint": "Lightning address (LUD-16): name@domain — not a BOLT11 invoice"
@@ -128,6 +191,10 @@
"symbol": "Ξ",
"category": "crypto",
"logoAssetPath": "src/assets/payto_logos/ethereum-eth-logo.svg",
+ "walletOpen": {
+ "scheme": "ethereum",
+ "walletApps": ["cakewallet"]
+ },
"authority": {
"placeholder": "0x…",
"hint": "Ethereum address (0x + 40 hex characters)"
@@ -138,6 +205,10 @@
"symbol": "ɱ",
"category": "crypto",
"logoAssetPath": "src/assets/payto_logos/Monero.png",
+ "walletOpen": {
+ "scheme": "monero",
+ "walletApps": ["cakewallet"]
+ },
"authority": {
"placeholder": "4… or 8…",
"hint": "Monero primary address (starts with 4 or 8)"
@@ -147,6 +218,10 @@
"label": "Nano",
"symbol": "Ӿ",
"category": "crypto",
+ "walletOpen": {
+ "scheme": "nano",
+ "walletApps": ["cakewallet"]
+ },
"authority": {
"placeholder": "nano_…",
"hint": "Nano account address (nano_ prefix)"
@@ -190,6 +265,10 @@
"symbol": "₿",
"category": "crypto",
"logoAssetPath": "src/assets/payto_logos/bitcoin-cash-bch-logo.svg",
+ "walletOpen": {
+ "scheme": "bitcoincash",
+ "walletApps": ["cakewallet"]
+ },
"authority": {
"placeholder": "bitcoincash:… or q…",
"hint": "Bitcoin Cash address (CashAddr or legacy)"
@@ -200,6 +279,10 @@
"symbol": "Ð",
"category": "crypto",
"logoAssetPath": "src/assets/payto_logos/dogecoin-doge-logo.svg",
+ "walletOpen": {
+ "scheme": "dogecoin",
+ "walletApps": ["cakewallet"]
+ },
"authority": {
"placeholder": "D…",
"hint": "Dogecoin address (usually starts with D)"
@@ -210,6 +293,10 @@
"symbol": "Ł",
"category": "crypto",
"logoAssetPath": "src/assets/payto_logos/Litecoin.png",
+ "walletOpen": {
+ "scheme": "litecoin",
+ "walletApps": ["cakewallet"]
+ },
"authority": {
"placeholder": "ltc1q… or L… or M…",
"hint": "Litecoin address"
@@ -260,6 +347,10 @@
"symbol": "◎",
"category": "crypto",
"logoAssetPath": "src/assets/payto_logos/solana.png",
+ "walletOpen": {
+ "scheme": "solana",
+ "walletApps": ["cakewallet"]
+ },
"authority": {
"placeholder": "Base58 pubkey…",
"hint": "Solana wallet address (base58, 32–44 characters)"
diff --git a/src/hooks/useProfileAuthorFeedSubRequests.ts b/src/hooks/useProfileAuthorFeedSubRequests.ts
index 1b92e00b..38d4eafc 100644
--- a/src/hooks/useProfileAuthorFeedSubRequests.ts
+++ b/src/hooks/useProfileAuthorFeedSubRequests.ts
@@ -73,32 +73,20 @@ export function useProfileAuthorFeedSubRequests({
}, [pubkey])
const [refreshToken, setRefreshToken] = useState(0)
- const [provisionalUrls, setProvisionalUrls] = useState([])
- const [fullUrls, setFullUrls] = useState(null)
+ /** Single emission per visit: provisional→full relay stacks used to restart NoteList and wipe rows mid-fetch. */
+ const [relayUrls, setRelayUrls] = useState(null)
useEffect(() => {
let cancelled = false
+ setRelayUrls(null)
const socialKinds = kinds.some(isSocialKindBlockedKind)
- const provisional = buildProfilePageReadRelayUrls(
- favoriteRelays,
- blockedRelays,
- emptyAuthor,
- socialKinds,
- includeAuthorLocalRelays,
- kinds,
- useGlobalRelayBootstrap
- )
- if (!cancelled) {
- setProvisionalUrls(provisional)
- setFullUrls(null)
- }
void client
.fetchRelayList(pubkey)
.catch(() => emptyAuthor)
.then((authorRl) => {
if (cancelled) return
- const full = buildProfilePageReadRelayUrls(
+ const urls = buildProfilePageReadRelayUrls(
favoriteRelays,
blockedRelays,
authorRl,
@@ -107,23 +95,18 @@ export function useProfileAuthorFeedSubRequests({
kinds,
useGlobalRelayBootstrap
)
- setFullUrls(full)
+ setRelayUrls(urls)
})
return () => {
cancelled = true
}
- // `relayListsKey` already fingerprints `favoriteRelays` + `blockedRelays` by sorted URL content.
- // Do not list those arrays here: the provider often hands new `[]` references each render and would
- // retrigger this effect forever (setState → re-render → new refs → effect → …).
}, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, includeAuthorLocalRelays, useGlobalRelayBootstrap])
- const activeUrls = fullUrls?.length ? fullUrls : provisionalUrls
-
const subRequests = useMemo(() => {
- if (!activeUrls.length) return [] as TFeedSubRequest[]
- return buildProfileAuthorSubRequestsFromUrlGroups([activeUrls], authorHex, [...kinds], limit)
- }, [activeUrls, authorHex, kinds, limit])
+ if (!relayUrls?.length) return [] as TFeedSubRequest[]
+ return buildProfileAuthorSubRequestsFromUrlGroups([relayUrls], authorHex, [...kinds], limit)
+ }, [relayUrls, authorHex, kinds, limit])
const followingFeedDeltaSubRequests = useMemo(() => [] as TFeedSubRequest[], [])
diff --git a/src/hooks/useProfilePins.tsx b/src/hooks/useProfilePins.tsx
index 3faf9233..4d970e7e 100644
--- a/src/hooks/useProfilePins.tsx
+++ b/src/hooks/useProfilePins.tsx
@@ -80,7 +80,7 @@ function blockedRelaysContentKey(blockedRelays: string[]): string {
export function useProfilePins(pubkey: string | undefined) {
const nostr = useNostrOptional()
const { blockedRelays } = useFavoriteRelays()
- const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
+ const viewerUsesGlobalBootstrap = useGlobalRelayBootstrapDefaults()
const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays])
const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim()
@@ -92,6 +92,7 @@ export function useProfilePins(pubkey: string | undefined) {
return false
}
}, [nostr?.pubkey, pubkey])
+ const useGlobalRelayBootstrap = viewerUsesGlobalBootstrap || !includeAuthorLocalRelays
const [pinEvents, setPinEvents] = useState([])
const [loadingPins, setLoadingPins] = useState(false)
diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx
index e8551d31..31146d86 100644
--- a/src/hooks/useProfileTimeline.tsx
+++ b/src/hooks/useProfileTimeline.tsx
@@ -142,7 +142,7 @@ export function useProfileTimeline({
}: UseProfileTimelineOptions): UseProfileTimelineResult {
const nostr = useNostrOptional()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
- const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
+ const viewerUsesGlobalBootstrap = useGlobalRelayBootstrapDefaults()
const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim()
if (!me) return false
@@ -152,6 +152,8 @@ export function useProfileTimeline({
return false
}
}, [nostr?.pubkey, pubkey])
+ /** Own profile: honor viewer relay prefs. Other profiles: always widen with FAST_READ / profile index relays. */
+ const useGlobalRelayBootstrap = viewerUsesGlobalBootstrap || !includeAuthorLocalRelays
const relayListsKey = useMemo(
() => relayListsContentKey(favoriteRelays, blockedRelays),
[favoriteRelays, blockedRelays]
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 7aed15ed..e8e17bc1 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -128,6 +128,9 @@ export default {
"Click to open payment options": "Click to open payment options",
"Click to copy address": "Click to copy address",
"Open on website": "Open on website",
+ "Open in wallet": "Open in wallet",
+ "Open in {{name}}": "Open in {{name}}",
+ "Open with": "Open with",
"Raw profile event": "Raw profile event",
"Full profile event": "Full profile event",
"Event (JSON)": "Event (JSON)",
diff --git a/src/lib/content-parser.ts b/src/lib/content-parser.ts
index b0cc4e6f..4359c02f 100644
--- a/src/lib/content-parser.ts
+++ b/src/lib/content-parser.ts
@@ -11,6 +11,7 @@ import {
EMOJI_SHORT_CODE_REGEX
} from '@/lib/content-patterns'
import { PAYTO_URI_REGEX } from '@/lib/payto'
+import { parseAboutContentWithCoinPayto } from '@/lib/payto-about-coin-lines'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import { isImage, isMedia, isHlsPlaylistUrl, isBlossomBudBlobUrl } from './url'
import { isSpotifyOpenUrl } from './spotify-url'
@@ -84,6 +85,9 @@ export const EmbeddedPaytoParser: TContentParser = {
regex: PAYTO_URI_REGEX
}
+/** `XMR: 4abc…` lines in profile about (catalog coin labels). */
+export const EmbeddedAboutCoinPaytoParser: TContentParser = parseAboutContentWithCoinPayto
+
export const EmbeddedUrlParser: TContentParser = (content: string) => {
const matches = content.matchAll(URL_REGEX)
const result: TEmbeddedNode[] = []
diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts
index 4843b017..a1af85e3 100644
--- a/src/lib/favorites-feed-relays.ts
+++ b/src/lib/favorites-feed-relays.ts
@@ -65,7 +65,10 @@ export function getFavoritesFeedRelayUrls(
/**
* Merge relay URL lists in order; first occurrence wins; drops blocked.
*/
-export function mergeRelayUrlLayers(layers: string[][], blockedRelays: string[]): string[] {
+export function mergeRelayUrlLayers(
+ layers: readonly (readonly string[])[],
+ blockedRelays: string[]
+): string[] {
const blocked = blockedSet(blockedRelays)
const seen = new Set()
const out: string[] = []
@@ -115,10 +118,27 @@ export function buildProfileAugmentedReadRelayUrls(
useGlobalRelayBootstrap
? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[])
: []
- const merged = mergeRelayUrlLayers([authorRelayUrls, fastReadLayer], blockedRelays)
+ const merged = mergeRelayUrlLayers(
+ useGlobalRelayBootstrap ? [fastReadLayer, authorRelayUrls] : [authorRelayUrls, fastReadLayer],
+ blockedRelays
+ )
return merged.slice(0, maxRelays)
}
+/**
+ * Another user's NIP-65 read/write lists can fill {@link PROFILE_PAGE_FEED_MAX_RELAYS} before the fast-read
+ * tier is reached in {@link feedRelayPolicyUrls}, so kind 1 / 1111 REQs never hit relays that carry them.
+ */
+function pinFastReadForRemoteProfileFeed(
+ urls: string[],
+ fastReadLayer: readonly string[],
+ blockedRelays: string[],
+ maxRelays: number
+): string[] {
+ if (!fastReadLayer.length) return urls.slice(0, maxRelays)
+ return mergeRelayUrlLayers([fastReadLayer, urls], blockedRelays).slice(0, maxRelays)
+}
+
export type ReadRelayPriorityOptions = {
/** User NIP-65 write list — local URLs are promoted with inboxes for REQ. */
userWriteRelays?: string[]
@@ -218,17 +238,29 @@ export function buildProfilePageReadRelayUrls(
allowThirdPartyLocalRelays: true
}
)
+ const pinFastReadForRemote = useGlobal && !includeAuthorLocalRelays
+
/** Authors without kind 10002: widen REQ targets so notes/metadata are still discoverable on index relays. */
if (authorHasNoNip65) {
const profileSource = useGlobal ? PROFILE_RELAY_URLS : profileFetchRelayUrlsWithoutFastReadLayer()
const profileFetchLayer = profileSource.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
- return mergeRelayUrlLayers([urls, profileFetchLayer], blockedRelays).slice(0, maxRelays + 8)
+ const cap = maxRelays + 8
+ const merged = mergeRelayUrlLayers([urls, profileFetchLayer], blockedRelays).slice(0, cap)
+ return pinFastReadForRemote
+ ? pinFastReadForRemoteProfileFeed(merged, fastReadLayer, blockedRelays, cap)
+ : merged
}
if (wantsDocumentLayer) {
const docLayer = DOCUMENT_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
- return mergeRelayUrlLayers([urls, docLayer], blockedRelays).slice(0, maxRelays + 6)
+ const cap = maxRelays + 6
+ const merged = mergeRelayUrlLayers([urls, docLayer], blockedRelays).slice(0, cap)
+ return pinFastReadForRemote
+ ? pinFastReadForRemoteProfileFeed(merged, fastReadLayer, blockedRelays, cap)
+ : merged
}
- return urls
+ return pinFastReadForRemote
+ ? pinFastReadForRemoteProfileFeed(urls, fastReadLayer, blockedRelays, maxRelays)
+ : urls
}
/**
diff --git a/src/lib/merge-payment-methods.test.ts b/src/lib/merge-payment-methods.test.ts
index d6fa6287..f5cbdebd 100644
--- a/src/lib/merge-payment-methods.test.ts
+++ b/src/lib/merge-payment-methods.test.ts
@@ -134,6 +134,46 @@ describe('buildOrderedZapLightningAddresses', () => {
})
})
+describe('mergePaymentMethods kind 0 about coin lines', () => {
+ it('imports XMR from about text', () => {
+ const addr =
+ '84mAJEgdihyRHkz8fGeuqgbQ19SuGeFWbhokJG2uMNMwTkDyoyQ3H7BijQNwSriSp9hHfaRGZYpCuKvHJwTer8av845U9py'
+ const profileEvent = {
+ kind: kinds.Metadata,
+ pubkey: 'aa'.repeat(32),
+ created_at: 1,
+ tags: [] as string[][],
+ content: JSON.stringify({
+ about: `https://example.com\n\nXMR: ${addr}`
+ }),
+ id: 'bb'.repeat(64),
+ sig: 'cc'.repeat(128)
+ } as Event
+
+ const methods = mergePaymentMethods(null, null, profileEvent)
+ expect(methods.some((m) => m.type === 'monero' && m.authority === addr)).toBe(true)
+ })
+})
+
+describe('mergePaymentMethods kind 0 cryptocurrency_addresses', () => {
+ it('imports Garnet monero from profile JSON', () => {
+ const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ const profileEvent = {
+ kind: kinds.Metadata,
+ pubkey: 'aa'.repeat(32),
+ created_at: 1,
+ tags: [] as string[][],
+ content: JSON.stringify({ cryptocurrency_addresses: { monero: addr } }),
+ id: 'bb'.repeat(64),
+ sig: 'cc'.repeat(128)
+ } as Event
+
+ const methods = mergePaymentMethods(null, null, profileEvent)
+ expect(methods.some((m) => m.type === 'monero' && m.authority === addr)).toBe(true)
+ expect(methods.find((m) => m.type === 'monero')?.payto).toBe(`payto://monero/${addr}`)
+ })
+})
+
describe('prepareZapDialogAlternativePayments', () => {
const groups = [
{
diff --git a/src/lib/merge-payment-methods.ts b/src/lib/merge-payment-methods.ts
index 696e2e12..6f0a1636 100644
--- a/src/lib/merge-payment-methods.ts
+++ b/src/lib/merge-payment-methods.ts
@@ -4,9 +4,11 @@ import {
getCanonicalPaytoType,
getPaytoEditorTypeLabel,
getPaytoTypeInfo,
+ isKnownPaytoType,
isLightningPaytoType,
isZappableLightningPaytoType
} from '@/lib/payto'
+import { extractKind0PaymentMethodsFromProfileJson } from '@/lib/payto-kind0-import'
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url'
import type { TProfile } from '@/types'
import { kinds, type Event } from 'nostr-tools'
@@ -196,6 +198,25 @@ export function mergePaymentMethods(
return
}
+ const netCanonical = getCanonicalPaytoType(net)
+ if (
+ isKnownPaytoType(netCanonical) &&
+ !isLightningPaytoType(netCanonical) &&
+ netCanonical !== 'bitcoin' &&
+ netCanonical !== 'liquid' &&
+ netCanonical !== 'lbtc' &&
+ netCanonical !== 'usdt'
+ ) {
+ add(
+ netCanonical,
+ addr,
+ buildPaytoUri(netCanonical, addr),
+ getPaytoEditorTypeLabel(netCanonical),
+ { 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
@@ -235,6 +256,14 @@ export function mergePaymentMethods(
}
if (profileEvent?.kind === kinds.Metadata) {
+ try {
+ const profileJson = JSON.parse(profileEvent.content || '{}') as unknown
+ for (const m of extractKind0PaymentMethodsFromProfileJson(profileJson)) {
+ add(m.type, m.authority, m.payto, m.displayType)
+ }
+ } catch {
+ /* ignore invalid kind 0 JSON */
+ }
for (const tag of profileEvent.tags) {
if (tag[0] === 'payto' && tag[1] && tag[2]) {
const type = String(tag[1]).toLowerCase()
diff --git a/src/lib/payto-about-coin-lines.test.ts b/src/lib/payto-about-coin-lines.test.ts
new file mode 100644
index 00000000..02b07634
--- /dev/null
+++ b/src/lib/payto-about-coin-lines.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import {
+ extractAboutCoinPaymentMethods,
+ parseAboutCoinLabelPaymentLines
+} from './payto-about-coin-lines'
+import { extractKind0PaymentMethodsFromProfileJson } from './payto-kind0-import'
+
+const XMR_ADDR =
+ '84mAJEgdihyRHkz8fGeuqgbQ19SuGeFWbhokJG2uMNMwTkDyoyQ3H7BijQNwSriSp9hHfaRGZYpCuKvHJwTer8av845U9py'
+
+describe('parseAboutCoinLabelPaymentLines', () => {
+ it('parses XMR: line in profile about', () => {
+ const about = `https://pubky.app/profile/foo\n\nXMR: ${XMR_ADDR}\n\nSimpleX: https://smp17.simplex.im/a#test`
+ const matches = parseAboutCoinLabelPaymentLines(about)
+ expect(matches).toHaveLength(1)
+ expect(matches[0].paytoType).toBe('monero')
+ expect(matches[0].authority).toBe(XMR_ADDR)
+ expect(matches[0].payto).toBe(`payto://monero/${encodeURIComponent(XMR_ADDR)}`)
+ })
+})
+
+describe('extractKind0PaymentMethodsFromProfileJson about', () => {
+ it('imports monero from about coin line', () => {
+ const methods = extractKind0PaymentMethodsFromProfileJson({
+ about: `XMR: ${XMR_ADDR}`
+ })
+ expect(methods.some((m) => m.type === 'monero' && m.authority === XMR_ADDR)).toBe(true)
+ })
+})
+
+describe('extractAboutCoinPaymentMethods', () => {
+ it('dedupes repeated labels', () => {
+ const methods = extractAboutCoinPaymentMethods(`XMR: ${XMR_ADDR}\nXMR: ${XMR_ADDR}`)
+ expect(methods).toHaveLength(1)
+ })
+})
diff --git a/src/lib/payto-about-coin-lines.ts b/src/lib/payto-about-coin-lines.ts
new file mode 100644
index 00000000..2892207c
--- /dev/null
+++ b/src/lib/payto-about-coin-lines.ts
@@ -0,0 +1,142 @@
+/**
+ * `XMR: 4abc…` / `BTC: bc1…` lines in kind 0 `about` (catalog-driven labels).
+ */
+
+import paytoTypesCatalog from '@/data/payto-types.json'
+import { buildPaytoUri } from '@/lib/payto'
+import { getCanonicalPaytoType, getPaytoEditorTypeLabel, isKnownPaytoType } from '@/lib/payto-registry'
+import type { Kind0ImportedPaymentMethod } from '@/lib/payto-kind0-import'
+
+type PaytoAboutCoinCatalog = {
+ kind0CryptocurrencyAddresses?: Record
+ aliases?: Record
+}
+
+const catalog = paytoTypesCatalog as PaytoAboutCoinCatalog
+
+function escapeRegExp(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+function mapCoinLabelToPaytoType(label: string): string | null {
+ const k = label.trim().toLowerCase()
+ if (!k) return null
+ const fromCrypto = catalog.kind0CryptocurrencyAddresses?.[k]
+ if (fromCrypto) return getCanonicalPaytoType(fromCrypto)
+ const canonical = getCanonicalPaytoType(k)
+ return isKnownPaytoType(canonical) ? canonical : null
+}
+
+function buildAboutCoinLabelAlternation(): string {
+ const labels = new Set()
+ const crypto = catalog.kind0CryptocurrencyAddresses ?? {}
+ for (const key of Object.keys(crypto)) {
+ labels.add(key)
+ labels.add(key.toUpperCase())
+ }
+ for (const [alias, canonical] of Object.entries(catalog.aliases ?? {})) {
+ if (crypto[alias] || Object.values(crypto).includes(canonical)) {
+ labels.add(alias)
+ labels.add(alias.toUpperCase())
+ }
+ }
+ return [...labels]
+ .sort((a, b) => b.length - a.length)
+ .map(escapeRegExp)
+ .join('|')
+}
+
+let aboutCoinLineRegex: RegExp | null = null
+
+function getAboutCoinLineRegex(): RegExp | null {
+ if (aboutCoinLineRegex) return aboutCoinLineRegex
+ const alternation = buildAboutCoinLabelAlternation()
+ if (!alternation) return null
+ aboutCoinLineRegex = new RegExp(
+ `(?:^|[\\n\\r])\\s*(${alternation})\\s*:\\s*([^\\s\\n]+)`,
+ 'gi'
+ )
+ return aboutCoinLineRegex
+}
+
+export type AboutCoinLineMatch = {
+ coinLabel: string
+ authority: string
+ paytoType: string
+ payto: string
+ displayType: string
+ /** Full matched segment including label and address (for content replacement). */
+ raw: string
+}
+
+export function parseAboutCoinLabelPaymentLines(about: string): AboutCoinLineMatch[] {
+ const text = about?.trim()
+ if (!text) return []
+ const regex = getAboutCoinLineRegex()
+ if (!regex) return []
+
+ const seen = new Set()
+ const out: AboutCoinLineMatch[] = []
+ regex.lastIndex = 0
+
+ for (const match of text.matchAll(regex)) {
+ const coinLabel = match[1] ?? ''
+ const authority = (match[2] ?? '').trim()
+ if (!authority) continue
+ const paytoType = mapCoinLabelToPaytoType(coinLabel)
+ if (!paytoType) continue
+ const dedupe = `${paytoType}:${authority.toLowerCase()}`
+ if (seen.has(dedupe)) continue
+ seen.add(dedupe)
+ out.push({
+ coinLabel,
+ authority,
+ paytoType,
+ payto: buildPaytoUri(paytoType, authority),
+ displayType: getPaytoEditorTypeLabel(paytoType),
+ raw: match[0]
+ })
+ }
+ return out
+}
+
+export function extractAboutCoinPaymentMethods(about: string): Kind0ImportedPaymentMethod[] {
+ return parseAboutCoinLabelPaymentLines(about).map((m) => ({
+ type: m.paytoType,
+ authority: m.authority,
+ payto: m.payto,
+ displayType: m.displayType
+ }))
+}
+
+/** Split profile about text into plain segments and payto URIs for {@link parseContent}. */
+export function parseAboutContentWithCoinPayto(content: string): import('@/lib/content-parser').TEmbeddedNode[] {
+ const text = content
+ if (!text) return [{ type: 'text', data: '' }]
+
+ const regex = getAboutCoinLineRegex()
+ if (!regex) return [{ type: 'text', data: text }]
+
+ const result: import('@/lib/content-parser').TEmbeddedNode[] = []
+ let lastIndex = 0
+ regex.lastIndex = 0
+
+ for (const match of text.matchAll(regex)) {
+ const matchStart = match.index ?? 0
+ const coinLabel = match[1] ?? ''
+ const authority = (match[2] ?? '').trim()
+ const paytoType = mapCoinLabelToPaytoType(coinLabel)
+ if (!authority || !paytoType) continue
+
+ if (matchStart > lastIndex) {
+ result.push({ type: 'text', data: text.slice(lastIndex, matchStart) })
+ }
+ result.push({ type: 'payto', data: buildPaytoUri(paytoType, authority) })
+ lastIndex = matchStart + match[0].length
+ }
+
+ if (lastIndex < text.length) {
+ result.push({ type: 'text', data: text.slice(lastIndex) })
+ }
+ return result.length > 0 ? result : [{ type: 'text', data: text }]
+}
diff --git a/src/lib/payto-kind0-import.test.ts b/src/lib/payto-kind0-import.test.ts
new file mode 100644
index 00000000..3aae8ff7
--- /dev/null
+++ b/src/lib/payto-kind0-import.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { extractKind0PaymentMethodsFromProfileJson } from './payto-kind0-import'
+
+describe('extractKind0PaymentMethodsFromProfileJson', () => {
+ it('imports Garnet cryptocurrency_addresses.monero', () => {
+ const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ const methods = extractKind0PaymentMethodsFromProfileJson({
+ cryptocurrency_addresses: { monero: addr }
+ })
+ expect(methods).toHaveLength(1)
+ expect(methods[0].type).toBe('monero')
+ expect(methods[0].authority).toBe(addr)
+ expect(methods[0].payto).toBe(`payto://monero/${addr}`)
+ })
+
+ it('maps xmr alias to monero', () => {
+ const methods = extractKind0PaymentMethodsFromProfileJson({
+ cryptocurrency_addresses: { xmr: '4abc' }
+ })
+ expect(methods[0]?.type).toBe('monero')
+ })
+
+ it('reads bare root monero key', () => {
+ const methods = extractKind0PaymentMethodsFromProfileJson({
+ monero: '4root'
+ })
+ expect(methods[0]?.authority).toBe('4root')
+ })
+
+ it('dedupes cryptocurrency_addresses and root field', () => {
+ const methods = extractKind0PaymentMethodsFromProfileJson({
+ monero: '4same',
+ cryptocurrency_addresses: { monero: '4same' }
+ })
+ expect(methods).toHaveLength(1)
+ })
+})
diff --git a/src/lib/payto-kind0-import.ts b/src/lib/payto-kind0-import.ts
new file mode 100644
index 00000000..a6390129
--- /dev/null
+++ b/src/lib/payto-kind0-import.ts
@@ -0,0 +1,94 @@
+/**
+ * Kind 0 JSON payment hints (incl. Garnet {@code cryptocurrency_addresses}) → payto methods.
+ */
+
+import paytoTypesCatalog from '@/data/payto-types.json'
+import { buildPaytoUri } from '@/lib/payto'
+import { extractAboutCoinPaymentMethods } from '@/lib/payto-about-coin-lines'
+import { getCanonicalPaytoType, getPaytoEditorTypeLabel, isKnownPaytoType } from '@/lib/payto-registry'
+
+export type Kind0ImportedPaymentMethod = {
+ type: string
+ authority: string
+ payto: string
+ displayType: string
+}
+
+type PaytoKind0ImportCatalog = {
+ kind0CryptocurrencyAddresses?: Record
+ kind0RootPaymentFields?: Record
+}
+
+const importCatalog = paytoTypesCatalog as PaytoKind0ImportCatalog
+
+function mapExternalKeyToPaytoType(externalKey: string): string | null {
+ const k = externalKey.trim().toLowerCase()
+ if (!k) return null
+ const fromCrypto = importCatalog.kind0CryptocurrencyAddresses?.[k]
+ if (fromCrypto) return getCanonicalPaytoType(fromCrypto)
+ const fromRoot = importCatalog.kind0RootPaymentFields?.[k]
+ if (fromRoot) return getCanonicalPaytoType(fromRoot)
+ const canonical = getCanonicalPaytoType(k)
+ return isKnownPaytoType(canonical) ? canonical : null
+}
+
+function readStringAddress(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+ const s = value.trim()
+ return s.length > 0 ? s : null
+}
+
+/**
+ * Extract payto-shaped methods from parsed kind 0 metadata JSON.
+ * Supports Garnet `cryptocurrency_addresses` and legacy top-level coin keys.
+ */
+export function extractKind0PaymentMethodsFromProfileJson(
+ profileJson: unknown
+): Kind0ImportedPaymentMethod[] {
+ if (!profileJson || typeof profileJson !== 'object' || Array.isArray(profileJson)) {
+ return []
+ }
+
+ const obj = profileJson as Record
+ const seen = new Set()
+ const out: Kind0ImportedPaymentMethod[] = []
+
+ const push = (externalKey: string, address: string) => {
+ const paytoType = mapExternalKeyToPaytoType(externalKey)
+ if (!paytoType) return
+ const authority = address.trim()
+ if (!authority) return
+ const dedupe = `${paytoType}:${authority.toLowerCase()}`
+ if (seen.has(dedupe)) return
+ seen.add(dedupe)
+ out.push({
+ type: paytoType,
+ authority,
+ payto: buildPaytoUri(paytoType, authority),
+ displayType: getPaytoEditorTypeLabel(paytoType)
+ })
+ }
+
+ const cryptoBlock = obj.cryptocurrency_addresses
+ if (cryptoBlock && typeof cryptoBlock === 'object' && !Array.isArray(cryptoBlock)) {
+ for (const [key, value] of Object.entries(cryptoBlock as Record)) {
+ const addr = readStringAddress(value)
+ if (addr) push(key, addr)
+ }
+ }
+
+ const rootKeys = importCatalog.kind0RootPaymentFields ?? {}
+ for (const externalKey of Object.keys(rootKeys)) {
+ const addr = readStringAddress(obj[externalKey])
+ if (addr) push(externalKey, addr)
+ }
+
+ const about = obj.about
+ if (typeof about === 'string' && about.trim()) {
+ for (const m of extractAboutCoinPaymentMethods(about)) {
+ push(m.type, m.authority)
+ }
+ }
+
+ return out
+}
diff --git a/src/lib/payto-registry.ts b/src/lib/payto-registry.ts
index 36f256dd..85742bef 100644
--- a/src/lib/payto-registry.ts
+++ b/src/lib/payto-registry.ts
@@ -5,6 +5,7 @@
import paytoTypesCatalog from '@/data/payto-types.json'
import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos'
+import { getPaytoPrimaryOpenUrl } from '@/lib/payto-wallet-open'
import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url'
export type PaytoCategory = 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip'
@@ -14,6 +15,16 @@ export type PaytoAuthorityHelp = {
hint: string
}
+export type PaytoWalletOpenRow = {
+ scheme?: string
+ style?: 'path' | 'query'
+ path?: string
+ query?: Record
+ requireAtSign?: boolean
+ requirePrefix?: string
+ walletApps?: string[]
+}
+
export type PaytoTypeRecord = {
label: string
symbol?: string
@@ -21,6 +32,8 @@ export type PaytoTypeRecord = {
/** Repo-relative path, e.g. `src/assets/payto_logos/ethereum-eth-logo.svg`. */
logoAssetPath?: string
profileUrlTemplate?: string
+ /** Native wallet URI / app deep link (see {@link getPaytoPrimaryOpenUrl}). */
+ walletOpen?: PaytoWalletOpenRow
authority?: PaytoAuthorityHelp
}
@@ -111,6 +124,9 @@ export function getPaytoProfileUrl(type: string, authority: string): string | nu
return resolvePaypalPaymentUrl(authority)
}
+ const fromWallet = getPaytoPrimaryOpenUrl(type, authority)
+ if (fromWallet) return fromWallet
+
const template = getPaytoTypeRecord(type)?.profileUrlTemplate
if (!template) return null
return template.replace('{authority}', encodeURIComponent(authority.trim()))
diff --git a/src/lib/payto-wallet-open.test.ts b/src/lib/payto-wallet-open.test.ts
new file mode 100644
index 00000000..991e5733
--- /dev/null
+++ b/src/lib/payto-wallet-open.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest'
+import { getPaytoProfileUrl } from '@/lib/payto-registry'
+import {
+ filterPaytoPaymentOpenHandlersForDevice,
+ filterWalletOpenActionsForDevice,
+ getPaytoPaymentOpenHandlers,
+ getPaytoPrimaryOpenUrl,
+ getPaytoWalletOpenActions,
+ isPaytoHttpOpenUrl
+} from './payto-wallet-open'
+
+describe('getPaytoPrimaryOpenUrl', () => {
+ it('builds monero: URI for primary address', () => {
+ const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ expect(getPaytoPrimaryOpenUrl('monero', addr)).toBe(`monero:${addr}`)
+ expect(getPaytoProfileUrl('monero', addr)).toBe(`monero:${addr}`)
+ })
+
+ it('builds bitcoin: URI', () => {
+ expect(getPaytoPrimaryOpenUrl('bitcoin', 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh')).toBe(
+ 'bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'
+ )
+ })
+
+ it('maps BIP-353 human-readable name to lightning: URI', () => {
+ expect(getPaytoPrimaryOpenUrl('bip353', 'user@example.com')).toBe('lightning:user@example.com')
+ })
+
+ it('maps BIP-352 silent payment to bitcoin: URI', () => {
+ const sp = 'sp1qxyz'
+ expect(getPaytoPrimaryOpenUrl('bip352', sp)).toBe(`bitcoin:${sp}`)
+ })
+
+ it('requires lno1 prefix for bolt12', () => {
+ expect(getPaytoPrimaryOpenUrl('bolt12', 'lno1offer')).toBe('bolt12:lno1offer')
+ expect(getPaytoPrimaryOpenUrl('bolt12', 'bc1qinvalid')).toBeNull()
+ })
+})
+
+describe('getPaytoWalletOpenActions', () => {
+ it('includes Cake Wallet deep link for monero', () => {
+ const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ const actions = getPaytoWalletOpenActions('monero', addr)
+ expect(actions).toHaveLength(1)
+ expect(actions[0].label).toBe('Cake Wallet')
+ expect(actions[0].href).toBe(`cakewallet:monero?address=${addr}`)
+ expect(actions[0].mobileOnly).toBe(true)
+ })
+
+ it('hides mobile-only actions on desktop UA', () => {
+ const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ const actions = getPaytoWalletOpenActions('monero', addr)
+ const prev = navigator.userAgent
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0',
+ configurable: true
+ })
+ try {
+ expect(filterWalletOpenActionsForDevice(actions)).toHaveLength(0)
+ } finally {
+ Object.defineProperty(navigator, 'userAgent', { value: prev, configurable: true })
+ }
+ })
+})
+
+describe('getPaytoPaymentOpenHandlers', () => {
+ it('lists named apps only, not native coin schemes', () => {
+ const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ const monero = getPaytoPaymentOpenHandlers('monero', addr)
+ expect(monero.some((h) => h.openTargetName === 'Cake Wallet')).toBe(true)
+ expect(monero.some((h) => h.href.startsWith('monero:'))).toBe(false)
+
+ const btc = getPaytoWalletOpenActions('bitcoin', 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh')
+ expect(btc[0]?.href).toBe(
+ 'cakewallet:bitcoin?address=bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'
+ )
+
+ const sp = getPaytoWalletOpenActions('bip352', 'sp1qxyz0123456789')
+ expect(sp[0]?.href).toBe('cakewallet:bitcoin?address=sp1qxyz0123456789')
+
+ const cash = getPaytoPaymentOpenHandlers('cashme', '$cashtag')
+ expect(cash).toHaveLength(1)
+ expect(cash[0].isHttp).toBe(true)
+ expect(cash[0].openTargetName).toBe('Cash App')
+ expect(cash[0].href).toBe('https://cash.app/%24cashtag')
+ })
+
+ it('builds Phoenix bolt12 deep link from offer string', () => {
+ const offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx283qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36ryg488qwlrnzyjczs'
+ const actions = getPaytoWalletOpenActions('bolt12', offer)
+ expect(actions).toHaveLength(1)
+ expect(actions[0].href).toBe(`phoenix:pay?uri=bolt12:${offer}`)
+ expect(actions[0].mobileOnly).toBe(true)
+ })
+
+ it('includes Phoenix on mobile only', () => {
+ const handlers = getPaytoPaymentOpenHandlers('lightning', 'user@example.com')
+ const phoenix = handlers.find((h) => h.openTargetName === 'Phoenix')
+ expect(phoenix?.mobileOnly).toBe(true)
+
+ const prev = navigator.userAgent
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0',
+ configurable: true
+ })
+ try {
+ expect(
+ filterPaytoPaymentOpenHandlersForDevice(handlers).some((h) => h.openTargetName === 'Phoenix')
+ ).toBe(false)
+ } finally {
+ Object.defineProperty(navigator, 'userAgent', { value: prev, configurable: true })
+ }
+ })
+})
+
+describe('isPaytoHttpOpenUrl', () => {
+ it('distinguishes https profile links from wallet schemes', () => {
+ expect(isPaytoHttpOpenUrl('https://paypal.me/foo')).toBe(true)
+ expect(isPaytoHttpOpenUrl('monero:4abc')).toBe(false)
+ })
+})
diff --git a/src/lib/payto-wallet-open.ts b/src/lib/payto-wallet-open.ts
new file mode 100644
index 00000000..d5b6c897
--- /dev/null
+++ b/src/lib/payto-wallet-open.ts
@@ -0,0 +1,236 @@
+/**
+ * Wallet deep links and “open in app” targets driven by {@link ../data/payto-types.json}.
+ */
+
+import paytoTypesCatalog from '@/data/payto-types.json'
+import type { PaytoWalletOpenRow } from '@/lib/payto-registry'
+import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url'
+
+type PaytoTypeRecordWallet = {
+ label?: string
+ profileUrlTemplate?: string
+ walletOpen?: PaytoWalletOpenRow
+}
+
+/** Labeled “open in …” action shown inside {@link PaytoDialog}. */
+export type PaytoPaymentOpenHandler = {
+ id: string
+ /** App or service name for i18n: “Open in {{name}}”. */
+ openTargetName: string
+ href: string
+ isHttp: boolean
+ mobileOnly?: boolean
+}
+
+export type PaytoWalletOpenAction = {
+ id: string
+ label: string
+ href: string
+ /** Prefer showing on phones/tablets (e.g. Cake Wallet app scheme). */
+ mobileOnly?: boolean
+}
+
+type WalletAppRowJson = {
+ label: string
+ mobileOnly?: boolean
+ /** `cakewallet:{coinScheme}?address={authority}` — `{coinScheme}` from type's walletOpen.scheme or type id */
+ uriTemplate: string
+}
+
+type PaytoWalletCatalogJson = {
+ aliases?: Record
+ types: Record
+ walletApps?: Record
+}
+
+const walletCatalog = paytoTypesCatalog as PaytoWalletCatalogJson
+const PAYTO_ALIASES = walletCatalog.aliases ?? {}
+const PAYTO_TYPES = walletCatalog.types
+
+function getCanonicalPaytoType(type: string): string {
+ const key = type.toLowerCase().trim()
+ return PAYTO_ALIASES[key] ?? key
+}
+
+function getPaytoTypeRecord(type: string): PaytoTypeRecordWallet | undefined {
+ return PAYTO_TYPES[getCanonicalPaytoType(type)]
+}
+
+function trimAuthority(authority: string): string {
+ return authority.trim()
+}
+
+function substituteAuthority(template: string, authority: string): string {
+ return template.split('{authority}').join(authority)
+}
+
+function buildQueryUri(scheme: string, path: string, query: Record, authority: string): string {
+ const params = new URLSearchParams()
+ for (const [key, raw] of Object.entries(query)) {
+ params.set(key, substituteAuthority(raw, authority))
+ }
+ const base = path ? `${scheme}:${path}` : `${scheme}:`
+ const qs = params.toString()
+ return qs ? `${base}?${qs}` : base
+}
+
+function resolveWalletOpenRow(
+ paytoType: string,
+ authority: string,
+ row: PaytoWalletOpenRow | undefined
+): string | null {
+ if (!row) return null
+ const auth = trimAuthority(authority)
+ if (!auth) return null
+ if (row.requireAtSign && !auth.includes('@')) return null
+ if (row.requirePrefix) {
+ const prefix = row.requirePrefix.toLowerCase()
+ if (!auth.toLowerCase().startsWith(prefix)) return null
+ }
+ if (/^https?:\/\//i.test(auth)) return auth
+
+ const scheme = (row.scheme ?? paytoType).toLowerCase()
+ if (row.style === 'query' && row.query) {
+ return buildQueryUri(scheme, row.path ?? '', row.query, auth)
+ }
+ const pathPart = row.path ? `${row.path}/` : ''
+ return `${scheme}:${pathPart}${auth}`
+}
+
+function resolveWalletAppUri(
+ appId: string,
+ paytoType: string,
+ authority: string,
+ row: PaytoWalletOpenRow | undefined
+): string | null {
+ const app = walletCatalog.walletApps?.[appId]
+ if (!app) return null
+ const auth = trimAuthority(authority)
+ if (!auth) return null
+ const coinScheme = (row?.scheme ?? paytoType).toLowerCase()
+ const href = substituteAuthority(
+ app.uriTemplate.replace(/\{coinScheme\}/g, coinScheme),
+ auth
+ )
+ return href
+}
+
+/**
+ * Primary browser/OS URL for this payto target (wallet URI or https).
+ * Returns null when the type should use copy-only or zap (caller checks zappable lightning).
+ */
+export function getPaytoPrimaryOpenUrl(type: string, authority: string): string | null {
+ const canonical = getCanonicalPaytoType(type)
+ const record = getPaytoTypeRecord(canonical)
+ const auth = trimAuthority(authority)
+ if (!auth || !record) return null
+
+ const fromWallet = resolveWalletOpenRow(canonical, auth, record.walletOpen)
+ if (fromWallet) return fromWallet
+
+ const template = record.profileUrlTemplate
+ if (template) {
+ return substituteAuthority(template, encodeURIComponent(auth))
+ }
+
+ return null
+}
+
+/** Optional app-specific links (e.g. Cake Wallet on Android). */
+export function getPaytoWalletOpenActions(type: string, authority: string): PaytoWalletOpenAction[] {
+ const canonical = getCanonicalPaytoType(type)
+ const record = getPaytoTypeRecord(canonical)
+ const row = record?.walletOpen
+ if (!row?.walletApps?.length) return []
+
+ const auth = trimAuthority(authority)
+ if (!auth) return []
+
+ const out: PaytoWalletOpenAction[] = []
+ for (const appId of row.walletApps) {
+ const app = walletCatalog.walletApps?.[appId]
+ const href = resolveWalletAppUri(appId, canonical, auth, row)
+ if (!app || !href) continue
+ out.push({
+ id: `${canonical}-${appId}`,
+ label: app.label,
+ href,
+ /** App deep links are mobile-only unless catalog sets `mobileOnly: false`. */
+ mobileOnly: app.mobileOnly !== false
+ })
+ }
+ return out
+}
+
+export function isPaytoHttpOpenUrl(url: string | null | undefined): boolean {
+ return !!url && /^https?:\/\//i.test(url)
+}
+
+export function isLikelyMobileWalletUserAgent(): boolean {
+ if (typeof navigator === 'undefined') return false
+ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
+}
+
+export function filterWalletOpenActionsForDevice(
+ actions: PaytoWalletOpenAction[]
+): PaytoWalletOpenAction[] {
+ if (isLikelyMobileWalletUserAgent()) return actions
+ return actions.filter((a) => !a.mobileOnly)
+}
+
+/**
+ * Named app/site open targets for PaytoDialog (https + walletApps only).
+ * Native coin schemes (monero:, bitcoin:, …) are omitted — users copy the payto URI instead.
+ */
+export function getPaytoPaymentOpenHandlers(type: string, authority: string): PaytoPaymentOpenHandler[] {
+ const canonical = getCanonicalPaytoType(type)
+ const record = getPaytoTypeRecord(canonical)
+ const auth = trimAuthority(authority)
+ if (!auth || !record) return []
+
+ const handlers: PaytoPaymentOpenHandler[] = []
+ const seen = new Set()
+
+ const add = (
+ id: string,
+ openTargetName: string,
+ href: string | null | undefined,
+ mobileOnly?: boolean
+ ) => {
+ if (!href || seen.has(href)) return
+ seen.add(href)
+ handlers.push({
+ id,
+ openTargetName,
+ href,
+ isHttp: isPaytoHttpOpenUrl(href),
+ mobileOnly
+ })
+ }
+
+ if (canonical === 'paypal') {
+ add('paypal', 'PayPal', resolvePaypalPaymentUrl(auth))
+ return handlers
+ }
+
+ if (record.profileUrlTemplate) {
+ add(
+ `${canonical}-web`,
+ record.label ?? canonical,
+ substituteAuthority(record.profileUrlTemplate, encodeURIComponent(auth))
+ )
+ }
+
+ for (const app of getPaytoWalletOpenActions(type, auth)) {
+ add(app.id, app.label, app.href, app.mobileOnly)
+ }
+
+ return handlers
+}
+
+export function filterPaytoPaymentOpenHandlersForDevice(
+ handlers: PaytoPaymentOpenHandler[]
+): PaytoPaymentOpenHandler[] {
+ if (isLikelyMobileWalletUserAgent()) return handlers
+ return handlers.filter((h) => !h.mobileOnly)
+}
diff --git a/src/lib/payto.ts b/src/lib/payto.ts
index 834cbb4e..b4d015bc 100644
--- a/src/lib/payto.ts
+++ b/src/lib/payto.ts
@@ -23,9 +23,22 @@ export {
PAYTO_EDITOR_TYPE_ORDER,
PAYTO_KNOWN_TYPES,
type PaytoAuthorityHelp,
- type PaytoCategory
+ type PaytoCategory,
+ type PaytoWalletOpenRow
} from '@/lib/payto-registry'
+export {
+ getPaytoPrimaryOpenUrl,
+ getPaytoPaymentOpenHandlers,
+ filterPaytoPaymentOpenHandlersForDevice,
+ getPaytoWalletOpenActions,
+ filterWalletOpenActionsForDevice,
+ isPaytoHttpOpenUrl,
+ isLikelyMobileWalletUserAgent,
+ type PaytoPaymentOpenHandler,
+ type PaytoWalletOpenAction
+} from '@/lib/payto-wallet-open'
+
export const PAYTO_URI_REGEX = /payto:\/\/([a-z0-9-]+)\/([^\s\]\)\<\"']+)/gi
export interface ParsedPayto {
@@ -64,3 +77,11 @@ export {
PAYTO_INLINE_DISPLAY_AUTHORITY_CHARS,
truncatePaytoAuthority
} from '@/lib/payto-display'
+
+export { extractKind0PaymentMethodsFromProfileJson, type Kind0ImportedPaymentMethod } from '@/lib/payto-kind0-import'
+
+export {
+ extractAboutCoinPaymentMethods,
+ parseAboutCoinLabelPaymentLines,
+ type AboutCoinLineMatch
+} from '@/lib/payto-about-coin-lines'
diff --git a/src/lib/profile-author-warmup-spec.ts b/src/lib/profile-author-warmup-spec.ts
index 6d952481..e95e5a58 100644
--- a/src/lib/profile-author-warmup-spec.ts
+++ b/src/lib/profile-author-warmup-spec.ts
@@ -1,3 +1,5 @@
+import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
+import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import type { TSubRequestFilter } from '@/types'
import { normalizeHexPubkey } from '@/lib/pubkey'
import type { Filter } from 'nostr-tools'
@@ -62,3 +64,15 @@ export function getProfileAuthorWarmupRelayUrls(
}
return out
}
+
+/** Bounded relay stack for profile timeline fetch / fallback (shard URLs + fast-read + profile index). */
+export function getProfileTimelineFetchRelayUrls(
+ mapped: Array<{ urls: string[]; filter: TSubRequestFilter }>,
+ maxRelays = 24
+): string[] {
+ return dedupeNormalizeRelayUrlsOrdered([
+ ...getProfileAuthorWarmupRelayUrls(mapped),
+ ...FAST_READ_RELAY_URLS,
+ ...PROFILE_RELAY_URLS
+ ]).slice(0, maxRelays)
+}
diff --git a/src/lib/relay-url-priority.test.ts b/src/lib/relay-url-priority.test.ts
index 9a2837ef..5974d3b4 100644
--- a/src/lib/relay-url-priority.test.ts
+++ b/src/lib/relay-url-priority.test.ts
@@ -92,7 +92,7 @@ describe('buildProfilePageReadRelayUrls', () => {
syncViewerRelayStackNostrLandAggrEligible([])
})
- it('prioritizes viewed author write relays ahead of long read lists', () => {
+ it('prioritizes viewed author write relays ahead of long read lists on own profile', () => {
syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/'])
const out = buildProfilePageReadRelayUrls(
[],
@@ -101,11 +101,32 @@ describe('buildProfilePageReadRelayUrls', () => {
read: Array.from({ length: 20 }, (_, i) => `wss://author-inbox-${i}.example/`),
write: ['wss://author-outbox.example/']
},
- false
+ false,
+ true
)
expect(out[0]).toBe('wss://aggr.nostr.land/')
expect(out[1]).toBe('wss://author-outbox.example/')
syncViewerRelayStackNostrLandAggrEligible([])
})
+
+ it('pins fast-read for remote profile feeds when author NIP-65 would fill the cap', () => {
+ syncViewerRelayStackNostrLandAggrEligible([])
+ const out = buildProfilePageReadRelayUrls(
+ [],
+ [],
+ {
+ read: Array.from({ length: 12 }, (_, i) => `wss://author-inbox-${i}.example/`),
+ write: ['wss://author-outbox.example/']
+ },
+ true,
+ false,
+ [1],
+ true
+ )
+ const hasFastRead = out.some(
+ (u) => u.includes('nostr.land') || u.includes('theforest.nostr1.com') || u.includes('nostr.wine')
+ )
+ expect(hasFastRead).toBe(true)
+ })
})