Browse Source

add payto handlers

imwald
Silberengel 4 weeks ago
parent
commit
191afba03d
  1. 64
      src/components/NoteList/index.tsx
  2. 56
      src/components/PaytoDialog/index.tsx
  3. 47
      src/components/PaytoLink/index.tsx
  4. 107
      src/components/Profile/ProfileMediaFeed.tsx
  5. 2
      src/components/ProfileAbout/index.tsx
  6. 93
      src/data/payto-types.json
  7. 33
      src/hooks/useProfileAuthorFeedSubRequests.ts
  8. 3
      src/hooks/useProfilePins.tsx
  9. 4
      src/hooks/useProfileTimeline.tsx
  10. 3
      src/i18n/locales/en.ts
  11. 4
      src/lib/content-parser.ts
  12. 42
      src/lib/favorites-feed-relays.ts
  13. 40
      src/lib/merge-payment-methods.test.ts
  14. 29
      src/lib/merge-payment-methods.ts
  15. 36
      src/lib/payto-about-coin-lines.test.ts
  16. 142
      src/lib/payto-about-coin-lines.ts
  17. 37
      src/lib/payto-kind0-import.test.ts
  18. 94
      src/lib/payto-kind0-import.ts
  19. 16
      src/lib/payto-registry.ts
  20. 121
      src/lib/payto-wallet-open.test.ts
  21. 236
      src/lib/payto-wallet-open.ts
  22. 23
      src/lib/payto.ts
  23. 14
      src/lib/profile-author-warmup-spec.ts
  24. 25
      src/lib/relay-url-priority.test.ts

64
src/components/NoteList/index.tsx

