mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-21 14:09:17 +02:00
add hacky zap implementation
This commit is contained in:
@@ -17,7 +17,7 @@ import { UserLink } from "../user-link";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { convertTimestampToDate } from "../../helpers/date";
|
import { convertTimestampToDate } from "../../helpers/date";
|
||||||
import { DislikeIcon, LikeIcon } from "../icons";
|
import { DislikeIcon, LikeIcon } from "../icons";
|
||||||
import { parseZapNote } from "../../helpers/nip57";
|
import { parseZapNote } from "../../helpers/nip-57";
|
||||||
import { readableAmountInSats } from "../../helpers/bolt11";
|
import { readableAmountInSats } from "../../helpers/bolt11";
|
||||||
import useEventReactions from "../../hooks/use-event-reactions";
|
import useEventReactions from "../../hooks/use-event-reactions";
|
||||||
import useEventZaps from "../../hooks/use-event-zaps";
|
import useEventZaps from "../../hooks/use-event-zaps";
|
||||||
|
@@ -1,22 +1,127 @@
|
|||||||
import { Button, ButtonProps } from "@chakra-ui/react";
|
import { Button, ButtonProps, useToast } from "@chakra-ui/react";
|
||||||
import { readableAmountInSats } from "../../helpers/bolt11";
|
import { makeZapRequest } from "nostr-tools/nip57";
|
||||||
import { totalZaps } from "../../helpers/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 useEventZaps from "../../hooks/use-event-zaps";
|
||||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
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 { NostrEvent } from "../../types/nostr-event";
|
||||||
import { LightningIcon } from "../icons";
|
import { LightningIcon } from "../icons";
|
||||||
|
|
||||||
export default function NoteZapButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
|
export default function NoteZapButton({ note, ...props }: { note: NostrEvent } & Omit<ButtonProps, "children">) {
|
||||||
|
const { requestSignature } = useSigningContext();
|
||||||
|
const account = useCurrentAccount();
|
||||||
const metadata = useUserMetadata(note.pubkey);
|
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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<LightningIcon color="yellow.500" />}
|
leftIcon={<LightningIcon color="yellow.500" />}
|
||||||
aria-label="Zap Note"
|
aria-label="Zap Note"
|
||||||
title="Zap Note"
|
title="Zap Note"
|
||||||
|
colorScheme={hasZapped ? "brand" : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
isDisabled
|
isLoading={loading}
|
||||||
|
onClick={handleClick}
|
||||||
|
isDisabled={!tipAddress}
|
||||||
>
|
>
|
||||||
{readableAmountInSats(totalZaps(zaps), false)}
|
{readableAmountInSats(totalZaps(zaps), false)}
|
||||||
</Button>
|
</Button>
|
||||||
|
@@ -6,7 +6,7 @@ export function encodeText(prefix: string, text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function decodeText(encoded: 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)));
|
const text = new TextDecoder().decode(new Uint8Array(bech32.fromWords(decoded.words)));
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
|
@@ -8,3 +8,22 @@ export function isLNURL(lnurl: string) {
|
|||||||
return false;
|
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);
|
||||||
|
}
|
||||||
|
@@ -73,6 +73,7 @@ export function parseZapNote(event: NostrEvent) {
|
|||||||
const eventId = zapRequest.tags.find(isETag)?.[1];
|
const eventId = zapRequest.tags.find(isETag)?.[1];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
zap: event,
|
||||||
request: zapRequest,
|
request: zapRequest,
|
||||||
payment,
|
payment,
|
||||||
eventId,
|
eventId,
|
49
src/services/lnurl-metadata.ts
Normal file
49
src/services/lnurl-metadata.ts
Normal 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;
|
@@ -6,7 +6,7 @@ import { useAppTitle } from "../../hooks/use-app-title";
|
|||||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||||
import { useThrottle } from "react-use";
|
import { useThrottle } from "react-use";
|
||||||
import { Kind } from "nostr-tools";
|
import { Kind } from "nostr-tools";
|
||||||
import { parseZapNote } from "../../helpers/nip57";
|
import { parseZapNote } from "../../helpers/nip-57";
|
||||||
import { NoteLink } from "../../components/note-link";
|
import { NoteLink } from "../../components/note-link";
|
||||||
|
|
||||||
export default function PopularTab() {
|
export default function PopularTab() {
|
||||||
|
@@ -7,7 +7,7 @@ import { UserAvatarLink } from "../../components/user-avatar-link";
|
|||||||
import { UserLink } from "../../components/user-link";
|
import { UserLink } from "../../components/user-link";
|
||||||
import { readableAmountInSats } from "../../helpers/bolt11";
|
import { readableAmountInSats } from "../../helpers/bolt11";
|
||||||
import { convertTimestampToDate } from "../../helpers/date";
|
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 { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||||
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
|
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
|
||||||
import { NostrEvent } from "../../types/nostr-event";
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
|
Reference in New Issue
Block a user