Files
rabbit/src/components/modal/ZapRequestModal.tsx
2024-01-14 23:24:45 +09:00

426 lines
14 KiB
TypeScript

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<WebLNProvider | undefined>();
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 <canvas width="256" height="256" ref={canvasRef} />;
};
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 (
<Show
when={!zapped()}
fallback={
<div class="flex flex-col items-center gap-4 py-8">
<span class="inline-block h-28 w-28 rounded-full border-4 border-primary p-4 text-primary">
<Check />
</span>
<div class="text-secondary text-xl">{i18n()('zap.completed')}</div>
</div>
}
>
<div class="flex flex-col items-center gap-2">
<div>
<QRCodeDisplay text={lightingInvoice()} />
</div>
<a
class="inline-block rounded bg-primary p-4 font-bold text-primary-fg hover:bg-primary-hover"
href={lightingInvoice()}
>
{i18n()('zap.sendViaWallet')}
</a>
<Show when={webln.status() === 'available'}>
<button
type="button"
class="inline-block rounded bg-primary p-4 font-bold text-primary-fg hover:bg-primary-hover"
onClick={handleClickWebLN}
>
{i18n()('zap.sendViaWebLN')}
</button>
</Show>
</div>
</Show>
);
};
const ZapDialog: Component<ZapDialogProps> = (props) => {
const i18n = useTranslation();
const pubkey = usePubkey();
const { config } = useConfig();
const [amountSats, setAmountSats] = createSignal<number>(1);
const [comment, setComment] = createSignal<string>('');
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<string | undefined> => {
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<HTMLFormElement, Event> = (ev) => {
ev.preventDefault();
getInvoiceMutation.mutate();
};
return (
<Switch>
<Match when={query.isError}>
{i18n()('zap.fetchingLnUrlEndpointError')}: {query?.error?.message}
</Match>
<Match when={error()} keyed>
{(err) => (
<>
{i18n()('zap.lnUrlEndpointError')}: {err.reason}
</>
)}
</Match>
<Match when={query.isFetching}>{i18n()('zap.fetchingLnUrlEndpoint')}</Match>
<Match when={getInvoiceMutation.isPending}>{i18n()('zap.fetchingLnUrlInvoice')}</Match>
<Match when={getInvoiceMutation.isError}>
{i18n()('zap.fetchingLnUrlInvoiceError')}: {getInvoiceMutation?.error?.message}
</Match>
<Match when={getInvoiceMutation.isSuccess && getInvoiceMutation.data} keyed>
{(invoice) => (
<InvoiceDisplay
invoice={invoice}
event={props.event}
nostrPubkey={endpoint()?.nostrPubkey}
/>
)}
</Match>
<Match when={query.isSuccess}>
<div class="flex flex-col items-center">
<Show when={!allowsNostr()}>
<div class="pb-8 text-center">{i18n()('zap.lnurlServiceDoesNotAllowNostr')}</div>
</Show>
<Show when={hasZapTag()}>
<div class="pb-8 text-center">{i18n()('zap.zapSplitIsNotSupported')}</div>
</Show>
<div class="flex flex-col items-center overflow-hidden rounded px-8 py-2 text-fg-secondary">
<Show when={lnurlServiceIcon()} keyed>
{(url) => (
<img
class="max-h-64 w-64 rounded object-cover"
alt="LNURL service icon"
src={url}
/>
)}
</Show>
<div class="font-bold">{lnurlPayUrlDomain()}</div>
<div>{endpoint()?.decodedMetadata?.textPlain}</div>
<div>{endpoint()?.decodedMetadata?.textLongDesc}</div>
</div>
<div class="w-96 rounded-lg border border-border p-2">
<EventDisplay event={props.event} actions={false} embedding={false} />
</div>
<form class="mt-4 flex w-64 flex-col items-center gap-1" onSubmit={handleSubmit}>
<label class="flex w-full items-center gap-2">
<input
type="number"
name="amountSats"
class="min-w-0 flex-1 rounded-md border border-border bg-bg text-center text-3xl ring-border placeholder:text-fg-secondary focus:border-border focus:ring-primary"
min={minSendableInSats()}
max={maxSendableInSats()}
required
disabled={query.isPending}
value={amountSats()}
onChange={(ev) => setAmountSats(parseInt(ev.target.value, 10))}
/>
<div class="text-center text-xl text-fg-secondary">sats</div>
</label>
<input
type="text"
name="comment"
class="w-full rounded-md border border-border bg-bg ring-border placeholder:text-fg-secondary focus:border-border focus:ring-primary"
maxLength={commentAllowed() > 0 ? commentAllowed() : 70}
placeholder={i18n()('zap.comment')}
disabled={query.isPending}
value={comment()}
onChange={(ev) => setComment(ev.target.value)}
/>
<button
type="submit"
class="flex w-full items-center justify-center rounded bg-primary py-4 text-primary-fg hover:bg-primary-hover"
disabled={getInvoiceMutation.isPending}
>
<span class="inline-block h-6 w-6">
<Bolt />
</span>
</button>
</form>
</div>
</Match>
</Switch>
);
};
const ZapRequestModal: Component<ZapRequestModalProps> = (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 (
<BasicModal onClose={props.onClose}>
<div class="p-8">
<Show when={isZapConfigured()} fallback={i18n()('zap.userDidNotConfigureZap')}>
<Show when={lud06() != null && lud16() != null}>
<div class="flex justify-center gap-3 pb-2">
<button
type="button"
class="rounded border-2 border-primary p-2"
classList={{
'bg-primary': lnurlSource() === 'lud06',
'text-primary-fg': lnurlSource() === 'lud06',
'bg-bg': lnurlSource() !== 'lud06',
'text-primary': lnurlSource() !== 'lud06',
}}
onClick={() => setLnurlSource('lud06')}
>
{i18n()('zap.lud06')}
</button>
<button
type="button"
class="rounded border-2 border-primary p-2"
classList={{
'bg-primary': lnurlSource() === 'lud16',
'text-primary-fg': lnurlSource() === 'lud16',
'bg-bg': lnurlSource() !== 'lud16',
'text-primary': lnurlSource() !== 'lud16',
}}
onClick={() => setLnurlSource('lud16')}
>
{i18n()('zap.lud16')}
</button>
</div>
</Show>
<Show when={lnurlSource() === 'lud06' && lud06()} keyed>
{(value) => <ZapDialog lnurlPayUrl={lud06ToLnurlPayUrl(value)} event={props.event} />}
</Show>
<Show when={lnurlSource() === 'lud16' && lud16()} keyed>
{(value) => <ZapDialog lnurlPayUrl={lud16ToLnurlPayUrl(value)} event={props.event} />}
</Show>
</Show>
</div>
</BasicModal>
);
};
export default ZapRequestModal;