improve invoice integration

add simple followers list on desktop
This commit is contained in:
hzrd149 2023-02-07 17:04:18 -06:00
parent 8caf80f899
commit 41609865d5
14 changed files with 237 additions and 103 deletions

View File

@ -27,7 +27,7 @@ export const App = () => {
<Routes>
<Route path="/login" element={<LoginView />} />
<Route
path="/user/:pubkey"
path="/u/:pubkey"
element={
<RequireSetup>
<UserPage />

View File

@ -0,0 +1,45 @@
import { Box, Button, Flex, SkeletonText } from "@chakra-ui/react";
import { Link } from "react-router-dom";
import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip-19";
import { getUserDisplayName } from "../helpers/user-metadata";
import useSubject from "../hooks/use-subject";
import { useUserContacts } from "../hooks/use-user-contacts";
import { useUserMetadata } from "../hooks/use-user-metadata";
import identity from "../services/identity";
import { UserAvatar } from "./user-avatar";
const FollowingListItem = ({ pubkey }: { pubkey: string }) => {
const { metadata, loading } = useUserMetadata(pubkey);
if (loading || !metadata) return <SkeletonText />;
return (
<Button
as={Link}
leftIcon={<UserAvatar pubkey={pubkey} size="xs" />}
overflow="hidden"
variant="outline"
to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}
justifyContent="flex-start"
>
{getUserDisplayName(metadata, pubkey)}
</Button>
);
};
export const FollowingList = () => {
const pubkey = useSubject(identity.pubkey);
const { contacts, loading } = useUserContacts(pubkey);
if (loading || !contacts) return <SkeletonText />;
return (
<Box overflow="auto" pr="2" pb="4" pt="2">
<Flex direction="column" gap="2">
{contacts.contacts.map((contact) => (
<FollowingListItem key={contact.pubkey} pubkey={contact.pubkey} />
))}
</Flex>
</Box>
);
};

View File

@ -44,3 +44,8 @@ export const ProfileIcon = createIcon({
displayName: "user-line",
d: "M4 22a8 8 0 1 1 16 0h-2a6 6 0 1 0-12 0H4zm8-9c-3.315 0-6-2.685-6-6s2.685-6 6-6 6 2.685 6 6-2.685 6-6 6zm0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4z",
});
export const ClipboardIcon = createIcon({
displayName: "clipboard-line",
d: "M7 4V2h10v2h3.007c.548 0 .993.445.993.993v16.014a.994.994 0 0 1-.993.993H3.993A.994.994 0 0 1 3 21.007V4.993C3 4.445 3.445 4 3.993 4H7zm0 2H5v14h14V6h-2v2H7V6zm2-2v2h6V4H9z",
});

View File

@ -0,0 +1,90 @@
import React, { useState } from "react";
import {
Box,
Button,
ButtonGroup,
Heading,
IconButton,
Text,
} from "@chakra-ui/react";
import { requestProvider } from "webln";
import { getReadableAmount, parsePaymentRequest } from "../helpers/bolt11";
import { useAsync } from "react-use";
import { ClipboardIcon } from "./icons";
import moment from "moment";
export type InvoiceButtonProps = {
paymentRequest: string;
};
export const InlineInvoiceCard = ({ paymentRequest }: InvoiceButtonProps) => {
const { value: invoice, error } = useAsync(async () =>
parsePaymentRequest(paymentRequest)
);
const [loading, setLoading] = useState(false);
const handleClick = async (event: React.SyntheticEvent) => {
if (!window.webln) return;
event.preventDefault();
setLoading(true);
try {
const provider = await requestProvider();
const response = await provider.sendPayment(paymentRequest);
if (response.preimage) {
console.log("Paid");
}
} catch (e) {
console.log("Failed to pay invoice");
console.log(e);
}
setLoading(false);
};
if (error) {
<>{paymentRequest}</>;
}
if (!invoice) return <>Loading Invoice...</>;
const isExpired = moment(invoice.expiry).isBefore(moment());
return (
<Box
padding="3"
borderColor="yellow.300"
borderWidth="1px"
borderRadius="md"
display="flex"
gap="4"
alignItems="center"
>
<Box flexGrow={1}>
<Text fontWeight="bold">Lightning Invoice</Text>
<Text>{invoice.description}</Text>
</Box>
<Box>
<Text color={isExpired ? "red.400" : undefined}>
{isExpired ? "Expired" : "Expires"}:{" "}
{moment(invoice.expiry).fromNow()}
</Text>
</Box>
<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) : ""}
</Button>
</ButtonGroup>
</Box>
);
};

View File

@ -1,46 +0,0 @@
import { useState } from "react";
import { Button } from "@chakra-ui/react";
import { requestProvider } from "webln";
import { getReadableAmount, parsePaymentRequest } from "../helpers/bolt11";
import { useAsync } from "react-use";
export type InvoiceButtonProps = {
paymentRequest: string;
};
export const InvoiceButton = ({ paymentRequest }: InvoiceButtonProps) => {
const { value: invoice, error } = useAsync(async () =>
parsePaymentRequest(paymentRequest)
);
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
try {
const provider = await requestProvider();
await provider.enable();
const response = await provider.sendPayment(paymentRequest);
if (response.preimage) {
console.log("Paid");
}
} catch (e) {
console.log("Failed to pay invoice");
console.log(e);
}
setLoading(false);
};
if (error) {
<>{paymentRequest}</>;
}
return (
<Button
colorScheme="yellow"
variant="outline"
onClick={handleClick}
isLoading={loading}
>
Invoice for{" "}
{invoice?.amount ? getReadableAmount(invoice.amount) : "♾️"}
</Button>
);
};

