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> <Routes>
<Route path="/login" element={<LoginView />} /> <Route path="/login" element={<LoginView />} />
<Route <Route
path="/user/:pubkey" path="/u/:pubkey"
element={ element={
<RequireSetup> <RequireSetup>
<UserPage /> <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", 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", 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 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 { 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 { import {
GlobalIcon, GlobalIcon,
HomeIcon, HomeIcon,
@@ -14,6 +15,13 @@ import {
ProfileIcon, ProfileIcon,
SettingsIcon, SettingsIcon,
} from "./icons"; } 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 MobileLayout = ({ children }: { children: React.ReactNode }) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -90,7 +98,8 @@ const DesktopLayout = ({ children }: { children: React.ReactNode }) => {
<ErrorBoundary>{children}</ErrorBoundary> <ErrorBoundary>{children}</ErrorBoundary>
</Flex> </Flex>
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}> <VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
<Button onClick={() => navigate("/")}>Manage Follows</Button> <Heading size="md">Following</Heading>
<FollowingList />
</VStack> </VStack>
</Container> </Container>
); );

View File

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

View File

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

View File

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

View File

@@ -3,12 +3,16 @@ import {
Section, Section,
AmountSection, AmountSection,
DescriptionSection, DescriptionSection,
TimestampSection,
} from "light-bolt11-decoder"; } from "light-bolt11-decoder";
import { convertTimestampToDate } from "./date";
export type ParsedInvoice = { export type ParsedInvoice = {
paymentRequest: string; paymentRequest: string;
description: string; description: string;
amount?: number; amount?: number;
timestamp: Date;
expiry: Date;
}; };
function isDescription(section: Section): section is DescriptionSection { 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 { function isAmount(section: Section): section is AmountSection {
return section.name === "amount"; return section.name === "amount";
} }
function isTimestamp(section: Section): section is TimestampSection {
return section.name === "timestamp";
}
export function parsePaymentRequest(paymentRequest: string): ParsedInvoice { export function parsePaymentRequest(paymentRequest: string): ParsedInvoice {
const decoded = decode(paymentRequest); const decoded = decode(paymentRequest);
const timestamp = decoded.sections.find(isTimestamp)?.value ?? 0;
return { return {
paymentRequest: decoded.paymentRequest, paymentRequest: decoded.paymentRequest,
description: decoded.sections.find(isDescription)?.value ?? "", description: decoded.sections.find(isDescription)?.value ?? "",
amount: decoded.sections.find(isAmount)?.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 { 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) { if (metadata?.display_name && metadata?.name) {
return `${metadata.display_name} (${metadata.name})`; return `${metadata.display_name} (${metadata.name})`;
} else if (metadata?.name) { } else if (metadata?.name) {
return 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 { UserPostsTab } from "./posts";
import { useUserMetadata } from "../../hooks/use-user-metadata"; import { useUserMetadata } from "../../hooks/use-user-metadata";
import { UserAvatar } from "../../components/user-avatar"; 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 { useIsMobile } from "../../hooks/use-is-mobile";
import { UserRelaysTab } from "./relays"; import { UserRelaysTab } from "./relays";
import { UserFollowingTab } from "./following"; import { UserFollowingTab } from "./following";
import { UserRepliesTab } from "./replies"; import { UserRepliesTab } from "./replies";
import { normalizeToBech32, normalizeToHex } from "../../helpers/nip-19"; import { normalizeToHex } from "../../helpers/nip-19";
import { Page } from "../../components/page"; import { Page } from "../../components/page";
import { UserProfileMenu } from "./user-profile-menu"; import { UserProfileMenu } from "./user-profile-menu";
@@ -60,8 +60,7 @@ export const UserView = ({ pubkey }: UserViewProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { metadata, loading: loadingMetadata } = useUserMetadata(pubkey, true); const { metadata, loading: loadingMetadata } = useUserMetadata(pubkey, true);
const bech32Key = normalizeToBech32(pubkey); const label = metadata && getUserDisplayName(metadata, pubkey);
const label = metadata ? getUserFullName(metadata) || bech32Key : bech32Key;
return ( return (
<Flex <Flex