add zap tab to user profile

fix twitter embed
This commit is contained in:
hzrd149
2023-02-20 09:18:43 -06:00
parent 46dd557538
commit 50265633bd
14 changed files with 189 additions and 21 deletions

View File

@@ -19,7 +19,7 @@
"light-bolt11-decoder": "^2.1.0",
"moment": "^2.29.4",
"noble-secp256k1": "^1.2.14",
"nostr-tools": "^1.4.1",
"nostr-tools": "^1.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",

View File

@@ -26,6 +26,7 @@ import { LoginNip05View } from "./views/login/nip05";
import { Button, Flex, Spinner, Text } from "@chakra-ui/react";
import { deleteDatabase } from "./services/db";
import { LoginNsecView } from "./views/login/nsec";
import UserZapsTab from "./views/user/zaps";
const RequireCurrentAccount = ({ children }: { children: JSX.Element }) => {
let location = useLocation();
@@ -85,6 +86,7 @@ const router = createBrowserRouter([
children: [
{ path: "", element: <UserNotesTab /> },
{ path: "notes", element: <UserNotesTab /> },
{ path: "zaps", element: <UserZapsTab /> },
{ path: "followers", element: <UserFollowersTab /> },
{ path: "following", element: <UserFollowingTab /> },
{ path: "relays", element: <UserRelaysTab /> },

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { Box, Button, ButtonGroup, IconButton, Text } from "@chakra-ui/react";
import { requestProvider } from "webln";
import { getReadableAmount, parsePaymentRequest } from "../helpers/bolt11";
import { readableAmountInSats, parsePaymentRequest } from "../helpers/bolt11";
import { useAsync } from "react-use";
import { ClipboardIcon } from "./icons";
import moment from "moment";
@@ -62,7 +62,7 @@ export const InlineInvoiceCard = ({ paymentRequest }: InvoiceButtonProps) => {
<ButtonGroup>
<IconButton icon={<ClipboardIcon />} title="Copy to clipboard" aria-label="copy invoice" variant="outline" />
<Button as="a" variant="outline" onClick={handleClick} isLoading={loading} href={`lightning:${paymentRequest}`}>
Pay {invoice.amount ? getReadableAmount(invoice.amount) : ""}
Pay {invoice.amount ? readableAmountInSats(invoice.amount) : ""}
</Button>
</ButtonGroup>
</Box>

View File

@@ -47,7 +47,7 @@ const embeds: EmbedType[] = [
},
// Twitter tweet
{
regexp: /^https?:\/\/twitter\.com\/(?:\#!\/)?(\w+)\/status(es)?\/(\d+)[^\s]*/im,
regexp: /https?:\/\/twitter\.com\/(?:\#!\/)?(\w+)\/status(es)?\/(\d+)[^\s]*/im,
render: (match) => <TweetEmbed href={match[0]} conversation={false} />,
name: "Tweet",
isMedia: true,

View File

@@ -4,7 +4,11 @@ import { LightningIcon } from "./icons";
import { useState } from "react";
import { encodeText } from "../helpers/bech32";
export const UserTipButton = ({ pubkey, ...props }: { pubkey: string } & Omit<IconButtonProps, "aria-label">) => {
export const UserTipButton = ({
pubkey,
eventId,
...props
}: { pubkey: string; eventId?: string } & Omit<IconButtonProps, "aria-label">) => {
const metadata = useUserMetadata(pubkey);
const [loading, setLoading] = useState(false);
const toast = useToast();
@@ -59,7 +63,7 @@ export const UserTipButton = ({ pubkey, ...props }: { pubkey: string } & Omit<Ic
title="Send Tip"
icon={<LightningIcon />}
isLoading={loading}
color="yellow.300"
color="yellow.400"
{...props}
/>
);

View File

@@ -0,0 +1,24 @@
import { Button } from "@chakra-ui/react";
import { useState } from "react";
import { useUserMetadata } from "../hooks/use-user-metadata";
import {} from "nostr-tools/nip57";
const ZapButton = ({ pubkey, noteId }: { noteId: string; pubkey: string }) => {
const [loading, setLoading] = useState(false);
const metadata = useUserMetadata(pubkey);
const handleClick = async () => {
setLoading(true);
try {
} catch (e) {}
setLoading(false);
};
return (
<Button size="sm" onClick={handleClick} isLoading={loading}>
Zap
</Button>
);
};
export default ZapButton;

View File

@@ -32,7 +32,7 @@ export function parsePaymentRequest(paymentRequest: string): ParsedInvoice {
};
}
export function getReadableAmount(amount: number) {
export function readableAmountInSats(amount: number) {
const amountInSats = amount / 1000;
if (amountInSats > 1000000) {
return `${amountInSats / 1000000}M sats`;

66
src/helpers/nip57.ts Normal file
View File

@@ -0,0 +1,66 @@
import { utf8Decoder } from "nostr-tools/utils";
import { bech32 } from "@scure/base";
import { isETag, isPTag, NostrEvent } from "../types/nostr-event";
import { parsePaymentRequest } from "./bolt11";
import { Kind0ParsedContent } from "./user-metadata";
import { validateZapRequest } from "nostr-tools/nip57";
// based on https://github.com/nbd-wtf/nostr-tools/blob/master/nip57.ts
export async function getZapEndpoint(metadata: Kind0ParsedContent): Promise<null | string> {
try {
let lnurl: string = "";
let { lud06, lud16 } = metadata;
if (lud06) {
let { words } = bech32.decode(lud06, 1000);
let data = bech32.fromWords(words);
lnurl = utf8Decoder.decode(data);
} else if (lud16) {
let [name, domain] = lud16.split("@");
lnurl = `https://${domain}/.well-known/lnurlp/${name}`;
} else {
return null;
}
let res = await fetch(lnurl);
let body = await res.json();
if (body.allowsNostr && body.nostrPubkey) {
return body.callback;
}
} catch (err) {
/*-*/
}
return null;
}
export function isNoteZap(event: NostrEvent) {
return event.tags.some(isETag);
}
export function isProfileZap(event: NostrEvent) {
return !isNoteZap(event) && event.tags.some(isPTag);
}
export function parseZapNote(event: NostrEvent) {
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
if (!zapRequestStr) throw new Error("no description tag");
const bolt11 = event.tags.find((t) => t[0] === "bolt11")?.[1];
if (!bolt11) throw new Error("missing bolt11 invoice");
const error = validateZapRequest(zapRequestStr);
if (error) throw new Error(error);
const zapRequest = JSON.parse(zapRequestStr) as NostrEvent;
const payment = parsePaymentRequest(bolt11);
const eventId = zapRequest.tags.find(isETag)?.[1];
return {
request: zapRequest,
payment,
eventId,
};
}

View File

@@ -1,6 +1,7 @@
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
import { Account } from "./account";
import { signEvent, getEventHash, getPublicKey } from "nostr-tools";
import { getPublicKey } from "nostr-tools/keys";
import { signEvent, getEventHash } from "nostr-tools/event";
import db from "./db";
class SigningService {

View File

@@ -1,14 +1,7 @@
export type ETag = ["e", string] | ["e", string, string] | ["e", string, string, string];
export type PTag = ["p", string] | ["p", string, string];
export type RTag = ["r", string] | ["r", string, string];
export type Tag =
| [string]
| [string, string]
| [string, string, string]
| [string, string, string, string]
| ETag
| PTag
| RTag;
export type Tag = string[] | ETag | PTag | RTag;
export type NostrEvent = {
id: string;

View File

@@ -21,7 +21,7 @@ import { RelayUrlInput } from "../../components/relay-url-input";
import { Bech32Prefix, normalizeToBech32, normalizeToHex } from "../../helpers/nip-19";
import accountService from "../../services/account";
import clientRelaysService from "../../services/client-relays";
import { generatePrivateKey, getPublicKey } from "nostr-tools";
import { generatePrivateKey, getPublicKey } from "nostr-tools/keys";
import signingService from "../../services/signing";
export const LoginNsecView = () => {

View File

@@ -30,6 +30,7 @@ import { useCurrentAccount } from "../../hooks/use-current-account";
const tabs = [
{ label: "Notes", path: "notes" },
{ label: "Zaps", path: "zaps" },
{ label: "Followers", path: "followers" },
{ label: "Following", path: "following" },
{ label: "Relays", path: "relays" },

77
src/views/user/zaps.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { Box, Button, Flex, Spinner, Text, useDisclosure } from "@chakra-ui/react";
import moment from "moment";
import { useOutletContext } from "react-router-dom";
import { ErrorBoundary, ErrorFallback } from "../../components/error-boundary";
import QuoteNote from "../../components/note/quote-note";
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 { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
const ZapNote = ({ zapEvent }: { zapEvent: NostrEvent }) => {
const { isOpen, onToggle } = useDisclosure();
try {
const { request, payment, eventId } = parseZapNote(zapEvent);
return (
<Box
borderWidth="1px"
borderRadius="lg"
overflow="hidden"
padding="2"
display="flex"
gap="2"
flexDirection="column"
>
<Flex gap="2" alignItems="center" wrap="wrap">
<UserAvatarLink pubkey={request.pubkey} size="xs" />
<UserLink pubkey={request.pubkey} />
{payment.amount && <Text>{readableAmountInSats(payment.amount)}</Text>}
{request.content && (
<Button variant="link" onClick={onToggle}>
Show message
</Button>
)}
<Text ml="auto">{moment(convertTimestampToDate(request.created_at)).fromNow()}</Text>
</Flex>
{request.content && isOpen && <Text>{request.content}</Text>}
{eventId && <QuoteNote noteId={eventId} />}
</Box>
);
} catch (e) {
if (e instanceof Error) {
return <ErrorFallback error={e} resetErrorBoundary={() => {}} />;
}
return null;
}
};
const UserZapsTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useReadRelayUrls();
const { events, loading, loadMore } = useTimelineLoader(
`${pubkey}-zaps`,
readRelays,
{ "#p": [pubkey], kinds: [9735], since: moment().subtract(1, "day").unix() },
{ pageSize: moment.duration(1, "day").asSeconds() }
);
const timeline = events.filter(isProfileZap);
return (
<Flex direction="column" gap="2" pr="2" pl="2">
{timeline.map((event) => (
<ErrorBoundary key={event.id}>
<ZapNote zapEvent={event} />
</ErrorBoundary>
))}
{loading ? <Spinner ml="auto" mr="auto" mt="8" mb="8" /> : <Button onClick={() => loadMore()}>Load More</Button>}
</Flex>
);
};
export default UserZapsTab;

View File

@@ -3483,10 +3483,10 @@ node-releases@^2.0.6:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.8.tgz#0f349cdc8fcfa39a92ac0be9bc48b7706292b9ae"
integrity sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==
nostr-tools@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.4.1.tgz#9a8ba4ae7b23e78fa495b1b98b9b26992f41f791"
integrity sha512-pFAbVNtRMfW5ducUmk0f20IZv4a6pXYchBQKuA6D3x6sg83KoWipuiAie/gfOgrkoCsBLV14DmpWsVmo3N7WXQ==
nostr-tools@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.5.0.tgz#b8b8d4d9e122c4acbdfe8b580e82f539f0866b42"
integrity sha512-9zZdS3OF1hC8Tzpx2ycFLFx0AYSXBkLZ5EaXcPWIdZlQgSkpDrHl/TUYk2E8K7OocK+3VNbo4+7K2N38qdCjsg==
dependencies:
"@noble/hashes" "1.0.0"
"@noble/secp256k1" "^1.7.1"