24 changed files with 1066 additions and 221 deletions
@ -0,0 +1,36 @@
@@ -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) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,142 @@
@@ -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 }] |
||||
} |
||||
@ -0,0 +1,37 @@
@@ -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) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,94 @@
@@ -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 |
||||
} |
||||
@ -0,0 +1,121 @@
@@ -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) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,236 @@
@@ -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) |
||||
} |
||||
Loading…
Reference in new issue