@ -5,7 +5,6 @@ import {
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FIRST_RELAY_RESULT_GRACE_MS, FIRST_RELAY_RESULT_GRACE_MS,
PROFILE_MEDIA_TAB_KINDS, PROFILE_MEDIA_TAB_KINDS,
PROFILE_RELAY_URLS,
SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS,
SINGLE_RELAY_KINDLESS_REQ_LIMIT SINGLE_RELAY_KINDLESS_REQ_LIMIT
} from '@/constants' } from '@/constants'
@ -24,7 +23,6 @@ import {
isSpellSubRequestsSameFiltersDifferentRelays isSpellSubRequestsSameFiltersDifferentRelays
} from '@/lib/spell-feed-request-identity' } from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
@ -81,8 +79,8 @@ import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/prov
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { buildFeedFullSearchRelayUrls } from '@/lib/feed-full-search-relays' import { buildFeedFullSearchRelayUrls } from '@/lib/feed-full-search-relays'
import { import {
getProfileAuthorWarmupRelayUrls,
getProfileAuthorWarmupSpec, getProfileAuthorWarmupSpec,
getProfileTimelineFetchRelayUrls,
isProfileTimelineSubscriptionKey isProfileTimelineSubscriptionKey
} from '@/lib/profile-author-warmup-spec' } from '@/lib/profile-author-warmup-spec'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
@ -1988,11 +1986,20 @@ const NoteList = forwardRef(
feedTimelineScopePrevRef.current = undefined feedTimelineScopePrevRef.current = undefined
} }
const profileRelayStackRefinement =
preserveTimelineOnSubRequestsChange &&
mergeTimelineWhenSubRequestFiltersMatch &&
!userPulledRefresh &&
!feedScopeChanged &&
prevSubKey != null &&
(isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) ||
isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey))
const keepExistingTimelineEvents = const keepExistingTimelineEvents =
preserveTimelineOnSubRequestsChange && preserveTimelineOnSubRequestsChange &&
!userPulledRefresh && !userPulledRefresh &&
!feedScopeChanged && !feedScopeChanged &&
eventsRef.current.length > 0 && (eventsRef.current.length > 0 || profileRelayStackRefinement) &&
(prevSubKey === subRequestsKey || (prevSubKey === subRequestsKey ||
isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) || isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) ||
(mergeTimelineWhenSubRequestFiltersMatch && (mergeTimelineWhenSubRequestFiltersMatch &&
@ -2004,6 +2011,7 @@ const NoteList = forwardRef(
async function init() { async function init() {
if (timelineEffectStale()) return undefined if (timelineEffectStale()) return undefined
if (!profileRelayStackRefinement) {
timelineMergeBootstrapRef.current = null timelineMergeBootstrapRef.current = null
feedPaintSessionPendingRef.current = false feedPaintSessionPendingRef.current = false
feedPaintRelayPendingRef.current = false feedPaintRelayPendingRef.current = false
@ -2011,6 +2019,7 @@ const NoteList = forwardRef(
feedPaintLiveRelayDoneRef.current = false feedPaintLiveRelayDoneRef.current = false
feedRelayReturnedAnyEventRef.current = false feedRelayReturnedAnyEventRef.current = false
singleRelayKindlessFallbackAttemptedRef.current = false singleRelayKindlessFallbackAttemptedRef.current = false
}
// Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton. // Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton.
const keepRowsVisible = const keepRowsVisible =
@ -2122,18 +2131,15 @@ const NoteList = forwardRef(
? ALGO_LIMIT ? ALGO_LIMIT
: LIMIT : LIMIT
/** Manual refresh on profile feeds: bounded fetch in parallel with subscribe (don't wait for EOSE outcomes). */ /** Profile feeds: bounded fetch in parallel with subscribe (do not wait for EOSE / outcomes). */
if (userPulledRefresh && profileAuthorWarmSpecForRefresh && profileMappedForRefresh) { const runProfileTimelineNetworkFetch = (variant: string) => {
if (!profileAuthorWarmSpecForRefresh || !profileMappedForRefresh) return
publicReadFallbackAttemptedRef.current = true publicReadFallbackAttemptedRef.current = true
const pullRefreshRelays = dedupeNormalizeRelayUrlsOrdered([ const primeRelays = getProfileTimelineFetchRelayUrls(profileMappedForRefresh)
...getProfileAuthorWarmupRelayUrls(profileMappedForRefresh),
...FAST_READ_RELAY_URLS,
...PROFILE_RELAY_URLS
]).slice(0, 24)
void (async () => { void (async () => {
try { try {
const fetched = await client.fetchEvents( const fetched = await client.fetchEvents(
pullRefreshRelays, primeRelays,
{ {
authors: [profileAuthorWarmSpecForRefresh.author], authors: [profileAuthorWarmSpecForRefresh.author],
kinds: profileAuthorWarmSpecForRefresh.kinds, kinds: profileAuthorWarmSpecForRefresh.kinds,
@ -2166,17 +2172,23 @@ const NoteList = forwardRef(
setLoading(false) setLoading(false)
feedPaintRelayPendingRef.current = true feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = { feedPaintRelayMetaRef.current = {
variant: 'profile_pull_refresh', variant,
mergedCount: narrowedFetch.length mergedCount: narrowedFetch.length
} }
setFeedEmptyToastGateTick((n) => n + 1) setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true) setFeedTimelineEmptyUiReady(true)
} catch (e) { } 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 = const isSpellPageLocalWarmup =
hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0 hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0
@ -2448,7 +2460,8 @@ const NoteList = forwardRef(
if ( if (
isProfileTimelineFeed && isProfileTimelineFeed &&
profileAuthorWarmSpec && profileAuthorWarmSpec &&
!timelineEffectStale() !timelineEffectStale() &&
!profileRelayStackRefinement
) { ) {
const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800)) const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800))
const sessionHits = client.eventService.listSessionEventsAuthoredBy( const sessionHits = client.eventService.listSessionEventsAuthoredBy(
@ -2523,7 +2536,7 @@ const NoteList = forwardRef(
setFeedTimelineEmptyUiReady(true) setFeedTimelineEmptyUiReady(true)
} }
const relayUrls = getProfileAuthorWarmupRelayUrls(profileMapped) const relayUrls = getProfileTimelineFetchRelayUrls(profileMapped)
if (relayUrls.length > 0) { if (relayUrls.length > 0) {
const fetched = await client.fetchEvents( const fetched = await client.fetchEvents(
relayUrls, relayUrls,
@ -2566,12 +2579,14 @@ const NoteList = forwardRef(
})() })()
} }
} }
if (!primedFromDisk) { if (!primedFromDisk && !profileRelayStackRefinement) {
if (!keepRowsVisible) setLoading(true) if (!keepRowsVisible) setLoading(true)
timelineMergeBootstrapRef.current = [] timelineMergeBootstrapRef.current = []
setEvents([]) setEvents([])
setNewEvents([]) setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT) setShowCount(revealBatchSize ?? SHOW_COUNT)
} else if (!keepRowsVisible && !profileRelayStackRefinement) {
setLoading(true)
} }
} }
} else if (!keepRowsVisible) { } else if (!keepRowsVisible) {
@ -3700,13 +3715,6 @@ const NoteList = forwardRef(
mapped as Array<{ urls: string[]; filter: TSubRequestFilter }> mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
) )
: null : 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. */ /** 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 && uiStatuses.some((s) => s.success)) return
if (profileWarm && eventsRef.current.length > 0) return if (profileWarm && eventsRef.current.length > 0) return
@ -3730,11 +3738,9 @@ const NoteList = forwardRef(
: LIMIT : LIMIT
const fallbackRelays = profileWarm const fallbackRelays = profileWarm
? dedupeNormalizeRelayUrlsOrdered([ ? getProfileTimelineFetchRelayUrls(
...profileRelayUrls, mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
...FAST_READ_RELAY_URLS, )
...PROFILE_RELAY_URLS
]).slice(0, 24)
: FAST_READ_RELAY_URLS : FAST_READ_RELAY_URLS
void (async () => { void (async () => {

56
src/components/PaytoDialog/index.tsx

@ -6,11 +6,14 @@ import {
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' 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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { getPaytoTypeInfo, getPaytoProfileUrl } from '@/lib/payto' import {
import { Zap, ExternalLink } from 'lucide-react' filterPaytoPaymentOpenHandlersForDevice,
getPaytoPaymentOpenHandlers,
getPaytoTypeInfo
} from '@/lib/payto'
export default function PaytoDialog({ export default function PaytoDialog({
open, open,
@ -29,11 +32,13 @@ export default function PaytoDialog({
const info = getPaytoTypeInfo(type) const info = getPaytoTypeInfo(type)
const label = info?.label ?? type const label = info?.label ?? type
const isLightning = type.toLowerCase() === 'lightning' 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) 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) onOpenChange(false)
} }
@ -51,21 +56,13 @@ export default function PaytoDialog({
: t('Payment address – copy to use in your wallet or app')} : t('Payment address – copy to use in your wallet or app')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 pb-2"> <div className="space-y-4 pb-2">
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all select-text"> <div className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all select-text">
{authority} {authority}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{profileUrl && (
<Button variant="default" size="sm" asChild className="gap-2">
<a href={profileUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4" />
{t('Open on website')}
</a>
</Button>
)}
<Button <Button
variant="secondary" variant="default"
size="sm" size="sm"
onClick={() => handleCopy(authority, label)} onClick={() => handleCopy(authority, label)}
className="gap-2" className="gap-2"
@ -74,7 +71,7 @@ export default function PaytoDialog({
{t('Copy address')} {t('Copy address')}
</Button> </Button>
<Button <Button
variant="outline" variant="secondary"
size="sm" size="sm"
onClick={() => handleCopy(paytoUri)} onClick={() => handleCopy(paytoUri)}
className="gap-2" className="gap-2"
@ -83,6 +80,31 @@ export default function PaytoDialog({
{t('Copy payto URI')} {t('Copy payto URI')}
</Button> </Button>
</div> </div>
{openHandlers.length > 0 && (
<div className="space-y-2 border-t border-border pt-3">
<p className="text-sm font-medium text-muted-foreground">{t('Open with')}</p>
<div className="flex flex-wrap gap-2">
{openHandlers.map((handler) => (
<Button key={handler.id} variant="outline" size="sm" asChild className="gap-2">
<a
href={handler.href}
{...(handler.isHttp
? { target: '_blank', rel: 'noopener noreferrer' }
: {})}
onClick={(e) => e.stopPropagation()}
>
{handler.isHttp ? (
<ExternalLink className="size-4" />
) : (
<Wallet className="size-4" />
)}
{t('Open in {{name}}', { name: handler.openTargetName })}
</a>
</Button>
))}
</div>
</div>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

47
src/components/PaytoLink/index.tsx

@ -8,7 +8,6 @@ import {
getPaytoTypeInfo, getPaytoTypeInfo,
getPaytoIconChar, getPaytoIconChar,
getPaytoLogoPath, getPaytoLogoPath,
getPaytoProfileUrl,
isKnownPaytoType, isKnownPaytoType,
isLightningPaytoType, isLightningPaytoType,
isZappableLightningPaytoType, isZappableLightningPaytoType,
@ -92,7 +91,6 @@ export default function PaytoLink({
})() })()
const logoPath = getPaytoLogoPath(type) const logoPath = getPaytoLogoPath(type)
const iconChar = getPaytoIconChar(type) const iconChar = getPaytoIconChar(type)
const profileUrl = getPaytoProfileUrl(type, authority)
const childText = flattenPaytoLinkChildText(children) const childText = flattenPaytoLinkChildText(children)
const useCompactDisplay = const useCompactDisplay =
displayFormat === 'compact' && displayFormat === 'compact' &&
@ -106,6 +104,11 @@ export default function PaytoLink({
) )
const overrideTip = linkTitle?.trim() const overrideTip = linkTitle?.trim()
const fullAddressTip = `${displayLabel}: ${authority}` 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 = ( const iconEl = (
<span className="shrink-0 flex items-center justify-center w-4 h-4 text-[1rem] leading-none" aria-hidden> <span className="shrink-0 flex items-center justify-center w-4 h-4 text-[1rem] leading-none" aria-hidden>
@ -124,33 +127,6 @@ export default function PaytoLink({
</span> </span>
) )
if (profileUrl) {
return (
<a
href={profileUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
URI_LINK_CLASS,
'cursor-pointer text-left inline-flex items-center gap-1.5',
className
)}
title={
overrideTip ||
(useCompactDisplay
? fullAddressTip
: categoryLabel
? `${displayLabel} (${categoryLabel}): ${t('Open on website')}`
: `${displayLabel}: ${t('Open on website')}`)
}
onClick={(e) => e.stopPropagation()}
>
{iconEl}
{content}
</a>
)
}
return ( return (
<> <>
<button <button
@ -161,21 +137,12 @@ export default function PaytoLink({
'cursor-pointer text-left inline-flex items-center gap-1.5', 'cursor-pointer text-left inline-flex items-center gap-1.5',
className className
)} )}
title={ title={overrideTip || (useCompactDisplay ? fullAddressTip : paymentOptionsTip)}
overrideTip ||
(useCompactDisplay
? fullAddressTip
: known && categoryLabel
? `${displayLabel} (${categoryLabel}): ${t('Click to open payment options')}`
: known
? `${displayLabel}: ${t('Click to open payment options')}`
: t('Click to copy address'))
}
> >
{iconEl} {iconEl}
{content} {content}
</button> </button>
{known && ( {known && !canZap && (
<PaytoDialog <PaytoDialog
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}

107
src/components/Profile/ProfileMediaFeed.tsx

@ -1,27 +1,17 @@
import NoteList, { type TNoteListRef } from '@/components/NoteList' import NoteList, { type TNoteListRef } from '@/components/NoteList'
import { buildAuthorInboxOutboxRelayUrls } from '@/lib/favorites-feed-relays' import { buildAuthorInboxOutboxRelayUrls } from '@/lib/favorites-feed-relays'
import logger from '@/lib/logger'
import { PROFILE_MEDIA_TAB_KINDS } from '@/constants' import { PROFILE_MEDIA_TAB_KINDS } from '@/constants'
import { buildProfileMediaSubRequests } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { buildProfileMediaSubRequests } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { normalizeUrl } from '@/lib/url' import { normalizeHexPubkey } from '@/lib/pubkey'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context' import { useNostrOptional } from '@/providers/nostr-context'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hexPubkeysEqual } from '@/lib/pubkey'
import client from '@/services/client.service' import client from '@/services/client.service'
import { forwardRef, useEffect, useMemo, useState } from 'react' import { forwardRef, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
function blockedRelaysContentKey(blockedRelays: string[]): string {
return [...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort().join('\u0001')
}
const MEDIA_LOG = '[ProfileMedia]'
const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey }, ref) => { const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey }, ref) => {
const { t } = useTranslation()
const nostr = useNostrOptional() const nostr = useNostrOptional()
const { blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays])
const includeAuthorLocalRelays = useMemo(() => { const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim() const me = nostr?.pubkey?.trim()
const pk = pubkey?.trim() const pk = pubkey?.trim()
@ -33,68 +23,31 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
} }
}, [nostr?.pubkey, pubkey]) }, [nostr?.pubkey, pubkey])
/** const [authorRelayUrls, setAuthorRelayUrls] = useState<string[] | null>(null)
* 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<string[] | null>(null)
useEffect(() => { useEffect(() => {
const pk = pubkey?.trim() const pk = pubkey?.trim()
if (!pk) { if (!pk) {
logger.debug(`${MEDIA_LOG} empty pubkey — no relay resolution`) setAuthorRelayUrls([])
setRefinedAuthorRelayUrls([])
return return
} }
let cancelled = false let cancelled = false
setRefinedAuthorRelayUrls(null) setAuthorRelayUrls(null)
void (async () => { void client
try { .fetchRelayList(pk)
const peeked = await client.peekRelayListFromStorage(pk) .catch(() => ({ read: [] as string[], write: [] as string[] }))
if (!cancelled) { .then((authorRl) => {
setRefinedAuthorRelayUrls(
buildAuthorInboxOutboxRelayUrls(peeked, blockedRelays, includeAuthorLocalRelays)
)
}
} catch {
/* keep provisionalAuthorRelayUrls */
}
const authorRl = await client.fetchRelayList(pk).catch(() => ({
read: [] as string[],
write: [] as string[]
}))
if (cancelled) return if (cancelled) return
const authorStack = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays, includeAuthorLocalRelays) setAuthorRelayUrls(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)
}) })
logger.debug(`${MEDIA_LOG} author inbox/outbox relay list`, { authorRelays: authorStack })
setRefinedAuthorRelayUrls(authorStack)
})()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [pubkey, blockedKey, blockedRelays, includeAuthorLocalRelays]) }, [pubkey, 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
const subRequests = useMemo(() => { const subRequests = useMemo(() => {
const pk = pubkey?.trim() const pk = pubkey?.trim()
if (!pk) return [] if (!pk || !authorRelayUrls?.length) return []
return buildProfileMediaSubRequests(authorRelayUrls, blockedRelays, pk) return buildProfileMediaSubRequests(authorRelayUrls, blockedRelays, pk)
}, [pubkey, authorRelayUrls, blockedRelays]) }, [pubkey, authorRelayUrls, blockedRelays])
@ -104,43 +57,19 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
return `profile-media-${normalizeHexPubkey(pk)}` return `profile-media-${normalizeHexPubkey(pk)}`
}, [pubkey]) }, [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], []) const showKinds = useMemo(() => [...PROFILE_MEDIA_TAB_KINDS], [])
if (!pubkey?.trim()) { if (authorRelayUrls === null) {
return ( return (
<div className="px-4 py-8 text-center text-sm text-muted-foreground"> <div className="min-h-[min(40vh,320px)] min-w-0 px-2 py-8 text-center text-sm text-muted-foreground">
{t('Nothing to load for this feed.')} {/* Skeleton while author NIP-65 resolves — avoids provisional→refined subRequest churn */}
</div> </div>
) )
} }
if (!subRequests.length) { if (!subRequests.length) {
return ( return (
<div className="px-4 py-8 text-center text-sm text-muted-foreground"> <div className="min-h-[min(40vh,320px)] min-w-0 px-2 py-8 text-center text-sm text-muted-foreground" />
{t('Nothing to load for this feed.')}
</div>
) )
} }
@ -153,12 +82,8 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
hostPrimaryPageName="profile" hostPrimaryPageName="profile"
showKinds={showKinds} showKinds={showKinds}
useFilterAsIs useFilterAsIs
/**
* Provisional author tier (empty) then NIP-65 inbox/outbox refinement; REQ filter unchanged merge rows.
*/
preserveTimelineOnSubRequestsChange preserveTimelineOnSubRequestsChange
mergeTimelineWhenSubRequestFiltersMatch mergeTimelineWhenSubRequestFiltersMatch
/** Same live {@link client.subscribeTimeline} path as {@link useProfileTimeline} on the Posts tab; filter is native media kinds only. */
revealBatchSize={48} revealBatchSize={48}
filterMutedNotes={false} filterMutedNotes={false}
showKind1OPs showKind1OPs

