From 191afba03d8c3532d493c5cc900d2c40cb2812b6 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 20 May 2026 13:53:34 +0200 Subject: [PATCH] add payto handlers --- src/components/NoteList/index.tsx | 78 +++--- src/components/PaytoDialog/index.tsx | 56 +++-- src/components/PaytoLink/index.tsx | 47 +--- src/components/Profile/ProfileMediaFeed.tsx | 109 ++------- src/components/ProfileAbout/index.tsx | 2 + src/data/payto-types.json | 93 +++++++- src/hooks/useProfileAuthorFeedSubRequests.ts | 33 +-- src/hooks/useProfilePins.tsx | 3 +- src/hooks/useProfileTimeline.tsx | 4 +- src/i18n/locales/en.ts | 3 + src/lib/content-parser.ts | 4 + src/lib/favorites-feed-relays.ts | 42 +++- src/lib/merge-payment-methods.test.ts | 40 ++++ src/lib/merge-payment-methods.ts | 29 +++ src/lib/payto-about-coin-lines.test.ts | 36 +++ src/lib/payto-about-coin-lines.ts | 142 +++++++++++ src/lib/payto-kind0-import.test.ts | 37 +++ src/lib/payto-kind0-import.ts | 94 ++++++++ src/lib/payto-registry.ts | 16 ++ src/lib/payto-wallet-open.test.ts | 121 ++++++++++ src/lib/payto-wallet-open.ts | 236 +++++++++++++++++++ src/lib/payto.ts | 23 +- src/lib/profile-author-warmup-spec.ts | 14 ++ src/lib/relay-url-priority.test.ts | 25 +- 24 files changed, 1066 insertions(+), 221 deletions(-) create mode 100644 src/lib/payto-about-coin-lines.test.ts create mode 100644 src/lib/payto-about-coin-lines.ts create mode 100644 src/lib/payto-kind0-import.test.ts create mode 100644 src/lib/payto-kind0-import.ts create mode 100644 src/lib/payto-wallet-open.test.ts create mode 100644 src/lib/payto-wallet-open.ts 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 && ( +
+

{t('Open with')}

+
+ {openHandlers.map((handler) => ( + + ))} +
+
+ )}
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) + }) })