From f25b7428770f50e0b7b9889c4d9c2059503f845f Mon Sep 17 00:00:00 2001 From: Daniel Vergara Date: Thu, 19 Jun 2025 08:12:06 -0600 Subject: [PATCH] feat: render embedded invoices (#392) --- src/components/Content/index.tsx | 6 ++ src/components/Embedded/EmbeddedLNInvoice.tsx | 84 +++++++++++++++++++ src/components/Embedded/index.tsx | 1 + src/components/PictureContent/index.tsx | 6 ++ src/constants.ts | 1 + src/lib/content-parser.ts | 7 ++ src/services/lightning.service.ts | 21 +++++ 7 files changed, 126 insertions(+) create mode 100644 src/components/Embedded/EmbeddedLNInvoice.tsx diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 33859bd8..cc023555 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -3,6 +3,7 @@ import { EmbeddedEventParser, EmbeddedHashtagParser, EmbeddedImageParser, + EmbeddedLNInvoiceParser, EmbeddedMentionParser, EmbeddedNormalUrlParser, EmbeddedVideoParser, @@ -19,6 +20,7 @@ import { memo } from 'react' import { EmbeddedHashtag, EmbeddedMention, + EmbeddedLNInvoice, EmbeddedNormalUrl, EmbeddedNote, EmbeddedWebsocketUrl @@ -33,6 +35,7 @@ const Content = memo(({ event, className }: { event: Event; className?: string } EmbeddedImageParser, EmbeddedVideoParser, EmbeddedNormalUrlParser, + EmbeddedLNInvoiceParser, EmbeddedWebsocketUrlParser, EmbeddedEventParser, EmbeddedMentionParser, @@ -101,6 +104,9 @@ const Content = memo(({ event, className }: { event: Event; className?: string } if (node.type === 'url') { return } + if (node.type === 'invoice') { + return + } if (node.type === 'websocket-url') { return } diff --git a/src/components/Embedded/EmbeddedLNInvoice.tsx b/src/components/Embedded/EmbeddedLNInvoice.tsx new file mode 100644 index 00000000..e68297a9 --- /dev/null +++ b/src/components/Embedded/EmbeddedLNInvoice.tsx @@ -0,0 +1,84 @@ +import { formatAmount, getAmountFromInvoice } from '@/lib/lightning' +import { cn } from '@/lib/utils' +import { useNostr } from '@/providers/NostrProvider' +import { useToast } from '@/hooks' +import { Loader, Zap } from 'lucide-react' +import lightning from '@/services/lightning.service' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' + +export function EmbeddedLNInvoice({ invoice }: { invoice: string }) { + const { t } = useTranslation() + const { toast } = useToast() + const { checkLogin, pubkey } = useNostr() + const [paying, setPaying] = useState(false) + + const amount = useMemo(() => { + return getAmountFromInvoice(invoice) + }, [invoice]) + + const handlePay = async () => { + try { + if (!pubkey) { + throw new Error('You need to be logged in to zap') + } + setPaying(true) + const invoiceResult = await lightning.payInvoice(invoice) + // user canceled + if (!invoiceResult) { + return + } + } catch (error) { + toast({ + title: t('Lightning payment failed'), + description: (error as Error).message, + variant: 'destructive' + }) + } finally { + setPaying(false) + } + } + + const handlePayClick = (e: React.MouseEvent) => { + e.stopPropagation() + checkLogin(() => handlePay()) + } + + return ( +
e.stopPropagation()} + > +
+ +

Lightning Invoice