2
src/components/ProfileAbout/index.tsx

@ -1,6 +1,7 @@
import { import {
EmbeddedHashtagParser, EmbeddedHashtagParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedAboutCoinPaytoParser,
EmbeddedPaytoParser, EmbeddedPaytoParser,
EmbeddedUrlParser, EmbeddedUrlParser,
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
@ -40,6 +41,7 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
if (core) { if (core) {
const coreNodes = parseContent(core, [ const coreNodes = parseContent(core, [
EmbeddedAboutCoinPaytoParser,
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
EmbeddedUrlParser, EmbeddedUrlParser,
EmbeddedPaytoParser, EmbeddedPaytoParser,

93
src/data/payto-types.json

@ -3,6 +3,45 @@
"logoAssetsDir": "src/assets/payto_logos", "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)." "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": [ "editorOrder": [
"lightning", "lightning",
"bitcoin", "bitcoin",
@ -60,6 +99,10 @@
"symbol": "₿", "symbol": "₿",
"category": "bitcoin", "category": "bitcoin",
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg", "logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
"walletOpen": {
"scheme": "bitcoin",
"walletApps": ["cakewallet"]
},
"authority": { "authority": {
"placeholder": "bc1q…", "placeholder": "bc1q…",
"hint": "On-chain Bitcoin address (Bech32 bc1… preferred)" "hint": "On-chain Bitcoin address (Bech32 bc1… preferred)"
@ -70,15 +113,25 @@
"symbol": "₿", "symbol": "₿",
"category": "bitcoin", "category": "bitcoin",
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg", "logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
"walletOpen": {
"scheme": "bolt12",
"requirePrefix": "lno1",
"walletApps": ["phoenix"]
},
"authority": { "authority": {
"placeholder": "lno1…", "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": { "bip353": {
"label": "DNS Payment Instructions (BIP-353)", "label": "DNS Payment Instructions (BIP-353)",
"symbol": "⚡", "symbol": "⚡",
"category": "bitcoin-layer", "category": "bitcoin-layer",
"walletOpen": {
"scheme": "lightning",
"requireAtSign": true,
"walletApps": ["phoenix"]
},
"authority": { "authority": {
"placeholder": "user@example.com", "placeholder": "user@example.com",
"hint": "BIP-353 DNS payment instructions (human-readable name@domain, resolves to Lightning)" "hint": "BIP-353 DNS payment instructions (human-readable name@domain, resolves to Lightning)"
@ -89,6 +142,11 @@
"symbol": "₿", "symbol": "₿",
"category": "bitcoin", "category": "bitcoin",
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg", "logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
"walletOpen": {
"scheme": "bitcoin",
"requirePrefix": "sp1",
"walletApps": ["cakewallet"]
},
"authority": { "authority": {
"placeholder": "sp1…", "placeholder": "sp1…",
"hint": "BIP-352 silent payment address (sp1…)" "hint": "BIP-352 silent payment address (sp1…)"
@ -118,6 +176,11 @@
"label": "Lightning Network", "label": "Lightning Network",
"symbol": "⚡", "symbol": "⚡",
"category": "bitcoin-layer", "category": "bitcoin-layer",
"walletOpen": {
"scheme": "lightning",
"requireAtSign": true,
"walletApps": ["phoenix"]
},
"authority": { "authority": {
"placeholder": "user@getalby.com", "placeholder": "user@getalby.com",
"hint": "Lightning address (LUD-16): name@domain — not a BOLT11 invoice" "hint": "Lightning address (LUD-16): name@domain — not a BOLT11 invoice"
@ -128,6 +191,10 @@
"symbol": "Ξ", "symbol": "Ξ",
"category": "crypto", "category": "crypto",
"logoAssetPath": "src/assets/payto_logos/ethereum-eth-logo.svg", "logoAssetPath": "src/assets/payto_logos/ethereum-eth-logo.svg",
"walletOpen": {
"scheme": "ethereum",
"walletApps": ["cakewallet"]
},
"authority": { "authority": {
"placeholder": "0x…", "placeholder": "0x…",
"hint": "Ethereum address (0x + 40 hex characters)" "hint": "Ethereum address (0x + 40 hex characters)"
@ -138,6 +205,10 @@
"symbol": "ɱ", "symbol": "ɱ",
"category": "crypto", "category": "crypto",
"logoAssetPath": "src/assets/payto_logos/Monero.png", "logoAssetPath": "src/assets/payto_logos/Monero.png",
"walletOpen": {
"scheme": "monero",
"walletApps": ["cakewallet"]
},
"authority": { "authority": {
"placeholder": "4… or 8…", "placeholder": "4… or 8…",
"hint": "Monero primary address (starts with 4 or 8)" "hint": "Monero primary address (starts with 4 or 8)"
@ -147,6 +218,10 @@
"label": "Nano", "label": "Nano",
"symbol": "Ӿ", "symbol": "Ӿ",
"category": "crypto", "category": "crypto",
"walletOpen": {
"scheme": "nano",
"walletApps": ["cakewallet"]
},
"authority": { "authority": {
"placeholder": "nano_…", "placeholder": "nano_…",
"hint": "Nano account address (nano_ prefix)" "hint": "Nano account address (nano_ prefix)"
@ -190,6 +265,10 @@
"symbol": "₿", "symbol": "₿",
"category": "crypto", "category": "crypto",
"logoAssetPath": "src/assets/payto_logos/bitcoin-cash-bch-logo.svg", "logoAssetPath": "src/assets/payto_logos/bitcoin-cash-bch-logo.svg",
"walletOpen": {
"scheme": "bitcoincash",
"walletApps": ["cakewallet"]
},
"authority": { "authority": {
"placeholder": "bitcoincash:… or q…", "placeholder": "bitcoincash:… or q…",
"hint": "Bitcoin Cash address (CashAddr or legacy)" "hint": "Bitcoin Cash address (CashAddr or legacy)"
@ -200,6 +279,10 @@
"symbol": "Ð", "symbol": "Ð",
"category": "crypto", "category": "crypto",
"logoAssetPath": "src/assets/payto_logos/dogecoin-doge-logo.svg", "logoAssetPath": "src/assets/payto_logos/dogecoin-doge-logo.svg",
"walletOpen": {
"scheme": "dogecoin",
"walletApps": ["cakewallet"]
},
"authority": { "authority": {
"placeholder": "D…", "placeholder": "D…",
"hint": "Dogecoin address (usually starts with D)" "hint": "Dogecoin address (usually starts with D)"
@ -210,6 +293,10 @@
"symbol": "Ł", "symbol": "Ł",
"category": "crypto", "category": "crypto",
"logoAssetPath": "src/assets/payto_logos/Litecoin.png", "logoAssetPath": "src/assets/payto_logos/Litecoin.png",
"walletOpen": {
"scheme": "litecoin",
"walletApps": ["cakewallet"]
},
"authority": { "authority": {
"placeholder": "ltc1q… or L… or M…", "placeholder": "ltc1q… or L… or M…",
"hint": "Litecoin address" "hint": "Litecoin address"
@ -260,6 +347,10 @@
"symbol": "◎", "symbol": "◎",
"category": "crypto", "category": "crypto",
"logoAssetPath": "src/assets/payto_logos/solana.png", "logoAssetPath": "src/assets/payto_logos/solana.png",
"walletOpen": {
"scheme": "solana",
"walletApps": ["cakewallet"]
},
"authority": { "authority": {
"placeholder": "Base58 pubkey…", "placeholder": "Base58 pubkey…",
"hint": "Solana wallet address (base58, 32–44 characters)" "hint": "Solana wallet address (base58, 32–44 characters)"

33
src/hooks/useProfileAuthorFeedSubRequests.ts

@ -73,32 +73,20 @@ export function useProfileAuthorFeedSubRequests({
}, [pubkey]) }, [pubkey])
const [refreshToken, setRefreshToken] = useState(0) const [refreshToken, setRefreshToken] = useState(0)
const [provisionalUrls, setProvisionalUrls] = useState<string[]>([]) /** Single emission per visit: provisional→full relay stacks used to restart NoteList and wipe rows mid-fetch. */
const [fullUrls, setFullUrls] = useState<string[] | null>(null) const [relayUrls, setRelayUrls] = useState<string[] | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
setRelayUrls(null)
const socialKinds = kinds.some(isSocialKindBlockedKind) const socialKinds = kinds.some(isSocialKindBlockedKind)
const provisional = buildProfilePageReadRelayUrls(
favoriteRelays,
blockedRelays,
emptyAuthor,
socialKinds,
includeAuthorLocalRelays,
kinds,
useGlobalRelayBootstrap
)
if (!cancelled) {
setProvisionalUrls(provisional)
setFullUrls(null)
}
void client void client
.fetchRelayList(pubkey) .fetchRelayList(pubkey)
.catch(() => emptyAuthor) .catch(() => emptyAuthor)
.then((authorRl) => { .then((authorRl) => {
if (cancelled) return if (cancelled) return
const full = buildProfilePageReadRelayUrls( const urls = buildProfilePageReadRelayUrls(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
authorRl, authorRl,
@ -107,23 +95,18 @@ export function useProfileAuthorFeedSubRequests({
kinds, kinds,
useGlobalRelayBootstrap useGlobalRelayBootstrap
) )
setFullUrls(full) setRelayUrls(urls)
}) })
return () => { return () => {
cancelled = true 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]) }, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, includeAuthorLocalRelays, useGlobalRelayBootstrap])
const activeUrls = fullUrls?.length ? fullUrls : provisionalUrls
const subRequests = useMemo(() => { const subRequests = useMemo(() => {
if (!activeUrls.length) return [] as TFeedSubRequest[] if (!relayUrls?.length) return [] as TFeedSubRequest[]
return buildProfileAuthorSubRequestsFromUrlGroups([activeUrls], authorHex, [...kinds], limit) return buildProfileAuthorSubRequestsFromUrlGroups([relayUrls], authorHex, [...kinds], limit)
}, [activeUrls, authorHex, kinds, limit]) }, [relayUrls, authorHex, kinds, limit])
const followingFeedDeltaSubRequests = useMemo(() => [] as TFeedSubRequest[], []) const followingFeedDeltaSubRequests = useMemo(() => [] as TFeedSubRequest[], [])

3
src/hooks/useProfilePins.tsx

@ -80,7 +80,7 @@ function blockedRelaysContentKey(blockedRelays: string[]): string {
export function useProfilePins(pubkey: string | undefined) { export function useProfilePins(pubkey: string | undefined) {
const nostr = useNostrOptional() const nostr = useNostrOptional()
const { blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const viewerUsesGlobalBootstrap = useGlobalRelayBootstrapDefaults()
const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays]) const blockedKey = useMemo(() => blockedRelaysContentKey(blockedRelays), [blockedRelays])
const includeAuthorLocalRelays = useMemo(() => { const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim() const me = nostr?.pubkey?.trim()
@ -92,6 +92,7 @@ export function useProfilePins(pubkey: string | undefined) {
return false return false
} }
}, [nostr?.pubkey, pubkey]) }, [nostr?.pubkey, pubkey])
const useGlobalRelayBootstrap = viewerUsesGlobalBootstrap || !includeAuthorLocalRelays
const [pinEvents, setPinEvents] = useState<Event[]>([]) const [pinEvents, setPinEvents] = useState<Event[]>([])
const [loadingPins, setLoadingPins] = useState(false) const [loadingPins, setLoadingPins] = useState(false)

4
src/hooks/useProfileTimeline.tsx

@ -142,7 +142,7 @@ export function useProfileTimeline({
}: UseProfileTimelineOptions): UseProfileTimelineResult { }: UseProfileTimelineOptions): UseProfileTimelineResult {
const nostr = useNostrOptional() const nostr = useNostrOptional()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const viewerUsesGlobalBootstrap = useGlobalRelayBootstrapDefaults()
const includeAuthorLocalRelays = useMemo(() => { const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim() const me = nostr?.pubkey?.trim()
if (!me) return false if (!me) return false
@ -152,6 +152,8 @@ export function useProfileTimeline({
return false return false
} }
}, [nostr?.pubkey, pubkey]) }, [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( const relayListsKey = useMemo(
() => relayListsContentKey(favoriteRelays, blockedRelays), () => relayListsContentKey(favoriteRelays, blockedRelays),
[favoriteRelays, blockedRelays] [favoriteRelays, blockedRelays]

3
src/i18n/locales/en.ts

@ -128,6 +128,9 @@ export default {
"Click to open payment options": "Click to open payment options", "Click to open payment options": "Click to open payment options",
"Click to copy address": "Click to copy address", "Click to copy address": "Click to copy address",
"Open on website": "Open on website", "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", "Raw profile event": "Raw profile event",
"Full profile event": "Full profile event", "Full profile event": "Full profile event",
"Event (JSON)": "Event (JSON)", "Event (JSON)": "Event (JSON)",

4
src/lib/content-parser.ts

@ -11,6 +11,7 @@ import {
EMOJI_SHORT_CODE_REGEX EMOJI_SHORT_CODE_REGEX
} from '@/lib/content-patterns' } from '@/lib/content-patterns'
import { PAYTO_URI_REGEX } from '@/lib/payto' import { PAYTO_URI_REGEX } from '@/lib/payto'
import { parseAboutContentWithCoinPayto } from '@/lib/payto-about-coin-lines'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import { isImage, isMedia, isHlsPlaylistUrl, isBlossomBudBlobUrl } from './url' import { isImage, isMedia, isHlsPlaylistUrl, isBlossomBudBlobUrl } from './url'
import { isSpotifyOpenUrl } from './spotify-url' import { isSpotifyOpenUrl } from './spotify-url'
@ -84,6 +85,9 @@ export const EmbeddedPaytoParser: TContentParser = {
regex: PAYTO_URI_REGEX regex: PAYTO_URI_REGEX
} }
/** `XMR: 4abc…` lines in profile about (catalog coin labels). */
export const EmbeddedAboutCoinPaytoParser: TContentParser = parseAboutContentWithCoinPayto
export const EmbeddedUrlParser: TContentParser = (content: string) => { export const EmbeddedUrlParser: TContentParser = (content: string) => {
const matches = content.matchAll(URL_REGEX) const matches = content.matchAll(URL_REGEX)
const result: TEmbeddedNode[] = [] const result: TEmbeddedNode[] = []

42
src/lib/favorites-feed-relays.ts

@ -65,7 +65,10 @@ export function getFavoritesFeedRelayUrls(
/** /**
* Merge relay URL lists in order; first occurrence wins; drops blocked. * 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 blocked = blockedSet(blockedRelays)
const seen = new Set<string>() const seen = new Set<string>()
const out: string[] = [] const out: string[] = []
@ -115,10 +118,27 @@ export function buildProfileAugmentedReadRelayUrls(
useGlobalRelayBootstrap useGlobalRelayBootstrap
? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]) ? (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) 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 = { export type ReadRelayPriorityOptions = {
/** User NIP-65 write list — local URLs are promoted with inboxes for REQ. */ /** User NIP-65 write list — local URLs are promoted with inboxes for REQ. */
userWriteRelays?: string[] userWriteRelays?: string[]
@ -218,17 +238,29 @@ export function buildProfilePageReadRelayUrls(
allowThirdPartyLocalRelays: true allowThirdPartyLocalRelays: true
} }
) )
const pinFastReadForRemote = useGlobal && !includeAuthorLocalRelays
/** Authors without kind 10002: widen REQ targets so notes/metadata are still discoverable on index relays. */ /** Authors without kind 10002: widen REQ targets so notes/metadata are still discoverable on index relays. */
if (authorHasNoNip65) { if (authorHasNoNip65) {
const profileSource = useGlobal ? PROFILE_RELAY_URLS : profileFetchRelayUrlsWithoutFastReadLayer() const profileSource = useGlobal ? PROFILE_RELAY_URLS : profileFetchRelayUrlsWithoutFastReadLayer()
const profileFetchLayer = profileSource.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] 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) { if (wantsDocumentLayer) {
const docLayer = DOCUMENT_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] 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
} }
/** /**

40
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', () => { describe('prepareZapDialogAlternativePayments', () => {
const groups = [ const groups = [
{ {

29
src/lib/merge-payment-methods.ts

@ -4,9 +4,11 @@ import {
getCanonicalPaytoType, getCanonicalPaytoType,
getPaytoEditorTypeLabel, getPaytoEditorTypeLabel,
getPaytoTypeInfo, getPaytoTypeInfo,
isKnownPaytoType,
isLightningPaytoType, isLightningPaytoType,
isZappableLightningPaytoType isZappableLightningPaytoType
} from '@/lib/payto' } from '@/lib/payto'
import { extractKind0PaymentMethodsFromProfileJson } from '@/lib/payto-kind0-import'
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url' import { normalizePaypalAuthority } from '@/lib/payto-paypal-url'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
import { kinds, type Event } from 'nostr-tools' import { kinds, type Event } from 'nostr-tools'
@ -196,6 +198,25 @@ export function mergePaymentMethods(
return 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') { if (cur === 'usdt' || cur === 'usd₮' || cur === 'tether' || net === 'usdt') {
add('usdt', addr, buildPaytoUri('usdt', addr), 'Tether (USDT)', { currency: w.currency || 'USDT' }) add('usdt', addr, buildPaytoUri('usdt', addr), 'Tether (USDT)', { currency: w.currency || 'USDT' })
return return
@ -235,6 +256,14 @@ export function mergePaymentMethods(
} }
if (profileEvent?.kind === kinds.Metadata) { 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) { for (const tag of profileEvent.tags) {
if (tag[0] === 'payto' && tag[1] && tag[2]) { if (tag[0] === 'payto' && tag[1] && tag[2]) {
const type = String(tag[1]).toLowerCase() const type = String(tag[1]).toLowerCase()

36
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)
})
})

142
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<string, string>
aliases?: Record<string, string>
}
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<string>()
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<string>()
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 }]
}

37
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)
})
})

94
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<string, string>
kind0RootPaymentFields?: Record<string, string>
}
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<string, unknown>
const seen = new Set<string>()
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<string, unknown>)) {
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
}

16
src/lib/payto-registry.ts

@ -5,6 +5,7 @@
import paytoTypesCatalog from '@/data/payto-types.json' import paytoTypesCatalog from '@/data/payto-types.json'
import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos' import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos'
import { getPaytoPrimaryOpenUrl } from '@/lib/payto-wallet-open'
import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url' import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url'
export type PaytoCategory = 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip' export type PaytoCategory = 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip'
@ -14,6 +15,16 @@ export type PaytoAuthorityHelp = {
hint: string hint: string
} }
export type PaytoWalletOpenRow = {
scheme?: string
style?: 'path' | 'query'
path?: string
query?: Record<string, string>
requireAtSign?: boolean
requirePrefix?: string
walletApps?: string[]
}
export type PaytoTypeRecord = { export type PaytoTypeRecord = {
label: string label: string
symbol?: string symbol?: string
@ -21,6 +32,8 @@ export type PaytoTypeRecord = {
/** Repo-relative path, e.g. `src/assets/payto_logos/ethereum-eth-logo.svg`. */ /** Repo-relative path, e.g. `src/assets/payto_logos/ethereum-eth-logo.svg`. */
logoAssetPath?: string logoAssetPath?: string
profileUrlTemplate?: string profileUrlTemplate?: string
/** Native wallet URI / app deep link (see {@link getPaytoPrimaryOpenUrl}). */
walletOpen?: PaytoWalletOpenRow
authority?: PaytoAuthorityHelp authority?: PaytoAuthorityHelp
} }
@ -111,6 +124,9 @@ export function getPaytoProfileUrl(type: string, authority: string): string | nu
return resolvePaypalPaymentUrl(authority) return resolvePaypalPaymentUrl(authority)
} }
const fromWallet = getPaytoPrimaryOpenUrl(type, authority)
if (fromWallet) return fromWallet
const template = getPaytoTypeRecord(type)?.profileUrlTemplate const template = getPaytoTypeRecord(type)?.profileUrlTemplate
if (!template) return null if (!template) return null
return template.replace('{authority}', encodeURIComponent(authority.trim())) return template.replace('{authority}', encodeURIComponent(authority.trim()))

121
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)
})
})

236
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<string, string>
types: Record<string, PaytoTypeRecordWallet>
walletApps?: Record<string, WalletAppRowJson>
}
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<string, string>, 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<string>()
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)
}

23
src/lib/payto.ts

@ -23,9 +23,22 @@ export {
PAYTO_EDITOR_TYPE_ORDER, PAYTO_EDITOR_TYPE_ORDER,
PAYTO_KNOWN_TYPES, PAYTO_KNOWN_TYPES,
type PaytoAuthorityHelp, type PaytoAuthorityHelp,
type PaytoCategory type PaytoCategory,
type PaytoWalletOpenRow
} from '@/lib/payto-registry' } 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 const PAYTO_URI_REGEX = /payto:\/\/([a-z0-9-]+)\/([^\s\]\)\<\"']+)/gi
export interface ParsedPayto { export interface ParsedPayto {
@ -64,3 +77,11 @@ export {
PAYTO_INLINE_DISPLAY_AUTHORITY_CHARS, PAYTO_INLINE_DISPLAY_AUTHORITY_CHARS,
truncatePaytoAuthority truncatePaytoAuthority
} from '@/lib/payto-display' } from '@/lib/payto-display'
export { extractKind0PaymentMethodsFromProfileJson, type Kind0ImportedPaymentMethod } from '@/lib/payto-kind0-import'
export {
extractAboutCoinPaymentMethods,
parseAboutCoinLabelPaymentLines,
type AboutCoinLineMatch
} from '@/lib/payto-about-coin-lines'

14
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 type { TSubRequestFilter } from '@/types'
import { normalizeHexPubkey } from '@/lib/pubkey' import { normalizeHexPubkey } from '@/lib/pubkey'
import type { Filter } from 'nostr-tools' import type { Filter } from 'nostr-tools'
@ -62,3 +64,15 @@ export function getProfileAuthorWarmupRelayUrls(
} }
return out 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)
}

25
src/lib/relay-url-priority.test.ts

@ -92,7 +92,7 @@ describe('buildProfilePageReadRelayUrls', () => {
syncViewerRelayStackNostrLandAggrEligible([]) 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/']) syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/'])
const out = buildProfilePageReadRelayUrls( const out = buildProfilePageReadRelayUrls(
[], [],
@ -101,11 +101,32 @@ describe('buildProfilePageReadRelayUrls', () => {
read: Array.from({ length: 20 }, (_, i) => `wss://author-inbox-${i}.example/`), read: Array.from({ length: 20 }, (_, i) => `wss://author-inbox-${i}.example/`),
write: ['wss://author-outbox.example/'] write: ['wss://author-outbox.example/']
}, },
false false,
true
) )
expect(out[0]).toBe('wss://aggr.nostr.land/') expect(out[0]).toBe('wss://aggr.nostr.land/')
expect(out[1]).toBe('wss://author-outbox.example/') expect(out[1]).toBe('wss://author-outbox.example/')
syncViewerRelayStackNostrLandAggrEligible([]) 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)
})
}) })

Loading…
Cancel
Save