diff --git a/src/app.tsx b/src/app.tsx index 2bffbdd48..0a6f29e63 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -27,7 +27,7 @@ export const App = () => { } /> diff --git a/src/components/following-list.tsx b/src/components/following-list.tsx new file mode 100644 index 000000000..18d69ebd8 --- /dev/null +++ b/src/components/following-list.tsx @@ -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 ; + + return ( + + ); +}; + +export const FollowingList = () => { + const pubkey = useSubject(identity.pubkey); + const { contacts, loading } = useUserContacts(pubkey); + + if (loading || !contacts) return ; + + return ( + + + {contacts.contacts.map((contact) => ( + + ))} + + + ); +}; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index b7220b8c7..11373983b 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -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", +}); diff --git a/src/components/inline-invoice-card.tsx b/src/components/inline-invoice-card.tsx new file mode 100644 index 000000000..bcd49e91f --- /dev/null +++ b/src/components/inline-invoice-card.tsx @@ -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 ( + + + Lightning Invoice + {invoice.description} + + + + {isExpired ? "Expired" : "Expires"}:{" "} + {moment(invoice.expiry).fromNow()} + + + + } + title="Copy to clipboard" + aria-label="copy invoice" + variant="outline" + /> + + + + ); +}; diff --git a/src/components/invoice-button.tsx b/src/components/invoice-button.tsx deleted file mode 100644 index ffd59103b..000000000 --- a/src/components/invoice-button.tsx +++ /dev/null @@ -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 ( - - ); -}; diff --git a/src/components/page.tsx b/src/components/page.tsx index 0d0eb0f04..e91e270ab 100644 --- a/src/components/page.tsx +++ b/src/components/page.tsx @@ -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 }) => { {children} - + Following + ); diff --git a/src/components/post-contents.tsx b/src/components/post-contents.tsx index 9ee722719..529fd4ba5 100644 --- a/src/components/post-contents.tsx +++ b/src/components/post-contents.tsx @@ -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 ; + return ( + + ); } 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(/(? { + const fixedLines = content.replace(/(? - {fixedLines} - - ); - } -); + return ( + + {fixedLines} + + ); +}); diff --git a/src/components/post/index.tsx b/src/components/post/index.tsx index cd9e7e81b..a0824aa14 100644 --- a/src/components/post/index.tsx +++ b/src/components/post/index.tsx @@ -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 ( @@ -49,7 +47,7 @@ export const Post = React.memo(({ event }: PostProps) => { { - + diff --git a/src/components/user-avatar-link.tsx b/src/components/user-avatar-link.tsx index 59534adcd..00ccdbf87 100644 --- a/src/components/user-avatar-link.tsx +++ b/src/components/user-avatar-link.tsx @@ -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 ( - + diff --git a/src/helpers/bolt11.ts b/src/helpers/bolt11.ts index 8ac7c2587..db85d309d 100644 --- a/src/helpers/bolt11.ts +++ b/src/helpers/bolt11.ts @@ -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), }; } diff --git a/src/helpers/user-metadata.ts b/src/helpers/user-metadata.ts index a3f634896..c78c81092 100644 --- a/src/helpers/user-metadata.ts +++ b/src/helpers/user-metadata.ts @@ -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); } diff --git a/src/hooks/use-user-contacts.ts b/src/hooks/use-user-contacts.ts new file mode 100644 index 000000000..392c61377 --- /dev/null +++ b/src/hooks/use-user-contacts.ts @@ -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, + }; +} diff --git a/src/types/webln.d.ts b/src/types/webln.d.ts new file mode 100644 index 000000000..a9e4cddbb --- /dev/null +++ b/src/types/webln.d.ts @@ -0,0 +1,11 @@ +import { NostrEvent } from "./nostr-event"; +import { WebLNProvider } from "webln"; + +declare global { + interface Window { + webln?: WebLNProvider & { + enabled?: boolean; + isEnabled?: boolean; + }; + } +} diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx index 4316ee998..e686394de 100644 --- a/src/views/user/index.tsx +++ b/src/views/user/index.tsx @@ -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 (