+
+
+ {formatAmount(amount)} +
+ +
+ ) +} diff --git a/src/components/Embedded/index.tsx b/src/components/Embedded/index.tsx index 7c634ddf..f3f8a43d 100644 --- a/src/components/Embedded/index.tsx +++ b/src/components/Embedded/index.tsx @@ -1,4 +1,5 @@ export * from './EmbeddedHashtag' +export * from './EmbeddedLNInvoice' export * from './EmbeddedMention' export * from './EmbeddedNormalUrl' export * from './EmbeddedNote' diff --git a/src/components/PictureContent/index.tsx b/src/components/PictureContent/index.tsx index 82ccfa20..276ed3b8 100644 --- a/src/components/PictureContent/index.tsx +++ b/src/components/PictureContent/index.tsx @@ -1,5 +1,6 @@ import { EmbeddedEmojiParser, + EmbeddedLNInvoiceParser, EmbeddedHashtagParser, EmbeddedMentionParser, EmbeddedNormalUrlParser, @@ -12,6 +13,7 @@ import { Event } from 'nostr-tools' import { memo, useMemo } from 'react' import { EmbeddedHashtag, + EmbeddedLNInvoice, EmbeddedMention, EmbeddedNormalUrl, EmbeddedWebsocketUrl @@ -25,6 +27,7 @@ const PictureContent = memo(({ event, className }: { event: Event; className?: s const nodes = parseContent(event.content, [ EmbeddedNormalUrlParser, + EmbeddedLNInvoiceParser, EmbeddedWebsocketUrlParser, EmbeddedHashtagParser, EmbeddedMentionParser, @@ -44,6 +47,9 @@ const PictureContent = memo(({ event, className }: { event: Event; className?: s if (node.type === 'url') { return } + if (node.type === 'invoice') { + return + } if (node.type === 'websocket-url') { return } diff --git a/src/constants.ts b/src/constants.ts index 4f292a82..8857dcf8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -61,6 +61,7 @@ export const EMOJI_SHORT_CODE_REGEX = /:[a-zA-Z0-9_-]+:/g export const EMBEDDED_EVENT_REGEX = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g export const EMBEDDED_MENTION_REGEX = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g export const HASHTAG_REGEX = /#[\p{L}\p{N}\p{M}_]+/gu +export const LN_INVOICE_REGEX = /(ln(?:bc|tb|bcrt))([0-9]+[munp]?)?1([02-9ac-hj-np-z]+)/g export const MONITOR = '9bbbb845e5b6c831c29789900769843ab43bb5047abe697870cb50b6fc9bf923' export const MONITOR_RELAYS = ['wss://relay.nostr.watch/'] diff --git a/src/lib/content-parser.ts b/src/lib/content-parser.ts index 8e2e9095..24a4977b 100644 --- a/src/lib/content-parser.ts +++ b/src/lib/content-parser.ts @@ -4,6 +4,7 @@ import { EMOJI_SHORT_CODE_REGEX, HASHTAG_REGEX, IMAGE_REGEX, + LN_INVOICE_REGEX, URL_REGEX, VIDEO_REGEX, WS_URL_REGEX @@ -21,6 +22,7 @@ export type TEmbeddedNodeType = | 'websocket-url' | 'url' | 'emoji' + | 'invoice' export type TEmbeddedNode = | { @@ -79,6 +81,11 @@ export const EmbeddedEmojiParser: TContentParser = { regex: EMOJI_SHORT_CODE_REGEX } +export const EmbeddedLNInvoiceParser: TContentParser = { + type: 'invoice', + regex: LN_INVOICE_REGEX +} + export function parseContent(content: string, parsers: TContentParser[]) { let nodes: TEmbeddedNode[] = [{ type: 'text', data: content.trim() }] diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index 3842e4b1..4b6265f2 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -154,6 +154,27 @@ class LightningService { }) } + async payInvoice(invoice: string, closeOuterModel?: () => void): Promise<{ preimage: string; invoice: string } | null> { + if (this.provider) { + const { preimage } = await this.provider.sendPayment(invoice) + closeOuterModel?.() + return { preimage, invoice: invoice } + } + + return new Promise((resolve) => { + closeOuterModel?.() + launchPaymentModal({ + invoice: invoice, + onPaid: (response) => { + resolve({ preimage: response.preimage, invoice: invoice }) + }, + onCancelled: () => { + resolve(null) + } + }) + }) + } + async fetchRecentSupporters() { if (this.recentSupportersCache) { return this.recentSupportersCache