import { createSignal, createEffect, batch, onMount, Show, Switch, Match, type Component, type JSX, } from 'solid-js'; import { createMutation } from '@tanstack/solid-query'; import Bolt from 'heroicons/24/outline/bolt.svg'; import Check from 'heroicons/24/solid/check.svg'; import * as Kind from 'nostr-tools/kinds'; import { type Event as NostrEvent } from 'nostr-tools/pure'; import qrcode from 'qrcode'; import { requestProvider, type WebLNProvider } from 'webln'; // eslint-disable-next-line import/no-cycle import EventDisplay from '@/components/event/EventDisplay'; import BasicModal from '@/components/modal/BasicModal'; import useConfig from '@/core/useConfig'; import { useTranslation } from '@/i18n/useTranslation'; import createZapRequest from '@/nostr/builder/createZapRequest'; import { genericEvent } from '@/nostr/event'; import ZapReceipt from '@/nostr/event/ZapReceipt'; import { signEvent } from '@/nostr/useCommands'; import useLnurlEndpoint from '@/nostr/useLnurlEndpoint'; import useProfile from '@/nostr/useProfile'; import usePubkey from '@/nostr/usePubkey'; import useSubscription from '@/nostr/useSubscription'; import fetchLnurlCallback, { type FetchLnurlCallbackParams } from '@/nostr/zap/fetchLnurlCallback'; import lud06ToLnurlPayUrl from '@/nostr/zap/lud06ToLnurlPayUrl'; import lud16ToLnurlPayUrl from '@/nostr/zap/lud16ToLnurlPayUrl'; import verifyInvoice from '@/nostr/zap/verifyInvoice'; import ensureNonNull from '@/utils/ensureNonNull'; import epoch from '@/utils/epoch'; export type ZapRequestModalProps = { event: NostrEvent; // TODO zap to profile onClose: () => void; }; type ZapDialogProps = { lnurlPayUrl?: string | null; event: NostrEvent; }; const useWebLN = () => { const [provider, setProvider] = createSignal(); const [status, setStatus] = createSignal<'available' | 'unavailable' | 'checking'>('checking'); onMount(() => { requestProvider() .then((webln) => { batch(() => { setProvider(webln); setStatus('available'); }); }) .catch((err) => { console.warn('failed to request provider', err); setStatus('unavailable'); }); }); return { provider, status }; }; const QRCodeDisplay: Component<{ text: string }> = (props) => { let canvasRef: HTMLCanvasElement | undefined; const { getColorTheme } = useConfig(); createEffect(() => { if (canvasRef == null) return; const currentTheme = getColorTheme(); qrcode .toCanvas(canvasRef, props.text, { margin: 8, color: { dark: currentTheme.brightness === 'dark' ? '#ffffffff' : '#000000ff', light: '#00000000', }, }) .catch((e) => { console.error(e); }); }); return ; }; const InvoiceDisplay: Component<{ invoice: string; event: NostrEvent; nostrPubkey?: string }> = ( props, ) => { const i18n = useTranslation(); const { config } = useConfig(); const webln = useWebLN(); const lightingInvoice = () => `lightning:${props.invoice}`; const { events } = useSubscription(() => ensureNonNull([props.nostrPubkey] as const)(([nostrPubkey]) => ({ relayUrls: config().relayUrls, filters: [ { kinds: [Kind.Zap], authors: nostrPubkey != null ? [nostrPubkey] : undefined, '#p': [props.event.pubkey], '#e': [props.event.id], since: epoch(), }, ], continuous: true, })), ); const zapped = () => events().find((ev) => new ZapReceipt(ev).bolt11().paymentRequest === props.invoice); const handleClickWebLN = () => { const provider = webln.provider(); if (provider == null) return; provider .sendPayment(props.invoice) .then(() => { window.alert('success'); }) .catch((err) => { const message = err instanceof Error ? `:${err.message}` : ''; window.alert(`failed to send zap: ${message}`); }); }; return (
{i18n()('zap.completed')}
} >
{i18n()('zap.sendViaWallet')}
); }; const ZapDialog: Component = (props) => { const i18n = useTranslation(); const pubkey = usePubkey(); const { config } = useConfig(); const [amountSats, setAmountSats] = createSignal(1); const [comment, setComment] = createSignal(''); const { endpoint, error, allowsNostr, commentAllowed, query } = useLnurlEndpoint(() => ensureNonNull([props.lnurlPayUrl])(([url]) => ({ lnurlPayUrl: url, })), ); const event = () => genericEvent(props.event); const hasZapTag = () => event().findTagsByName('zap').length > 0; const lnurlPayUrlDomain = () => { if (props.lnurlPayUrl == null) return null; const url = new URL(props.lnurlPayUrl); return url.host; }; const lnurlServiceIcon = () => endpoint()?.decodedMetadata?.imageJPEG || endpoint()?.decodedMetadata?.imagePNG; const minSendableInSats = () => Math.ceil((endpoint()?.minSendable ?? 1) / 1000); const maxSendableInSats = () => Math.floor((endpoint()?.maxSendable ?? 1) / 1000); const getInvoice = async (): Promise => { const p = pubkey(); if (p == null) return undefined; const endpointData = endpoint(); if (endpointData == null) return undefined; if (props.lnurlPayUrl == null) return undefined; if (amountSats() < minSendableInSats() || amountSats() > maxSendableInSats()) return undefined; const amountMilliSats = Math.floor(amountSats() * 1000).toString(); const { callback } = endpointData; const callbackParams: FetchLnurlCallbackParams = { lnurlPayUrl: props.lnurlPayUrl, callback, amountMilliSats, }; if (commentAllowed() > 0 && comment().length > 0 && comment().length <= commentAllowed()) { callbackParams.comment = comment(); } if (allowsNostr()) { const unsignedEvent = createZapRequest({ amountMilliSats, content: comment(), pubkey: p, recipientPubkey: props.event.pubkey, eventId: props.event.id, relays: config().relayUrls, lnurlPayUrl: props.lnurlPayUrl, }); const zapRequest = await signEvent(unsignedEvent); callbackParams.zapRequest = zapRequest; } const callbackResponse = await fetchLnurlCallback(callbackParams); if (!('pr' in callbackResponse)) { throw new Error('failed to get invoice'); } const invoice = callbackResponse.pr; console.log(callbackResponse, invoice); if ( !(await verifyInvoice(invoice, { amountMilliSats, metadata: endpointData.metadata, zapRequest: callbackParams.zapRequest, })) ) { throw new Error('Invalid invoice'); } return invoice; }; const getInvoiceMutation = createMutation(() => ({ mutationKey: ['getInvoiceMutation', props.event.id], mutationFn: () => getInvoice(), })); const handleSubmit: JSX.EventHandler = (ev) => { ev.preventDefault(); getInvoiceMutation.mutate(); }; return ( {i18n()('zap.fetchingLnUrlEndpointError')}: {query?.error?.message} {(err) => ( <> {i18n()('zap.lnUrlEndpointError')}: {err.reason} )} {i18n()('zap.fetchingLnUrlEndpoint')} {i18n()('zap.fetchingLnUrlInvoice')} {i18n()('zap.fetchingLnUrlInvoiceError')}: {getInvoiceMutation?.error?.message} {(invoice) => ( )}
{i18n()('zap.lnurlServiceDoesNotAllowNostr')}
{i18n()('zap.zapSplitIsNotSupported')}
{(url) => ( LNURL service icon )}
{lnurlPayUrlDomain()}
{endpoint()?.decodedMetadata?.textPlain}
{endpoint()?.decodedMetadata?.textLongDesc}
0 ? commentAllowed() : 70} placeholder={i18n()('zap.comment')} disabled={query.isPending} value={comment()} onChange={(ev) => setComment(ev.target.value)} />
); }; const ZapRequestModal: Component = (props) => { const i18n = useTranslation(); const { lud06, lud16, isZapConfigured } = useProfile(() => ({ pubkey: props.event.pubkey, })); const [lnurlSource, setLnurlSource] = createSignal<'lud06' | 'lud16' | undefined>(); createEffect(() => { if (lud06() != null) setLnurlSource('lud06'); else if (lud16() != null) setLnurlSource('lud16'); }); return (
{(value) => } {(value) => }
); }; export default ZapRequestModal;