mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
426 lines
14 KiB
TypeScript
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;
|