View File

@ -1,12 +1,13 @@
import React from "react";
import { Button, Container, Flex, IconButton, VStack } from "@chakra-ui/react";
import {
Button,
Container,
Flex,
Heading,
IconButton,
VStack,
} from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { ErrorBoundary } from "./error-boundary";
import { ConnectedRelays } from "./connected-relays";
import { useIsMobile } from "../hooks/use-is-mobile";
import { ProfileButton } from "./profile-button";
import identity from "../services/identity";
import {
GlobalIcon,
HomeIcon,
@ -14,6 +15,13 @@ import {
ProfileIcon,
SettingsIcon,
} from "./icons";
import { ErrorBoundary } from "./error-boundary";
import { ConnectedRelays } from "./connected-relays";
import { useIsMobile } from "../hooks/use-is-mobile";
import { ProfileButton } from "./profile-button";
import identity from "../services/identity";
import { FollowingList } from "./following-list";
const MobileLayout = ({ children }: { children: React.ReactNode }) => {
const navigate = useNavigate();
@ -90,7 +98,8 @@ const DesktopLayout = ({ children }: { children: React.ReactNode }) => {
<ErrorBoundary>{children}</ErrorBoundary>
</Flex>
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
<Button onClick={() => navigate("/")}>Manage Follows</Button>
<Heading size="md">Following</Heading>
<FollowingList />
</VStack>
</Container>
);

View File

@ -12,10 +12,8 @@ import remarkImages from "remark-images";
import remarkUnwrapImages from "remark-unwrap-images";
import rehypeExternalLinks from "rehype-external-links";
// @ts-ignore
// import rehypeTruncate from "rehype-truncate";
// @ts-ignore
import linkifyRegex from "remark-linkify-regex";
import { InvoiceButton } from "./invoice-button";
import { InlineInvoiceCard } from "./inline-invoice-card";
import { TweetEmbed } from "./tweet-embed";
const lightningInvoiceRegExp = /(lightning:)?LNBC[A-Za-z0-9]+/i;
@ -41,7 +39,9 @@ const HandleLinkTypes = (props: LinkProps) => {
if (href) {
if (lightningInvoiceRegExp.test(href)) {
return <InvoiceButton paymentRequest={href.replace(/lightning:/i, "")} />;
return (
<InlineInvoiceCard paymentRequest={href.replace(/lightning:/i, "")} />
);
}
if (youtubeVideoLink.test(href)) {
const parts = youtubeVideoLink.exec(href);
@ -74,29 +74,23 @@ const components = {
export type PostContentsProps = {
content: string;
maxChars?: number;
};
export const PostContents = React.memo(
({ content, maxChars }: PostContentsProps) => {
const fixedLines = content.replace(/(?<! )\n/g, " \n");
export const PostContents = React.memo(({ content }: PostContentsProps) => {
const fixedLines = content.replace(/(?<! )\n/g, " \n");
return (
<ReactMarkdown
remarkPlugins={[
remarkImages,
remarkUnwrapImages,
remarkGfm,
linkifyRegex(lightningInvoiceRegExp),
]}
rehypePlugins={[
[rehypeExternalLinks, { target: "_blank" }],
// [rehypeTruncate, { maxChars, disable: !maxChars }],
]}
components={components}
>
{fixedLines}
</ReactMarkdown>
);
}
);
return (
<ReactMarkdown
remarkPlugins={[
remarkImages,
remarkUnwrapImages,
remarkGfm,
linkifyRegex(lightningInvoiceRegExp),
]}
rehypePlugins={[[rehypeExternalLinks, { target: "_blank" }]]}
components={components}
>
{fixedLines}
</ReactMarkdown>
);
});

View File

@ -20,7 +20,7 @@ import { PostModal } from "../post-modal";
import { NostrEvent } from "../../types/nostr-event";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { UserAvatarLink } from "../user-avatar-link";
import { getUserFullName } from "../../helpers/user-metadata";
import { getUserDisplayName } from "../../helpers/user-metadata";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
import { PostContents } from "../post-contents";
@ -35,9 +35,7 @@ export const Post = React.memo(({ event }: PostProps) => {
const { isOpen, onClose, onOpen } = useDisclosure();
const { metadata } = useUserMetadata(event.pubkey);
const username = metadata
? getUserFullName(metadata) || event.pubkey
: event.pubkey;
const username = metadata && getUserDisplayName(metadata, event.pubkey);
return (
<Card padding="2" variant="outline">
@ -49,7 +47,7 @@ export const Post = React.memo(({ event }: PostProps) => {
<Box>
<Heading size="sm">
<Link
to={`/user/${normalizeToBech32(
to={`/u/${normalizeToBech32(
event.pubkey,
Bech32Prefix.Pubkey
)}`}
@ -66,7 +64,7 @@ export const Post = React.memo(({ event }: PostProps) => {
<CardBody padding="0" mb="2">
<VStack alignItems="flex-start" justifyContent="stretch">
<Box overflow="hidden" width="100%">
<PostContents content={event.content} maxChars={300} />
<PostContents content={event.content} />
</Box>
</VStack>
</CardBody>

View File

@ -4,23 +4,18 @@ import { Link } from "react-router-dom";
import { Bech32Prefix, normalizeToBech32 } from "../helpers/nip-19";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { UserAvatar, UserAvatarProps } from "./user-avatar";
import { getUserDisplayName } from "../helpers/user-metadata";
export const UserAvatarLink = React.memo(
({ pubkey, ...props }: UserAvatarProps) => {
const { metadata } = useUserMetadata(pubkey);
let label = "Loading...";
if (metadata?.display_name && metadata?.name) {
label = `${metadata.display_name} (${metadata.name})`;
} else if (metadata?.name) {
label = metadata.name;
} else {
label = normalizeToBech32(pubkey) ?? pubkey;
}
const label = metadata
? getUserDisplayName(metadata, pubkey)
: "Loading...";
return (
<Tooltip label={label}>
<Link to={`/user/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
<Link to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
<UserAvatar pubkey={pubkey} {...props} />
</Link>
</Tooltip>

View File

@ -3,12 +3,16 @@ import {
Section,
AmountSection,
DescriptionSection,
TimestampSection,
} from "light-bolt11-decoder";
import { convertTimestampToDate } from "./date";
export type ParsedInvoice = {
paymentRequest: string;
description: string;
amount?: number;
timestamp: Date;
expiry: Date;
};
function isDescription(section: Section): section is DescriptionSection {
@ -17,14 +21,20 @@ function isDescription(section: Section): section is DescriptionSection {
function isAmount(section: Section): section is AmountSection {
return section.name === "amount";
}
function isTimestamp(section: Section): section is TimestampSection {
return section.name === "timestamp";
}
export function parsePaymentRequest(paymentRequest: string): ParsedInvoice {
const decoded = decode(paymentRequest);
const timestamp = decoded.sections.find(isTimestamp)?.value ?? 0;
return {
paymentRequest: decoded.paymentRequest,
description: decoded.sections.find(isDescription)?.value ?? "",
amount: decoded.sections.find(isAmount)?.value,
timestamp: convertTimestampToDate(timestamp),
expiry: convertTimestampToDate(timestamp + decoded.expiry),
};
}

View File

@ -1,9 +1,15 @@
import { Kind0ParsedContent } from "../types/nostr-event";
import { normalizeToBech32 } from "./nip-19";
import { truncatedId } from "./nostr-event";
export function getUserFullName(metadata: Kind0ParsedContent) {
export function getUserDisplayName(
metadata: Kind0ParsedContent,
pubkey: string
) {
if (metadata?.display_name && metadata?.name) {
return `${metadata.display_name} (${metadata.name})`;
} else if (metadata?.name) {
return metadata.name;
}
return truncatedId(normalizeToBech32(pubkey) ?? pubkey);
}

View File

@ -0,0 +1,18 @@
import { useMemo } from "react";
import settings from "../services/settings";
import userContacts from "../services/user-contacts";
import useSubject from "./use-subject";
export function useUserContacts(pubkey: string) {
const relays = useSubject(settings.relays);
const observable = useMemo(
() => userContacts.requestUserContacts(pubkey, relays),
[pubkey, relays]
);
const contacts = useSubject(observable) ?? undefined;
return {
loading: !contacts,
contacts,
};
}

11
src/types/webln.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
import { NostrEvent } from "./nostr-event";
import { WebLNProvider } from "webln";
declare global {
interface Window {
webln?: WebLNProvider & {
enabled?: boolean;
isEnabled?: boolean;
};
}
}

View File

@ -18,12 +18,12 @@ import { useParams } from "react-router-dom";
import { UserPostsTab } from "./posts";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import { UserAvatar } from "../../components/user-avatar";
import { getUserFullName } from "../../helpers/user-metadata";
import { getUserDisplayName } from "../../helpers/user-metadata";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { UserRelaysTab } from "./relays";
import { UserFollowingTab } from "./following";
import { UserRepliesTab } from "./replies";
import { normalizeToBech32, normalizeToHex } from "../../helpers/nip-19";
import { normalizeToHex } from "../../helpers/nip-19";
import { Page } from "../../components/page";
import { UserProfileMenu } from "./user-profile-menu";
@ -60,8 +60,7 @@ export const UserView = ({ pubkey }: UserViewProps) => {
const isMobile = useIsMobile();
const { metadata, loading: loadingMetadata } = useUserMetadata(pubkey, true);
const bech32Key = normalizeToBech32(pubkey);
const label = metadata ? getUserFullName(metadata) || bech32Key : bech32Key;
const label = metadata && getUserDisplayName(metadata, pubkey);
return (
<Flex