add hacky zap implementation

This commit is contained in:
hzrd149 2023-02-23 08:39:06 -06:00
parent f03aac1619
commit e7ce8913c7
8 changed files with 183 additions and 9 deletions

View File

@ -17,7 +17,7 @@ import { UserLink } from "../user-link";
import moment from "moment";
import { convertTimestampToDate } from "../../helpers/date";
import { DislikeIcon, LikeIcon } from "../icons";
import { parseZapNote } from "../../helpers/nip57";
import { parseZapNote } from "../../helpers/nip-57";
import { readableAmountInSats } from "../../helpers/bolt11";
import useEventReactions from "../../hooks/use-event-reactions";
import useEventZaps from "../../hooks/use-event-zaps";

View File

@ -1,22 +1,127 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import { readableAmountInSats } from "../../helpers/bolt11";
import { totalZaps } from "../../helpers/nip57";
import { Button, ButtonProps, useToast } from "@chakra-ui/react";
import { makeZapRequest } from "nostr-tools/nip57";
import { useMemo, useRef, useState } from "react";
import { random } from "../../helpers/array";
import { parsePaymentRequest, readableAmountInSats } from "../../helpers/bolt11";
import { parseZapNote, totalZaps } from "../../helpers/nip-57";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useEventZaps from "../../hooks/use-event-zaps";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import { getEventRelays } from "../../services/event-relays";
import eventZapsService from "../../services/event-zaps";
import lnurlMetadataService from "../../services/lnurl-metadata";
import { NostrEvent } from "../../types/nostr-event";
import { LightningIcon } from "../icons";
export default function NoteZapButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
const { requestSignature } = useSigningContext();
const account = useCurrentAccount();
const metadata = useUserMetadata(note.pubkey);
const zaps = useEventZaps(note.id, [], true) ?? [];
const zaps = useEventZaps(note.id) ?? [];
const parsedZaps = useMemo(() => {
const parsed = [];
for (const zap of zaps) {
try {
parsed.push(parseZapNote(zap));
} catch (e) {}
}
return parsed;
}, [zaps]);
const toast = useToast();
const [loading, setLoading] = useState(false);
const timeout = useRef(0);
const zapAmount = useRef(0);
const hasZapped = parsedZaps.some((zapRequest) => zapRequest.request.pubkey === account.pubkey);
const tipAddress = metadata?.lud06 ?? metadata?.lud16;
const handleClick = () => {
if (!tipAddress) return;
if (timeout.current) {
window.clearTimeout(timeout.current);
}
zapAmount.current += 21;
timeout.current = window.setTimeout(async () => {
setLoading(true);
try {
const eventRelays = getEventRelays(note.id).value;
const readRelays = clientRelaysService.getReadUrls();
const lnurlMetadata = await lnurlMetadataService.requestMetadata(tipAddress);
const amount = zapAmount.current * 1000;
if (lnurlMetadata && lnurlMetadata.allowsNostr && lnurlMetadata.nostrPubkey) {
const zapRequest = makeZapRequest({
profile: note.pubkey,
event: note.id,
// pick a random relay from the event and one of our read relays
relays: [random(eventRelays), random(readRelays)],
amount,
comment: "",
});
const signed = await requestSignature(zapRequest);
if (signed) {
if (amount > lnurlMetadata.maxSendable) throw new Error("amount to large");
if (amount < lnurlMetadata.minSendable) throw new Error("amount to small");
const url = new URL(lnurlMetadata.callback);
url.searchParams.append("amount", String(zapAmount.current * 1000));
url.searchParams.append("nostr", JSON.stringify(signed));
const { pr: payRequest } = await fetch(url).then((res) => res.json());
if (payRequest as string) {
const parsed = parsePaymentRequest(payRequest);
if (parsed.amount !== amount) throw new Error("incorrect amount");
if (window.webln) {
await window.webln.enable();
await window.webln.sendPayment(payRequest);
// fetch the zaps again
eventZapsService.requestZaps(note.id, readRelays, true);
} else {
window.addEventListener(
"focus",
() => {
// when the window regains focus, fetch the zaps again
eventZapsService.requestZaps(note.id, readRelays, true);
},
{ once: true }
);
window.open("lightning:" + payRequest);
}
}
}
} else {
// show standard tipping
}
} catch (e) {
if (e instanceof Error) {
console.log(e);
toast({
status: "error",
description: e.message,
});
}
}
zapAmount.current = 0;
setLoading(false);
}, 1500);
};
return (
<Button
leftIcon={<LightningIcon color="yellow.500" />}
aria-label="Zap Note"
title="Zap Note"
colorScheme={hasZapped ? "brand" : undefined}
{...props}
isDisabled
isLoading={loading}
onClick={handleClick}
isDisabled={!tipAddress}
>
{readableAmountInSats(totalZaps(zaps), false)}
</Button>

View File

@ -6,7 +6,7 @@ export function encodeText(prefix: string, text: string) {
}
export function decodeText(encoded: string) {
const decoded = bech32.decode(encoded);
const decoded = bech32.decode(encoded, 256);
const text = new TextDecoder().decode(new Uint8Array(bech32.fromWords(decoded.words)));
return {
text,

View File

@ -8,3 +8,22 @@ export function isLNURL(lnurl: string) {
return false;
}
}
export function parseLub16Address(address: string) {
let [name, domain] = address.split("@");
if (!name || !domain) return;
return `https://${domain}/.well-known/lnurlp/${name}`;
}
export function parseLNURL(lnurl: string) {
const { text, prefix } = decodeText(lnurl);
return prefix === "lnurl" ? text : undefined;
}
export function getLudEndpoint(addressOrLNURL: string) {
if (addressOrLNURL.includes("@")) {
return parseLub16Address(addressOrLNURL);
}
return parseLNURL(addressOrLNURL);
}

View File

@ -73,6 +73,7 @@ export function parseZapNote(event: NostrEvent) {
const eventId = zapRequest.tags.find(isETag)?.[1];
return {
zap: event,
request: zapRequest,
payment,
eventId,

View File

@ -0,0 +1,49 @@
import { getLudEndpoint } from "../helpers/lnurl";
type LNURLPMetadata = {
callback: string;
maxSendable: number;
minSendable: number;
metadata: string;
commentAllowed?: number;
tag: "payRequest";
allowsNostr?: true;
nostrPubkey?: string;
};
type LNURLError = {
status: "error";
message: string;
};
class LNURLMetadataService {
private metadata = new Map<string, LNURLPMetadata>();
private pending = new Map<string, Promise<LNURLPMetadata | undefined>>();
private async fetchMetadata(addressOrLNURL: string) {
const url = getLudEndpoint(addressOrLNURL);
if (!url) return;
try {
const metadata = await fetch(url).then((res) => res.json() as Promise<LNURLError | LNURLPMetadata>);
if ((metadata as LNURLPMetadata).tag === "payRequest") {
return metadata as LNURLPMetadata;
}
} catch (e) {}
this.pending.delete(addressOrLNURL);
}
async requestMetadata(addressOrLNURL: string, alwaysFetch = false) {
if (this.metadata.has(addressOrLNURL) && !alwaysFetch) {
return this.metadata.get(addressOrLNURL);
}
if (this.pending.has(addressOrLNURL)) {
return this.pending.get(addressOrLNURL);
}
const promise = this.fetchMetadata(addressOrLNURL);
this.pending.set(addressOrLNURL, promise);
return await promise;
}
}
const lnurlMetadataService = new LNURLMetadataService();
export default lnurlMetadataService;

View File

@ -6,7 +6,7 @@ import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useThrottle } from "react-use";
import { Kind } from "nostr-tools";
import { parseZapNote } from "../../helpers/nip57";
import { parseZapNote } from "../../helpers/nip-57";
import { NoteLink } from "../../components/note-link";
export default function PopularTab() {

View File

@ -7,7 +7,7 @@ import { UserAvatarLink } from "../../components/user-avatar-link";
import { UserLink } from "../../components/user-link";
import { readableAmountInSats } from "../../helpers/bolt11";
import { convertTimestampToDate } from "../../helpers/date";
import { isProfileZap, parseZapNote } from "../../helpers/nip57";
import { isProfileZap, parseZapNote } from "../../helpers/nip-57";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";