mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-10 04:39:19 +02:00
improve invoice integration
add simple followers list on desktop
This commit is contained in:
parent
8caf80f899
commit
41609865d5
@ -27,7 +27,7 @@ export const App = () => {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginView />} />
|
||||
<Route
|
||||
path="/user/:pubkey"
|
||||
path="/u/:pubkey"
|
||||
element={
|
||||
<RequireSetup>
|
||||
<UserPage />
|
||||
|
45
src/components/following-list.tsx
Normal file
45
src/components/following-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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",
|
||||
});
|
||||
|
90
src/components/inline-invoice-card.tsx
Normal file
90
src/components/inline-invoice-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
18
src/hooks/use-user-contacts.ts
Normal file
18
src/hooks/use-user-contacts.ts
Normal 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
11
src/types/webln.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
import { NostrEvent } from "./nostr-event";
|
||||
import { WebLNProvider } from "webln";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webln?: WebLNProvider & {
|
||||
enabled?: boolean;
|
||||
isEnabled?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user