mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 06:24:25 +01:00
feat: send zap
This commit is contained in:
380
src/components/modal/ZapRequestModal.tsx
Normal file
380
src/components/modal/ZapRequestModal.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
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 { 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 { signEvent } from '@/nostr/useCommands';
|
||||
import useLnurlEndpoint from '@/nostr/useLnurlEndpoint';
|
||||
import useProfile from '@/nostr/useProfile';
|
||||
import usePubkey from '@/nostr/usePubkey';
|
||||
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';
|
||||
|
||||
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 }> = (props) => {
|
||||
const i18n = useTranslation();
|
||||
const webln = useWebLN();
|
||||
|
||||
const lightingInvoice = () => `lightning:${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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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} />}
|
||||
</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;
|
||||
Reference in New Issue
Block a user