You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
342 lines
12 KiB
342 lines
12 KiB
import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' |
|
import { |
|
buildPaytoUri, |
|
getCanonicalPaytoType, |
|
getPaytoEditorTypeLabel, |
|
getPaytoTypeInfo, |
|
isLightningPaytoType, |
|
isZappableLightningPaytoType |
|
} from '@/lib/payto' |
|
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url' |
|
import type { TProfile } from '@/types' |
|
import { kinds, type Event } from 'nostr-tools' |
|
|
|
export type MergedPaymentMethod = { |
|
type: string |
|
authority: string |
|
payto?: string |
|
displayType: string |
|
currency?: string |
|
minAmount?: number |
|
maxAmount?: number |
|
} |
|
|
|
export type PaymentMethodGroup = { |
|
displayType: string |
|
methods: MergedPaymentMethod[] |
|
/** Zap dialog: emphasize on-chain Bitcoin when tip is ≥ 10k sats. */ |
|
highlighted?: boolean |
|
} |
|
|
|
export type ZapDialogAlternativePayments = { |
|
groups: PaymentMethodGroup[] |
|
/** Show banner when on-chain Bitcoin targets are listed (≥ 10k sats). */ |
|
showBitcoinOnChainHint: boolean |
|
} |
|
|
|
/** Normalize lightning/LUD-16 authority to a canonical form for deduplication. */ |
|
export function normalizeLightningAuthority(authority: string): string { |
|
const s = authority.trim().toLowerCase() |
|
if (!s) return s |
|
if (s.includes('@')) return s |
|
const firstDot = s.indexOf('.') |
|
if (firstDot > 0) return s.slice(0, firstDot) + '@' + s.slice(firstDot + 1) |
|
return s |
|
} |
|
|
|
export function normalizePaymentAuthority(type: string, authority: string): string { |
|
const t = type.toLowerCase() |
|
if (t === 'lightning' && authority) return normalizeLightningAuthority(authority) |
|
return authority.trim().toLowerCase() |
|
} |
|
|
|
function preferCanonicalLightningAuthority(a: string, b: string): string { |
|
const hasAt = (s: string) => s.trim().includes('@') |
|
if (hasAt(a) && !hasAt(b)) return a |
|
if (hasAt(b) && !hasAt(a)) return b |
|
return a |
|
} |
|
|
|
/** Canonical LUD-16 authority (user@domain) for display and payto:// URIs. */ |
|
function resolveLightningAuthority(a: string, b?: string): string { |
|
const preferred = b !== undefined ? preferCanonicalLightningAuthority(a, b) : a |
|
return normalizeLightningAuthority(preferred) || preferred.trim() |
|
} |
|
|
|
/** Below this zap size, on-chain Bitcoin payto targets are hidden in the zap dialog. */ |
|
export const ZAP_HIDE_BITCOIN_ALTS_MAX_SATS = 10_000 |
|
|
|
/** On-chain Bitcoin family (not Lightning / Liquid layer types). */ |
|
export function isBitcoinCategoryPaytoType(type: string): boolean { |
|
return getPaytoTypeInfo(getCanonicalPaytoType(type))?.category === 'bitcoin' |
|
} |
|
|
|
/** Sort key for zap dialog “other payment” groups (lower = higher in list). */ |
|
function zapAlternativeGroupSortRank(group: PaymentMethodGroup): number { |
|
const types = group.methods.map((m) => getCanonicalPaytoType(m.type)) |
|
if (types.some((t) => isBitcoinCategoryPaytoType(t))) return -1000 |
|
if (types.some((t) => getPaytoTypeInfo(t)?.category === 'bitcoin-layer' || t === 'liquid' || t === 'lbtc')) { |
|
return 0 |
|
} |
|
if (types.some((t) => t === 'monero')) return 1 |
|
if (types.some((t) => t === 'usdt')) return 2 |
|
if (types.some((t) => t === 'usdc')) return 3 |
|
return 10 |
|
} |
|
|
|
/** Filter, order, and annotate payto groups for the zap dialog “other payment methods” block. */ |
|
export function prepareZapDialogAlternativePayments( |
|
groups: PaymentMethodGroup[], |
|
zapSats: number |
|
): ZapDialogAlternativePayments { |
|
const showBitcoin = zapSats >= ZAP_HIDE_BITCOIN_ALTS_MAX_SATS |
|
|
|
const filtered = showBitcoin |
|
? groups |
|
: groups |
|
.map((group) => ({ |
|
...group, |
|
methods: group.methods.filter((m) => !isBitcoinCategoryPaytoType(m.type)) |
|
})) |
|
.filter((group) => group.methods.length > 0) |
|
|
|
const prepared = filtered |
|
.map((group) => ({ |
|
...group, |
|
highlighted: |
|
showBitcoin && group.methods.some((m) => isBitcoinCategoryPaytoType(m.type)) |
|
})) |
|
.sort((a, b) => zapAlternativeGroupSortRank(a) - zapAlternativeGroupSortRank(b)) |
|
|
|
return { |
|
groups: prepared, |
|
showBitcoinOnChainHint: showBitcoin && prepared.some((g) => g.highlighted) |
|
} |
|
} |
|
|
|
/** @deprecated Use {@link prepareZapDialogAlternativePayments} */ |
|
export function filterPaymentMethodGroupsForZapAmount( |
|
groups: PaymentMethodGroup[], |
|
zapSats: number |
|
): PaymentMethodGroup[] { |
|
return prepareZapDialogAlternativePayments(groups, zapSats).groups |
|
} |
|
|
|
/** Bitcoin-layer first, then on-chain Bitcoin family, then everything else. */ |
|
export function paytoPaymentSortRank(type: string): number { |
|
const category = getPaytoTypeInfo(type)?.category |
|
if (category === 'bitcoin-layer') return 0 |
|
if (category === 'bitcoin') return 1 |
|
return 2 |
|
} |
|
|
|
/** Merge payment methods from kind 10133 and profile (kind 0: JSON + tags), normalized and deduplicated. */ |
|
export function mergePaymentMethods( |
|
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null, |
|
profile: TProfile | null, |
|
profileEvent?: Event | null |
|
): MergedPaymentMethod[] { |
|
const seen = new Map<string, MergedPaymentMethod>() |
|
const out: MergedPaymentMethod[] = [] |
|
|
|
const add = ( |
|
type: string, |
|
authority: string, |
|
payto?: string, |
|
displayType?: string, |
|
extra?: { currency?: string; minAmount?: number; maxAmount?: number } |
|
) => { |
|
if (!authority?.trim()) return |
|
const normType = getCanonicalPaytoType(type) |
|
const key = `${normType}:${normalizePaymentAuthority(normType, authority)}` |
|
const existing = seen.get(key) |
|
if (existing) { |
|
if (isLightningPaytoType(normType)) { |
|
existing.authority = resolveLightningAuthority(existing.authority, authority.trim()) |
|
existing.payto = buildPaytoUri(normType, existing.authority) |
|
} |
|
return |
|
} |
|
const trimmedAuthority = authority.trim() |
|
const resolvedAuthority = |
|
isLightningPaytoType(normType) |
|
? resolveLightningAuthority(trimmedAuthority) |
|
: normType === 'paypal' |
|
? normalizePaypalAuthority(trimmedAuthority) |
|
: trimmedAuthority |
|
const entry: MergedPaymentMethod = { |
|
type: normType, |
|
authority: resolvedAuthority, |
|
payto: payto || (normType && resolvedAuthority ? buildPaytoUri(normType, resolvedAuthority) : undefined), |
|
displayType: displayType || getPaytoEditorTypeLabel(normType), |
|
...extra |
|
} |
|
seen.set(key, entry) |
|
out.push(entry) |
|
} |
|
|
|
const fromProfile = profile?.lightningAddressList?.length |
|
? profile.lightningAddressList |
|
: profile?.lightningAddress |
|
? [profile.lightningAddress] |
|
: [] |
|
fromProfile.forEach((addr) => { |
|
if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network') |
|
}) |
|
|
|
profile?.wWalletTags?.forEach((w) => { |
|
const net = w.network.toLowerCase() |
|
if (net === 'lightning') return |
|
const addr = w.address?.trim() |
|
if (!addr) return |
|
const cur = (w.currency || '').trim().toLowerCase() |
|
|
|
if (net === 'bitcoin') { |
|
add('bitcoin', addr, buildPaytoUri('bitcoin', addr), 'Bitcoin', { currency: w.currency }) |
|
return |
|
} |
|
|
|
if (cur === 'usdt' || cur === 'usd₮' || cur === 'tether' || net === 'usdt') { |
|
add('usdt', addr, buildPaytoUri('usdt', addr), 'Tether (USDT)', { currency: w.currency || 'USDT' }) |
|
return |
|
} |
|
|
|
if (net === 'liquid') { |
|
if (cur === 'lbtc' || cur === 'l-btc' || cur === 'liquid btc') { |
|
add('lbtc', addr, buildPaytoUri('lbtc', addr), 'Liquid Bitcoin (LBTC)', { currency: w.currency }) |
|
} else { |
|
add('liquid', addr, buildPaytoUri('liquid', addr), cur ? `Liquid (${w.currency})` : 'Liquid', { |
|
currency: w.currency |
|
}) |
|
} |
|
return |
|
} |
|
|
|
if (cur === 'lbtc' || cur === 'l-btc') { |
|
add('lbtc', addr, buildPaytoUri('lbtc', addr), 'Liquid Bitcoin (LBTC)', { currency: w.currency }) |
|
} |
|
}) |
|
|
|
if (paymentInfo?.methods?.length) { |
|
paymentInfo.methods.forEach((m) => { |
|
const authority = m.authority || m.address || '' |
|
add( |
|
(m.type || 'lightning').toLowerCase(), |
|
authority, |
|
m.payto, |
|
m.displayType, |
|
{ currency: m.currency, minAmount: m.minAmount, maxAmount: m.maxAmount } |
|
) |
|
}) |
|
} else if (paymentInfo?.payto) { |
|
const type = (paymentInfo.type || 'lightning').toLowerCase() |
|
const authority = paymentInfo.authority || paymentInfo.payto.replace(/^payto:\/\/[^/]+\//, '') || '' |
|
add(type, authority, paymentInfo.payto, type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment') |
|
} |
|
|
|
if (profileEvent?.kind === kinds.Metadata) { |
|
for (const tag of profileEvent.tags) { |
|
if (tag[0] === 'payto' && tag[1] && tag[2]) { |
|
const type = String(tag[1]).toLowerCase() |
|
add(type, String(tag[2]), buildPaytoUri(getCanonicalPaytoType(type), String(tag[2]))) |
|
} |
|
} |
|
} |
|
|
|
return out |
|
} |
|
|
|
/** True when the recipient has any payto / Lightning target (kind 0 or 10133). */ |
|
export function recipientHasAnyPaymentOptions( |
|
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null, |
|
profile: TProfile | null, |
|
profileEvent?: Event | null |
|
): boolean { |
|
return mergePaymentMethods(paymentInfo, profile, profileEvent).length > 0 |
|
} |
|
|
|
export function sortMergedPaymentMethods(methods: MergedPaymentMethod[]): MergedPaymentMethod[] { |
|
return [...methods].sort((a, b) => paytoPaymentSortRank(a.type) - paytoPaymentSortRank(b.type)) |
|
} |
|
|
|
/** Group payment methods by displayType (same headings as profile payment section). */ |
|
export function groupPaymentMethodsByDisplayType(methods: MergedPaymentMethod[]): PaymentMethodGroup[] { |
|
const groups = new Map<string, MergedPaymentMethod[]>() |
|
for (const method of methods) { |
|
const key = method.displayType || method.type |
|
if (!groups.has(key)) groups.set(key, []) |
|
groups.get(key)!.push(method) |
|
} |
|
const order = Array.from(groups.keys()).sort((a, b) => { |
|
const typeA = groups.get(a)?.[0]?.type ?? '' |
|
const typeB = groups.get(b)?.[0]?.type ?? '' |
|
return paytoPaymentSortRank(typeA) - paytoPaymentSortRank(typeB) |
|
}) |
|
return order.map((key) => ({ displayType: key, methods: groups.get(key) ?? [] })) |
|
} |
|
|
|
/** |
|
* Ordered Lightning targets for zaps: kind 0 `lud16` tags, then `w` (network lightning), |
|
* then kind 10133 lightning methods. Optional `preferredAddress` is moved to the front. |
|
*/ |
|
export function buildOrderedZapLightningAddresses(opts: { |
|
profileEvent?: Event | null |
|
/** Parsed kind 0 when the event is not loaded yet (e.g. feed profile row). */ |
|
profile?: TProfile | null |
|
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null |
|
preferredAddress?: string | null |
|
}): string[] { |
|
const seen = new Set<string>() |
|
const out: string[] = [] |
|
|
|
const add = (raw: string | undefined) => { |
|
if (!raw?.trim()) return |
|
const resolved = resolveLightningAuthority(raw.trim()) |
|
const key = normalizePaymentAuthority('lightning', resolved) |
|
if (seen.has(key)) return |
|
seen.add(key) |
|
out.push(resolved) |
|
} |
|
|
|
const ev = opts.profileEvent |
|
const profile = |
|
ev?.kind === kinds.Metadata ? getProfileFromEvent(ev) : (opts.profile ?? null) |
|
if (ev?.kind === kinds.Metadata) { |
|
for (const tag of ev.tags) { |
|
if (tag[0] === 'lud16' && tag[1]) add(tag[1]) |
|
if (tag[0] === 'lud06' && tag[1]) add(tag[1]) |
|
} |
|
for (const tag of ev.tags) { |
|
if (tag[0] !== 'w' || !tag[1] || !tag[2]) continue |
|
if (tag[3] && String(tag[3]).toLowerCase() === 'lightning') { |
|
add(tag[2]) |
|
} else if (!tag[3] && String(tag[1]).toLowerCase() === 'lightning') { |
|
add(tag[2]) |
|
} |
|
} |
|
} |
|
|
|
const paymentMethods = mergePaymentMethods(opts.paymentInfo, profile, ev) |
|
for (const m of paymentMethods) { |
|
if (isZappableLightningPaytoType(m.type)) add(m.authority) |
|
} |
|
|
|
return prioritizeZapLightningAddress(out, opts.preferredAddress ?? undefined) |
|
} |
|
|
|
/** Move `preferred` to the front when present; append if not already listed. */ |
|
export function prioritizeZapLightningAddress(candidates: string[], preferred?: string): string[] { |
|
if (!preferred?.trim()) return candidates |
|
const norm = normalizePaymentAuthority('lightning', preferred) |
|
const idx = candidates.findIndex((c) => normalizePaymentAuthority('lightning', c) === norm) |
|
if (idx === -1) { |
|
return [resolveLightningAuthority(preferred.trim()), ...candidates] |
|
} |
|
const rest = candidates.filter((_, i) => i !== idx) |
|
return [candidates[idx], ...rest] |
|
} |
|
|
|
/** Non-zap payto targets for zap dialog “other payment methods” (LUD-16 uses the Lightning selector). */ |
|
export function getAlternativePaymentMethods(methods: MergedPaymentMethod[]): MergedPaymentMethod[] { |
|
return methods.filter((m) => !isZappableLightningPaytoType(m.type)) |
|
}
|
|
|