Browse Source

get rid of long-press zaps

change zaps to tips, inclluding icon
imwald
Silberengel 3 weeks ago
parent
commit
889fa0a286
  1. 7
      src/components/NoteStats/ZapButton.tsx
  2. 2
      src/components/PaymentMethodsSection/index.tsx
  3. 5
      src/components/PaytoDialog/LightningInvoiceSection.tsx
  4. 1
      src/components/Profile/index.tsx
  5. 6
      src/components/ProfileZapButton/index.tsx
  6. 7
      src/components/ZapDialog/index.tsx
  7. 62
      src/hooks/useNip57QuickZap.ts
  8. 20
      src/i18n/locales/de.ts
  9. 20
      src/i18n/locales/en.ts
  10. 4
      src/pages/secondary/ProfileEditorPage/index.tsx
  11. 118
      src/services/lightning.service.ts

7
src/components/NoteStats/ZapButton.tsx

@ -13,7 +13,7 @@ import { useNostr } from '@/providers/NostrProvider'
import client, { replaceableEventService } from '@/services/client.service' import client, { replaceableEventService } from '@/services/client.service'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
import { kinds, type Event } from 'nostr-tools' import { kinds, type Event } from 'nostr-tools'
import { Zap } from 'lucide-react' import { Coins } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ZapDialog from '../ZapDialog' import ZapDialog from '../ZapDialog'
@ -217,7 +217,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
setOpenPaymentDialog(true) setOpenPaymentDialog(true)
} }
const zapButtonTitle = disable ? t('Zaps') : t('Payment methods') const zapButtonTitle = disable ? t('Tips') : t('Leave a tip')
return ( return (
<> <>
@ -233,9 +233,8 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
disabled={disable} disabled={disable}
onClick={handleOpenPaymentMethods} onClick={handleOpenPaymentMethods}
> >
<Zap <Coins
className={cn( className={cn(
hasZapped && 'fill-yellow-400',
disable disable
? 'text-muted-foreground/40' ? 'text-muted-foreground/40'
: cn( : cn(

2
src/components/PaymentMethodsSection/index.tsx

@ -130,7 +130,7 @@ export default function PaymentMethodsSection({
return ( return (
<div className={className}> <div className={className}>
<div className="text-xs font-semibold text-muted-foreground mb-2"> <div className="text-xs font-semibold text-muted-foreground mb-2">
{title ?? t('Payment Methods')} {title ?? t('Payment targets')}
</div> </div>
<div className="space-y-3 min-w-0"> <div className="space-y-3 min-w-0">
<PaymentMethodGroupsList groups={preferredGroups} {...listProps} /> <PaymentMethodGroupsList groups={preferredGroups} {...listProps} />

5
src/components/PaytoDialog/LightningInvoiceSection.tsx

@ -195,6 +195,11 @@ export default function LightningInvoiceSection({
</p> </p>
<p className="break-all text-base font-medium leading-snug sm:text-lg">{lightningAddress}</p> <p className="break-all text-base font-medium leading-snug sm:text-lg">{lightningAddress}</p>
</div> </div>
<p className="text-sm leading-relaxed text-muted-foreground">
{t(
'Plain Lightning payments do not create NIP-57 zap receipts on Nostr. Use the zap button when available.'
)}
</p>
<div className="min-w-0 space-y-3"> <div className="min-w-0 space-y-3">
<Label htmlFor="ln-invoice-sats" className="text-sm font-medium text-muted-foreground sm:text-base"> <Label htmlFor="ln-invoice-sats" className="text-sm font-medium text-muted-foreground sm:text-base">

1
src/components/Profile/index.tsx

@ -600,6 +600,7 @@ export default function Profile({
<PaymentMethodsSection <PaymentMethodsSection
groups={paymentMethodsByType} groups={paymentMethodsByType}
recipientPubkey={pubkey} recipientPubkey={pubkey}
title={t('Payment targets')}
className="mt-2 mb-4 p-3 pb-4 border rounded-lg bg-muted/50 min-w-0" className="mt-2 mb-4 p-3 pb-4 border rounded-lg bg-muted/50 min-w-0"
/> />
)} )}

6
src/components/ProfileZapButton/index.tsx

@ -1,7 +1,7 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useRecipientPaymentData } from '@/hooks/useRecipientAlternativePayments' import { useRecipientPaymentData } from '@/hooks/useRecipientAlternativePayments'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Zap } from 'lucide-react' import { Coins } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ZapDialog from '../ZapDialog' import ZapDialog from '../ZapDialog'
@ -22,7 +22,7 @@ export default function ProfileZapButton({
const setOpen = setOpenZapDialog ?? setInternalOpen const setOpen = setOpenZapDialog ?? setInternalOpen
const recipientPayment = useRecipientPaymentData(pubkey, true) const recipientPayment = useRecipientPaymentData(pubkey, true)
const title = t('Payment methods') const title = t('Leave a tip')
return ( return (
<> <>
@ -34,7 +34,7 @@ export default function ProfileZapButton({
aria-label={title} aria-label={title}
onClick={() => checkLogin(() => setOpen(true))} onClick={() => checkLogin(() => setOpen(true))}
> >
<Zap className="text-yellow-400" /> <Coins className="text-yellow-400" />
</Button> </Button>
{!setOpenZapDialog && ( {!setOpenZapDialog && (
<ZapDialog open={open} setOpen={setInternalOpen} pubkey={pubkey} prefetchedPayment={recipientPayment} /> <ZapDialog open={open} setOpen={setInternalOpen} pubkey={pubkey} prefetchedPayment={recipientPayment} />

7
src/components/ZapDialog/index.tsx

@ -96,13 +96,14 @@ export default function ZapDialog({
}, [recipientPayment, senderPaytoTypes]) }, [recipientPayment, senderPaytoTypes])
const { canQuickNip57Zap, quickZapLabel, sendQuickZap, zapping } = useNip57QuickZap({ const { canQuickNip57Zap, quickZapLabel, sendQuickZap, zapping } = useNip57QuickZap({
enabled: open,
recipientPubkey: pubkey, recipientPubkey: pubkey,
referencedEvent: event, referencedEvent: event,
recipientPayment, recipientPayment,
onZapDialogClose: () => setOpen(false) onZapDialogClose: () => setOpen(false)
}) })
const dialogTitle = t('Payment methods') const dialogTitle = t('Leave a tip')
const body = const body =
paymentGroups.length > 0 || canQuickNip57Zap ? ( paymentGroups.length > 0 || canQuickNip57Zap ? (
<> <>
@ -116,14 +117,14 @@ export default function ZapDialog({
referencedEvent={event} referencedEvent={event}
offerTipNoticeOnClose={false} offerTipNoticeOnClose={false}
onPostPaymentRequest={openPostPaymentPrompt} onPostPaymentRequest={openPostPaymentPrompt}
title={t('Payment methods')} title={t('Tip options')}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0" className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/> />
) : null} ) : null}
</> </>
) : ( ) : (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="py-8 text-center text-sm text-muted-foreground">
{t('No payment methods available for this profile')} {t('No payment targets on this profile')}
</p> </p>
) )

62
src/hooks/useNip57QuickZap.ts

@ -6,12 +6,14 @@ import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import type { RecipientPaymentData } from '@/hooks/useRecipientAlternativePayments' import type { RecipientPaymentData } from '@/hooks/useRecipientAlternativePayments'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatSatsGrouped } from '@/lib/lightning' import { formatSatsGrouped } from '@/lib/lightning'
export function useNip57QuickZap(opts: { export function useNip57QuickZap(opts: {
/** Probe LNURL-pay only while the payment dialog is open (avoids feed-wide fetch storms). */
enabled?: boolean
recipientPubkey: string recipientPubkey: string
referencedEvent?: NostrEvent referencedEvent?: NostrEvent
recipientPayment: RecipientPaymentData recipientPayment: RecipientPaymentData
@ -19,23 +21,55 @@ export function useNip57QuickZap(opts: {
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { isWalletConnected, defaultZapSats, defaultZapComment } = useZap() const { isWalletConnected, defaultZapSats, defaultZapComment, includePublicZapReceipt } = useZap()
const [zapping, setZapping] = useState(false) const [zapping, setZapping] = useState(false)
const enabled = opts.enabled ?? false
const lightningAddressOptions = useMemo( const lightningAddressOptionsKey = useMemo(
() => () =>
buildOrderedZapLightningAddresses({ buildOrderedZapLightningAddresses({
profileEvent: opts.recipientPayment.profileEvent, profileEvent: opts.recipientPayment.profileEvent,
profile: opts.recipientPayment.profile, profile: opts.recipientPayment.profile,
paymentInfo: opts.recipientPayment.paymentInfo paymentInfo: opts.recipientPayment.paymentInfo
}), }).join('\u0001'),
[opts.recipientPayment] [
opts.recipientPayment.profileEvent,
opts.recipientPayment.profile,
opts.recipientPayment.paymentInfo
]
) )
const [nip57Addresses, setNip57Addresses] = useState<string[] | null>(null)
useEffect(() => {
if (!enabled) {
setNip57Addresses(null)
return
}
let cancelled = false
if (!lightningAddressOptionsKey) {
setNip57Addresses([])
return
}
const candidates = lightningAddressOptionsKey.split('\u0001')
setNip57Addresses(null)
void lightning.filterNip57ZapEnabledAddresses(candidates).then((addrs) => {
if (!cancelled) setNip57Addresses(addrs)
})
return () => {
cancelled = true
}
}, [enabled, lightningAddressOptionsKey])
const canQuickNip57Zap = const canQuickNip57Zap =
enabled &&
isWalletConnected && isWalletConnected &&
defaultZapSats >= 1 && defaultZapSats >= 1 &&
lightningAddressOptions.length > 0 && nip57Addresses !== null &&
nip57Addresses.length > 0 &&
!!pubkey && !!pubkey &&
pubkey !== opts.recipientPubkey pubkey !== opts.recipientPubkey
@ -50,7 +84,7 @@ export function useNip57QuickZap(opts: {
}) })
const sendQuickZap = useCallback(() => { const sendQuickZap = useCallback(() => {
if (!canQuickNip57Zap || zapping) return if (!canQuickNip57Zap || zapping || !nip57Addresses?.length) return
checkLogin(async () => { checkLogin(async () => {
if (!pubkey) return if (!pubkey) return
try { try {
@ -63,11 +97,18 @@ export function useNip57QuickZap(opts: {
opts.onZapDialogClose, opts.onZapDialogClose,
undefined, undefined,
{ {
address: lightningAddressOptions[0], address: nip57Addresses[0],
candidates: lightningAddressOptions candidates: nip57Addresses
} }
) )
if (!zapResult) return if (!zapResult) return
if (includePublicZapReceipt && zapResult.zapReceipt === null) {
toast.warning(
t(
'Zap paid but no public receipt was published. The recipient may not use a NIP-57 zap wallet.'
)
)
}
if (opts.referencedEvent) { if (opts.referencedEvent) {
noteStatsService.addZap( noteStatsService.addZap(
pubkey, pubkey,
@ -86,11 +127,12 @@ export function useNip57QuickZap(opts: {
}, [ }, [
canQuickNip57Zap, canQuickNip57Zap,
zapping, zapping,
nip57Addresses,
checkLogin, checkLogin,
pubkey, pubkey,
defaultZapSats, defaultZapSats,
defaultZapComment, defaultZapComment,
lightningAddressOptions, includePublicZapReceipt,
opts, opts,
t t
]) ])

20
src/i18n/locales/de.ts

@ -212,9 +212,14 @@ export default {
'Failed to publish payment info': 'Failed to publish payment info', 'Failed to publish payment info': 'Failed to publish payment info',
'Invalid tags JSON': 'Invalid tags JSON', 'Invalid tags JSON': 'Invalid tags JSON',
'Payment methods': 'Zahlungsmethoden', 'Payment methods': 'Zahlungsmethoden',
'Send a payment to this user': 'Zahlung an diese Person senden', 'Leave a tip': 'Trinkgeld geben',
Tips: 'Tipps',
'Payment targets': 'Zahlungsziele',
'Tip options': 'Tipp-Optionen',
'Send a payment to this user': 'Trinkgeld geben',
'No payment methods available for this profile': 'No payment methods available for this profile':
'Keine Zahlungsmethoden für dieses Profil hinterlegt', 'Keine Zahlungsziele in diesem Profil',
'No payment targets on this profile': 'Keine Zahlungsziele in diesem Profil',
'Other payment methods': 'Weitere Zahlungsmethoden', 'Other payment methods': 'Weitere Zahlungsmethoden',
'Lightning address for zap': 'Lightning-Adresse für Zap', 'Lightning address for zap': 'Lightning-Adresse für Zap',
'Select lightning address': 'Lightning-Adresse wählen', 'Select lightning address': 'Lightning-Adresse wählen',
@ -289,7 +294,8 @@ export default {
'NIP-A3 payto-Tags: Typ (z. B. lightning) und Authority (z. B. user@domain.com).', 'NIP-A3 payto-Tags: Typ (z. B. lightning) und Authority (z. B. user@domain.com).',
'Type (e.g. lightning)': 'Type (e.g. lightning)', 'Type (e.g. lightning)': 'Type (e.g. lightning)',
'Authority (e.g. user@domain.com)': 'Authority (e.g. user@domain.com)', 'Authority (e.g. user@domain.com)': 'Authority (e.g. user@domain.com)',
'Add payment method': 'Add payment method', 'Add payment method': 'Zahlungsziel hinzufügen',
'Add payment target': 'Zahlungsziel hinzufügen',
Remove: 'Remove', Remove: 'Remove',
'Additional content (JSON)': 'Additional content (JSON)', 'Additional content (JSON)': 'Additional content (JSON)',
'Show full event JSON': 'Show full event JSON', 'Show full event JSON': 'Show full event JSON',
@ -679,7 +685,7 @@ export default {
'Quick zap': 'Schneller Zap', 'Quick zap': 'Schneller Zap',
'Preferred payto category': 'Bevorzugte Payto-Kategorie', 'Preferred payto category': 'Bevorzugte Payto-Kategorie',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.': 'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Diese Kategorie in Zahlungsmethodenlisten ausgeklappt anzeigen; andere Kategorien hinter einem Akkordeon einklappen.', 'Diese Kategorie in Tipp-Dialogen ausgeklappt anzeigen; andere Kategorien hinter einem Akkordeon einklappen.',
'Show all categories': 'Alle Kategorien anzeigen', 'Show all categories': 'Alle Kategorien anzeigen',
'Other payment categories ({{count}})': 'Weitere Zahlungskategorien ({{count}})', 'Other payment categories ({{count}})': 'Weitere Zahlungskategorien ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin', 'paytoCategory.bitcoin': 'Bitcoin',
@ -694,6 +700,10 @@ export default {
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',
'When off, your zap may still succeed but a public receipt may not be published to relays': 'When off, your zap may still succeed but a public receipt may not be published to relays':
'When off, your zap may still succeed but a public receipt may not be published to relays', 'When off, your zap may still succeed but a public receipt may not be published to relays',
'Zap paid but no public receipt was published. The recipient may not use a NIP-57 zap wallet.':
'Zap bezahlt, aber keine öffentliche Quittung veröffentlicht. Der Empfänger nutzt möglicherweise keine NIP-57-Zap-Wallet.',
'Plain Lightning payments do not create NIP-57 zap receipts on Nostr. Use the tip button when available.':
'Einfache Lightning-Zahlungen erstellen keine NIP-57-Zap-Quittungen auf Nostr. Nutze den Tipp-Button, wenn verfügbar.',
All: 'Alle', All: 'Alle',
Reactions: 'Reaktionen', Reactions: 'Reaktionen',
Zaps: 'Zaps', Zaps: 'Zaps',
@ -2709,7 +2719,7 @@ export default {
'Add to list': 'Add to list', 'Add to list': 'Add to list',
'Block relay': 'Block relay', 'Block relay': 'Block relay',
'Choose app': 'Choose app', 'Choose app': 'Choose app',
'Payment Methods': 'Payment Methods', 'Payment Methods': 'Zahlungsziele',
'Unfollowed thread notifications': 'Unfollowed thread notifications', 'Unfollowed thread notifications': 'Unfollowed thread notifications',
'Unmute thread notifications': 'Unmute thread notifications', 'Unmute thread notifications': 'Unmute thread notifications',
'Unmuted thread notifications': 'Unmuted thread notifications', 'Unmuted thread notifications': 'Unmuted thread notifications',

20
src/i18n/locales/en.ts

@ -209,9 +209,14 @@ export default {
'Failed to publish payment info': 'Failed to publish payment info', 'Failed to publish payment info': 'Failed to publish payment info',
'Invalid tags JSON': 'Invalid tags JSON', 'Invalid tags JSON': 'Invalid tags JSON',
'Payment methods': 'Payment methods', 'Payment methods': 'Payment methods',
'Send a payment to this user': 'Send a payment to this user', 'Leave a tip': 'Leave a tip',
Tips: 'Tips',
'Payment targets': 'Payment targets',
'Tip options': 'Tip options',
'Send a payment to this user': 'Leave a tip',
'No payment methods available for this profile': 'No payment methods available for this profile':
'No payment methods available for this profile', 'No payment targets on this profile',
'No payment targets on this profile': 'No payment targets on this profile',
'Other payment methods': 'Other payment methods', 'Other payment methods': 'Other payment methods',
'Lightning address for zap': 'Lightning address for zap', 'Lightning address for zap': 'Lightning address for zap',
'Select lightning address': 'Select lightning address', 'Select lightning address': 'Select lightning address',
@ -285,7 +290,8 @@ export default {
'NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).', 'NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).',
'Type (e.g. lightning)': 'Type (e.g. lightning)', 'Type (e.g. lightning)': 'Type (e.g. lightning)',
'Authority (e.g. user@domain.com)': 'Authority (e.g. user@domain.com)', 'Authority (e.g. user@domain.com)': 'Authority (e.g. user@domain.com)',
'Add payment method': 'Add payment method', 'Add payment method': 'Add payment target',
'Add payment target': 'Add payment target',
Remove: 'Remove', Remove: 'Remove',
'Additional content (JSON)': 'Additional content (JSON)', 'Additional content (JSON)': 'Additional content (JSON)',
'Show full event JSON': 'Show full event JSON', 'Show full event JSON': 'Show full event JSON',
@ -675,7 +681,7 @@ export default {
'Quick zap': 'Quick zap', 'Quick zap': 'Quick zap',
'Preferred payto category': 'Preferred payto category', 'Preferred payto category': 'Preferred payto category',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.': 'Show this category expanded on payment method lists; other categories collapse behind an accordion.':
'Show this category expanded on payment method lists; other categories collapse behind an accordion.', 'Show this category expanded on tip dialogs; other categories collapse behind an accordion.',
'Show all categories': 'Show all categories', 'Show all categories': 'Show all categories',
'Other payment categories ({{count}})': 'Other payment categories ({{count}})', 'Other payment categories ({{count}})': 'Other payment categories ({{count}})',
'paytoCategory.bitcoin': 'Bitcoin', 'paytoCategory.bitcoin': 'Bitcoin',
@ -690,6 +696,10 @@ export default {
'Include public zap receipt': 'Include public zap receipt', 'Include public zap receipt': 'Include public zap receipt',
'When off, your zap may still succeed but a public receipt may not be published to relays': 'When off, your zap may still succeed but a public receipt may not be published to relays':
'When off, your zap may still succeed but a public receipt may not be published to relays', 'When off, your zap may still succeed but a public receipt may not be published to relays',
'Zap paid but no public receipt was published. The recipient may not use a NIP-57 zap wallet.':
'Zap paid but no public receipt was published. The recipient may not use a NIP-57 zap wallet.',
'Plain Lightning payments do not create NIP-57 zap receipts on Nostr. Use the tip button when available.':
'Plain Lightning payments do not create NIP-57 zap receipts on Nostr. Use the tip button when available.',
All: 'All', All: 'All',
Reactions: 'Reactions', Reactions: 'Reactions',
Zaps: 'Zaps', Zaps: 'Zaps',
@ -2678,7 +2688,7 @@ export default {
'Add to list': 'Add to list', 'Add to list': 'Add to list',
'Block relay': 'Block relay', 'Block relay': 'Block relay',
'Choose app': 'Choose app', 'Choose app': 'Choose app',
'Payment Methods': 'Payment Methods', 'Payment Methods': 'Payment targets',
'Unfollowed thread notifications': 'Unfollowed thread notifications', 'Unfollowed thread notifications': 'Unfollowed thread notifications',
'Unmute thread notifications': 'Unmute thread notifications', 'Unmute thread notifications': 'Unmute thread notifications',
'Unmuted thread notifications': 'Unmuted thread notifications', 'Unmuted thread notifications': 'Unmuted thread notifications',

4
src/pages/secondary/ProfileEditorPage/index.tsx

@ -789,7 +789,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-auto space-y-4 pb-6"> <div className="flex-1 overflow-auto space-y-4 pb-6">
<Item> <Item>
<Label className="text-muted-foreground">{t('Payment methods')}</Label> <Label className="text-muted-foreground">{t('Payment targets')}</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('paytoEditor.intro', { {t('paytoEditor.intro', {
defaultValue: defaultValue:
@ -833,7 +833,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
} }
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
{t('Add payment method')} {t('Add payment target')}
</Button> </Button>
</div> </div>
</Item> </Item>

118
src/services/lightning.service.ts

@ -31,7 +31,12 @@ import { TProfile } from '@/types'
export type TRecentSupporter = { pubkey: string; amount: number; comment?: string } export type TRecentSupporter = { pubkey: string; amount: number; comment?: string }
export type PaymentFlowResult = { preimage: string; invoice: string } | null export type PaymentFlowResult = {
preimage: string
invoice: string
/** Set when we waited for a kind 9735 receipt on relays (null = none seen in time). */
zapReceipt?: NostrEvent | null
} | null
/** LNURL-pay limits from the recipient’s `.well-known/lnurlp` metadata. */ /** LNURL-pay limits from the recipient’s `.well-known/lnurlp` metadata. */
export type LnurlPayInvoiceOptions = { export type LnurlPayInvoiceOptions = {
@ -49,8 +54,9 @@ class LightningService {
private recentSupportersCache: TRecentSupporter[] | null = null private recentSupportersCache: TRecentSupporter[] | null = null
private lnurlPayMetadataCache = new Map< private lnurlPayMetadataCache = new Map<
string, string,
{ fetchedAt: number; meta: NonNullable<Awaited<ReturnType<LightningService['resolveLnurlPayMetadata']>>> } { fetchedAt: number; meta: Awaited<ReturnType<LightningService['resolveLnurlPayMetadata']>> }
>() >()
private nip57EnabledAddressCache = new Map<string, { fetchedAt: number; addrs: string[] }>()
constructor() { constructor() {
if (!LightningService.instance) { if (!LightningService.instance) {
@ -141,7 +147,16 @@ class LightningService {
try { try {
const { preimage } = await sendWebLNPaymentWithRetry(this.provider, pr) const { preimage } = await sendWebLNPaymentWithRetry(this.provider, pr)
closeOuterModel?.() closeOuterModel?.()
const result = { preimage, invoice: pr } const zapReceipt =
relays.length > 0
? await this.waitForZapReceipt({
recipient,
event,
invoice: pr,
relayUrls: relays
})
: undefined
const result: PaymentFlowResult = { preimage, invoice: pr, zapReceipt }
onPaymentFlowComplete?.(result) onPaymentFlowComplete?.(result)
return result return result
} catch (error) { } catch (error) {
@ -156,19 +171,27 @@ class LightningService {
closeModal() closeModal()
let checkPaymentInterval: ReturnType<typeof setInterval> | undefined let checkPaymentInterval: ReturnType<typeof setInterval> | undefined
let subCloser: SubCloser | undefined let subCloser: SubCloser | undefined
const finish = (result: PaymentFlowResult) => { const finish = async (result: PaymentFlowResult) => {
clearInterval(checkPaymentInterval) clearInterval(checkPaymentInterval)
subCloser?.close() subCloser?.close()
if (result && relays.length > 0 && result.zapReceipt === undefined) {
result.zapReceipt = await this.waitForZapReceipt({
recipient,
event,
invoice: result.invoice,
relayUrls: relays
})
}
onPaymentFlowComplete?.(result) onPaymentFlowComplete?.(result)
resolve(result) resolve(result)
} }
const { setPaid } = launchPaymentModal({ const { setPaid } = launchPaymentModal({
invoice: pr, invoice: pr,
onPaid: (response) => { onPaid: (response) => {
finish({ preimage: response.preimage, invoice: pr }) void finish({ preimage: response.preimage, invoice: pr })
}, },
onCancelled: () => { onCancelled: () => {
finish(null) void finish(null)
} }
}) })
@ -211,6 +234,67 @@ class LightningService {
}) })
} }
/** Lightning addresses whose LNURL-pay endpoint supports NIP-57 (`allowsNostr` + `nostrPubkey`). */
async filterNip57ZapEnabledAddresses(candidates: string[]): Promise<string[]> {
const key = candidates
.map((c) => c.trim().toLowerCase())
.filter(Boolean)
.join('\u0001')
if (!key) return []
const cached = this.nip57EnabledAddressCache.get(key)
if (cached && Date.now() - cached.fetchedAt < 60_000) {
return cached.addrs
}
const enabled: string[] = []
for (const addr of candidates) {
const endpoint = await this.fetchLnurlPayZapEndpoint(addr)
if (endpoint) enabled.push(addr)
}
const addrs = prioritizeZapLightningAddress(enabled, candidates[0])
this.nip57EnabledAddressCache.set(key, { fetchedAt: Date.now(), addrs })
return addrs
}
private waitForZapReceipt(params: {
recipient: string
event?: NostrEvent
invoice: string
relayUrls: string[]
timeoutMs?: number
}): Promise<NostrEvent | null> {
const relayUrls = [...new Set([...params.relayUrls, ...FAST_READ_RELAY_URLS])].slice(0, 8)
if (relayUrls.length === 0) return Promise.resolve(null)
return new Promise((resolve) => {
let subCloser: SubCloser | undefined
const timeout = setTimeout(() => {
subCloser?.close()
resolve(null)
}, params.timeoutMs ?? 20_000)
const filter: Filter = {
kinds: [kinds.Zap],
'#p': [params.recipient],
since: dayjs().subtract(2, 'minute').unix()
}
if (params.event) {
filter['#e'] = [params.event.id]
}
subCloser = client.subscribe(relayUrls, filter, {
onevent: (evt) => {
const info = getZapInfoFromEvent(evt)
if (!info || info.invoice !== params.invoice) return
clearTimeout(timeout)
subCloser?.close()
resolve(evt)
}
})
})
}
async payInvoice( async payInvoice(
invoice: string, invoice: string,
closeOuterModel?: () => void, closeOuterModel?: () => void,
@ -407,16 +491,23 @@ class LightningService {
}> { }> {
const cacheKey = lightningAddress.trim().toLowerCase() const cacheKey = lightningAddress.trim().toLowerCase()
const cached = this.lnurlPayMetadataCache.get(cacheKey) const cached = this.lnurlPayMetadataCache.get(cacheKey)
if (cached && Date.now() - cached.fetchedAt < 30_000) { if (cached && Date.now() - cached.fetchedAt < 60_000) {
return cached.meta return cached.meta
} }
const remember = (
meta: Awaited<ReturnType<LightningService['resolveLnurlPayMetadata']>>
) => {
this.lnurlPayMetadataCache.set(cacheKey, { fetchedAt: Date.now(), meta })
return meta
}
try { try {
let lnurl = '' let lnurl = ''
if (lightningAddress.includes('@')) { if (lightningAddress.includes('@')) {
const [name, domain] = lightningAddress.split('@') const [name, domain] = lightningAddress.split('@')
if (!name?.trim() || !domain?.trim()) return null if (!name?.trim() || !domain?.trim()) return remember(null)
lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString() lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString()
} else { } else {
const { words } = bech32.decode(lightningAddress as any, 1000) const { words } = bech32.decode(lightningAddress as any, 1000)
@ -432,7 +523,7 @@ class LightningService {
lnurl, lnurl,
lightningAddress lightningAddress
}) })
return null return remember(null)
} }
const text = await res.text() const text = await res.text()
@ -452,10 +543,10 @@ class LightningService {
lightningAddress, lightningAddress,
preview: text.slice(0, 160) preview: text.slice(0, 160)
}) })
return null return remember(null)
} }
if (typeof body.callback !== 'string' || !body.callback) return null if (typeof body.callback !== 'string' || !body.callback) return remember(null)
const commentAllowed = parseLnurlCommentAllowed(body.commentAllowed) const commentAllowed = parseLnurlCommentAllowed(body.commentAllowed)
@ -468,8 +559,7 @@ class LightningService {
minSendable: typeof body.minSendable === 'number' ? body.minSendable : undefined, minSendable: typeof body.minSendable === 'number' ? body.minSendable : undefined,
maxSendable: typeof body.maxSendable === 'number' ? body.maxSendable : undefined maxSendable: typeof body.maxSendable === 'number' ? body.maxSendable : undefined
} }
this.lnurlPayMetadataCache.set(cacheKey, { fetchedAt: Date.now(), meta }) return remember(meta)
return meta
} catch (err) { } catch (err) {
const failedFetch = const failedFetch =
err instanceof TypeError || (err instanceof Error && err.message === 'Failed to fetch') err instanceof TypeError || (err instanceof Error && err.message === 'Failed to fetch')
@ -481,7 +571,7 @@ class LightningService {
}) })
} }
return null return remember(null)
} }
} }

Loading…
Cancel
Save