feat: render embedded invoices (#392)
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
|||||||
EmbeddedEventParser,
|
EmbeddedEventParser,
|
||||||
EmbeddedHashtagParser,
|
EmbeddedHashtagParser,
|
||||||
EmbeddedImageParser,
|
EmbeddedImageParser,
|
||||||
|
EmbeddedLNInvoiceParser,
|
||||||
EmbeddedMentionParser,
|
EmbeddedMentionParser,
|
||||||
EmbeddedNormalUrlParser,
|
EmbeddedNormalUrlParser,
|
||||||
EmbeddedVideoParser,
|
EmbeddedVideoParser,
|
||||||
@@ -19,6 +20,7 @@ import { memo } from 'react'
|
|||||||
import {
|
import {
|
||||||
EmbeddedHashtag,
|
EmbeddedHashtag,
|
||||||
EmbeddedMention,
|
EmbeddedMention,
|
||||||
|
EmbeddedLNInvoice,
|
||||||
EmbeddedNormalUrl,
|
EmbeddedNormalUrl,
|
||||||
EmbeddedNote,
|
EmbeddedNote,
|
||||||
EmbeddedWebsocketUrl
|
EmbeddedWebsocketUrl
|
||||||
@@ -33,6 +35,7 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
|||||||
EmbeddedImageParser,
|
EmbeddedImageParser,
|
||||||
EmbeddedVideoParser,
|
EmbeddedVideoParser,
|
||||||
EmbeddedNormalUrlParser,
|
EmbeddedNormalUrlParser,
|
||||||
|
EmbeddedLNInvoiceParser,
|
||||||
EmbeddedWebsocketUrlParser,
|
EmbeddedWebsocketUrlParser,
|
||||||
EmbeddedEventParser,
|
EmbeddedEventParser,
|
||||||
EmbeddedMentionParser,
|
EmbeddedMentionParser,
|
||||||
@@ -101,6 +104,9 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
|||||||
if (node.type === 'url') {
|
if (node.type === 'url') {
|
||||||
return <EmbeddedNormalUrl url={node.data} key={index} />
|
return <EmbeddedNormalUrl url={node.data} key={index} />
|
||||||
}
|
}
|
||||||
|
if (node.type === 'invoice') {
|
||||||
|
return <EmbeddedLNInvoice invoice={node.data} key={index} />
|
||||||
|
}
|
||||||
if (node.type === 'websocket-url') {
|
if (node.type === 'websocket-url') {
|
||||||
return <EmbeddedWebsocketUrl url={node.data} key={index} />
|
return <EmbeddedWebsocketUrl url={node.data} key={index} />
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/components/Embedded/EmbeddedLNInvoice.tsx
Normal file
84
src/components/Embedded/EmbeddedLNInvoice.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border rounded-lg p-4 bg-card text-card-foreground shadow-sm',
|
||||||
|
'flex flex-col gap-3 my-2 max-w-sm'
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-yellow-500" />
|
||||||
|
<h3 className="font-semibold text-sm">Lightning Invoice</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold">
|
||||||
|
{formatAmount(amount)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'w-full px-4 py-2 rounded-md font-medium text-sm',
|
||||||
|
'bg-purple-600 hover:bg-purple-700 text-white',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
'flex items-center justify-center gap-2'
|
||||||
|
)}
|
||||||
|
onClick={handlePayClick}
|
||||||
|
>
|
||||||
|
{paying ? (
|
||||||
|
<>
|
||||||
|
<Loader className="w-4 h-4 animate-spin" />
|
||||||
|
Paying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Pay'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './EmbeddedHashtag'
|
export * from './EmbeddedHashtag'
|
||||||
|
export * from './EmbeddedLNInvoice'
|
||||||
export * from './EmbeddedMention'
|
export * from './EmbeddedMention'
|
||||||
export * from './EmbeddedNormalUrl'
|
export * from './EmbeddedNormalUrl'
|
||||||
export * from './EmbeddedNote'
|
export * from './EmbeddedNote'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
EmbeddedEmojiParser,
|
EmbeddedEmojiParser,
|
||||||
|
EmbeddedLNInvoiceParser,
|
||||||
EmbeddedHashtagParser,
|
EmbeddedHashtagParser,
|
||||||
EmbeddedMentionParser,
|
EmbeddedMentionParser,
|
||||||
EmbeddedNormalUrlParser,
|
EmbeddedNormalUrlParser,
|
||||||
@@ -12,6 +13,7 @@ import { Event } from 'nostr-tools'
|
|||||||
import { memo, useMemo } from 'react'
|
import { memo, useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
EmbeddedHashtag,
|
EmbeddedHashtag,
|
||||||
|
EmbeddedLNInvoice,
|
||||||
EmbeddedMention,
|
EmbeddedMention,
|
||||||
EmbeddedNormalUrl,
|
EmbeddedNormalUrl,
|
||||||
EmbeddedWebsocketUrl
|
EmbeddedWebsocketUrl
|
||||||
@@ -25,6 +27,7 @@ const PictureContent = memo(({ event, className }: { event: Event; className?: s
|
|||||||
|
|
||||||
const nodes = parseContent(event.content, [
|
const nodes = parseContent(event.content, [
|
||||||
EmbeddedNormalUrlParser,
|
EmbeddedNormalUrlParser,
|
||||||
|
EmbeddedLNInvoiceParser,
|
||||||
EmbeddedWebsocketUrlParser,
|
EmbeddedWebsocketUrlParser,
|
||||||
EmbeddedHashtagParser,
|
EmbeddedHashtagParser,
|
||||||
EmbeddedMentionParser,
|
EmbeddedMentionParser,
|
||||||
@@ -44,6 +47,9 @@ const PictureContent = memo(({ event, className }: { event: Event; className?: s
|
|||||||
if (node.type === 'url') {
|
if (node.type === 'url') {
|
||||||
return <EmbeddedNormalUrl key={index} url={node.data} />
|
return <EmbeddedNormalUrl key={index} url={node.data} />
|
||||||
}
|
}
|
||||||
|
if (node.type === 'invoice') {
|
||||||
|
return <EmbeddedLNInvoice invoice={node.data} key={index} />
|
||||||
|
}
|
||||||
if (node.type === 'websocket-url') {
|
if (node.type === 'websocket-url') {
|
||||||
return <EmbeddedWebsocketUrl key={index} url={node.data} />
|
return <EmbeddedWebsocketUrl key={index} url={node.data} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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 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 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 = '9bbbb845e5b6c831c29789900769843ab43bb5047abe697870cb50b6fc9bf923'
|
||||||
export const MONITOR_RELAYS = ['wss://relay.nostr.watch/']
|
export const MONITOR_RELAYS = ['wss://relay.nostr.watch/']
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
EMOJI_SHORT_CODE_REGEX,
|
EMOJI_SHORT_CODE_REGEX,
|
||||||
HASHTAG_REGEX,
|
HASHTAG_REGEX,
|
||||||
IMAGE_REGEX,
|
IMAGE_REGEX,
|
||||||
|
LN_INVOICE_REGEX,
|
||||||
URL_REGEX,
|
URL_REGEX,
|
||||||
VIDEO_REGEX,
|
VIDEO_REGEX,
|
||||||
WS_URL_REGEX
|
WS_URL_REGEX
|
||||||
@@ -21,6 +22,7 @@ export type TEmbeddedNodeType =
|
|||||||
| 'websocket-url'
|
| 'websocket-url'
|
||||||
| 'url'
|
| 'url'
|
||||||
| 'emoji'
|
| 'emoji'
|
||||||
|
| 'invoice'
|
||||||
|
|
||||||
export type TEmbeddedNode =
|
export type TEmbeddedNode =
|
||||||
| {
|
| {
|
||||||
@@ -79,6 +81,11 @@ export const EmbeddedEmojiParser: TContentParser = {
|
|||||||
regex: EMOJI_SHORT_CODE_REGEX
|
regex: EMOJI_SHORT_CODE_REGEX
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const EmbeddedLNInvoiceParser: TContentParser = {
|
||||||
|
type: 'invoice',
|
||||||
|
regex: LN_INVOICE_REGEX
|
||||||
|
}
|
||||||
|
|
||||||
export function parseContent(content: string, parsers: TContentParser[]) {
|
export function parseContent(content: string, parsers: TContentParser[]) {
|
||||||
let nodes: TEmbeddedNode[] = [{ type: 'text', data: content.trim() }]
|
let nodes: TEmbeddedNode[] = [{ type: 'text', data: content.trim() }]
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
async fetchRecentSupporters() {
|
||||||
if (this.recentSupportersCache) {
|
if (this.recentSupportersCache) {
|
||||||
return this.recentSupportersCache
|
return this.recentSupportersCache
|
||||||
|
|||||||
Reference in New Issue
Block a user