From c0e3269b7fd42e53f080217d7f12bff8d059bc67 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 4 Oct 2023 08:46:21 -0500 Subject: [PATCH 01/42] Add support for paying zap splits --- .changeset/fair-bears-brake.md | 5 + src/components/event-zap-modal/index.tsx | 205 ++++++++++++++++ src/components/event-zap-modal/input-step.tsx | 119 +++++++++ src/components/event-zap-modal/pay-step.tsx | 149 ++++++++++++ .../event-zap-modal/zap-options.tsx | 27 +++ src/components/invoice-modal.tsx | 95 +++++--- src/components/note/note-zap-button.tsx | 2 +- src/components/zap-modal.tsx | 228 ------------------ src/helpers/lnurl.ts | 15 ++ src/helpers/nostr/zaps.ts | 26 +- .../goals/components/goal-zap-button.tsx | 2 +- .../streams/components/stream-zap-button.tsx | 2 +- src/views/user/components/user-zap-button.tsx | 2 +- 13 files changed, 593 insertions(+), 284 deletions(-) create mode 100644 .changeset/fair-bears-brake.md create mode 100644 src/components/event-zap-modal/index.tsx create mode 100644 src/components/event-zap-modal/input-step.tsx create mode 100644 src/components/event-zap-modal/pay-step.tsx create mode 100644 src/components/event-zap-modal/zap-options.tsx delete mode 100644 src/components/zap-modal.tsx diff --git a/.changeset/fair-bears-brake.md b/.changeset/fair-bears-brake.md new file mode 100644 index 000000000..69b9cf4c0 --- /dev/null +++ b/.changeset/fair-bears-brake.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add support for paying zap splits diff --git a/src/components/event-zap-modal/index.tsx b/src/components/event-zap-modal/index.tsx new file mode 100644 index 000000000..df53895e6 --- /dev/null +++ b/src/components/event-zap-modal/index.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + ModalProps, +} from "@chakra-ui/react"; +import dayjs from "dayjs"; +import { Kind } from "nostr-tools"; + +import { DraftNostrEvent, NostrEvent, isDTag } from "../../types/nostr-event"; +import clientRelaysService from "../../services/client-relays"; +import { getEventRelays } from "../../services/event-relays"; +import { getZapSplits } from "../../helpers/nostr/zaps"; +import { unique } from "../../helpers/array"; +import { RelayMode } from "../../classes/relay"; +import relayScoreboardService from "../../services/relay-scoreboard"; +import { getEventCoordinate, isReplaceable } from "../../helpers/nostr/events"; +import { EmbedProps } from "../embed-event"; +import userRelaysService from "../../services/user-relays"; +import InputStep from "./input-step"; +import lnurlMetadataService from "../../services/lnurl-metadata"; +import userMetadataService from "../../services/user-metadata"; +import signingService from "../../services/signing"; +import accountService from "../../services/account"; +import PayStep from "./pay-step"; +import { getInvoiceFromCallbackUrl } from "../../helpers/lnurl"; + +export type PayRequest = { invoice?: string; pubkey: string; error?: any }; + +async function getPayRequestForPubkey( + pubkey: string, + event: NostrEvent | undefined, + amount: number, + comment?: string, + additionalRelays?: string[], +): Promise { + const metadata = userMetadataService.getSubject(pubkey).value; + const address = metadata?.lud16 || metadata?.lud06; + if (!address) throw new Error("User missing lightning address"); + const lnurlMetadata = await lnurlMetadataService.requestMetadata(address); + + if (!lnurlMetadata) throw new Error("LNURL endpoint unreachable"); + + if (amount > lnurlMetadata.maxSendable) throw new Error("Amount to large"); + if (amount < lnurlMetadata.minSendable) throw new Error("Amount to small"); + + const canZap = !!lnurlMetadata.allowsNostr && !!lnurlMetadata.nostrPubkey; + if (!canZap) { + // build LNURL callback url + const callback = new URL(lnurlMetadata.callback); + callback.searchParams.append("amount", String(amount)); + if (comment) callback.searchParams.append("comment", comment); + + const invoice = await getInvoiceFromCallbackUrl(callback); + + return { invoice, pubkey }; + } + + const userInbox = relayScoreboardService + .getRankedRelays( + userRelaysService + .getRelays(pubkey) + .value?.relays.filter((r) => r.mode & RelayMode.READ) + .map((r) => r.url) ?? [], + ) + .slice(0, 4); + const eventRelays = event ? relayScoreboardService.getRankedRelays(getEventRelays(event.id).value).slice(0, 4) : []; + const outbox = relayScoreboardService.getRankedRelays(clientRelaysService.getWriteUrls()).slice(0, 4); + const additional = relayScoreboardService.getRankedRelays(additionalRelays); + + // create zap request + const zapRequest: DraftNostrEvent = { + kind: Kind.ZapRequest, + created_at: dayjs().unix(), + content: comment ?? "", + tags: [ + ["p", pubkey], + ["relays", ...unique([...userInbox, ...eventRelays, ...outbox, ...additional])], + ["amount", String(amount)], + ], + }; + + // attach "e" or "a" tag + if (event) { + if (isReplaceable(event.kind) && event.tags.some(isDTag)) { + zapRequest.tags.push(["a", getEventCoordinate(event)]); + } else zapRequest.tags.push(["e", event.id]); + } + + // TODO: move this out to a separate step so the user can choose when to sign + const account = accountService.current.value; + if (!account) throw new Error("No Account"); + const signed = await signingService.requestSignature(zapRequest, account); + + // build LNURL callback url + const callback = new URL(lnurlMetadata.callback); + callback.searchParams.append("amount", String(amount)); + callback.searchParams.append("nostr", JSON.stringify(signed)); + + const invoice = await getInvoiceFromCallbackUrl(callback); + + return { invoice, pubkey }; +} + +async function getPayRequestsForEvent( + event: NostrEvent, + amount: number, + comment?: string, + additionalRelays?: string[], +) { + const splits = getZapSplits(event); + + const draftZapRequests: PayRequest[] = []; + for (const { pubkey, percent } of splits) { + try { + // NOTE: round to the nearest sat since there isn't support for msats yet + const splitAmount = Math.round((amount / 1000) * percent) * 1000; + draftZapRequests.push(await getPayRequestForPubkey(pubkey, event, splitAmount, comment, additionalRelays)); + } catch (e) { + draftZapRequests.push({ error: e, pubkey }); + } + } + + return draftZapRequests; +} + +export type ZapModalProps = Omit & { + pubkey: string; + event?: NostrEvent; + relays?: string[]; + initialComment?: string; + initialAmount?: number; + onInvoice: (invoice: string) => void; + allowComment?: boolean; + showEmbed?: boolean; + embedProps?: EmbedProps; + additionalRelays?: string[]; +}; + +export default function ZapModal({ + event, + pubkey, + relays, + onClose, + initialComment, + initialAmount, + onInvoice, + allowComment = true, + showEmbed = true, + embedProps, + additionalRelays = [], + ...props +}: ZapModalProps) { + const [callbacks, setCallbacks] = useState(); + + const renderContent = () => { + if (callbacks && callbacks.length > 0) { + return ; + } else { + return ( + { + const amountInMSats = values.amount * 1000; + if (event) { + setCallbacks(await getPayRequestsForEvent(event, amountInMSats, values.comment, additionalRelays)); + } else { + const callback = await getPayRequestForPubkey( + pubkey, + event, + amountInMSats, + values.comment, + additionalRelays, + ); + setCallbacks([callback]); + } + }} + /> + ); + } + }; + + return ( + + + + + + Zap Event + + {renderContent()} + + + ); +} diff --git a/src/components/event-zap-modal/input-step.tsx b/src/components/event-zap-modal/input-step.tsx new file mode 100644 index 000000000..cb94b35af --- /dev/null +++ b/src/components/event-zap-modal/input-step.tsx @@ -0,0 +1,119 @@ +import { Box, Button, Flex, Input, Text } from "@chakra-ui/react"; +import { useForm } from "react-hook-form"; + +import { NostrEvent } from "../../types/nostr-event"; +import { readablizeSats } from "../../helpers/bolt11"; +import { LightningIcon } from "../icons"; +import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata"; +import { getZapSplits } from "../../helpers/nostr/zaps"; +import { EmbedEvent, EmbedProps } from "../embed-event"; +import useAppSettings from "../../hooks/use-app-settings"; +import CustomZapAmountOptions from "./zap-options"; +import { UserAvatar } from "../user-avatar"; +import { UserLink } from "../user-link"; + +function UserCard({ pubkey, percent }: { pubkey: string; percent?: number }) { + const { address } = useUserLNURLMetadata(pubkey); + + return ( + + + + + {address} + + {percent && ( + + {Math.round(percent * 10000) / 100}% + + )} + + ); +} + +export type InputStepProps = { + pubkey: string; + event?: NostrEvent; + initialComment?: string; + initialAmount?: number; + allowComment?: boolean; + showEmbed?: boolean; + embedProps?: EmbedProps; + onSubmit: (values: { amount: number; comment: string }) => void; +}; + +export default function InputStep({ + event, + pubkey, + initialComment, + initialAmount, + allowComment = true, + showEmbed = true, + embedProps, + onSubmit, +}: InputStepProps) { + const { customZapAmounts } = useAppSettings(); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors, isSubmitting }, + } = useForm<{ + amount: number; + comment: string; + }>({ + mode: "onBlur", + defaultValues: { + amount: initialAmount ?? (parseInt(customZapAmounts.split(",")[0]) || 100), + comment: initialComment ?? "", + }, + }); + + const splits = event ? getZapSplits(event) : []; + + const { metadata: lnurlMetadata } = useUserLNURLMetadata(pubkey); + const canZap = lnurlMetadata?.allowsNostr && lnurlMetadata?.nostrPubkey; + + const showComment = allowComment && (splits.length > 0 || canZap || lnurlMetadata?.commentAllowed); + const actionName = canZap ? "Zap" : "Tip"; + + const onSubmitZap = handleSubmit(onSubmit); + + return ( +
+ + {splits.map((p) => ( + + ))} + + {showEmbed && event && } + + {showComment && ( + + )} + + setValue("amount", amount)} /> + + + + + + +
+ ); +} diff --git a/src/components/event-zap-modal/pay-step.tsx b/src/components/event-zap-modal/pay-step.tsx new file mode 100644 index 000000000..9e39f5ce1 --- /dev/null +++ b/src/components/event-zap-modal/pay-step.tsx @@ -0,0 +1,149 @@ +import { useMount } from "react-use"; +import { Alert, Box, Button, ButtonGroup, Flex, IconButton, Spacer, useDisclosure, useToast } from "@chakra-ui/react"; + +import { PayRequest } from "."; +import { UserAvatar } from "../user-avatar"; +import { UserLink } from "../user-link"; +import { ArrowDownSIcon, ArrowUpSIcon, CheckIcon, ErrorIcon, LightningIcon } from "../icons"; +import { InvoiceModalContent } from "../invoice-modal"; +import { PropsWithChildren, useEffect, useState } from "react"; +import appSettings from "../../services/settings/app-settings"; + +function UserCard({ children, pubkey }: PropsWithChildren & { pubkey: string }) { + return ( + + + + + + + {children} + + ); +} +function PayRequestCard({ pubkey, invoice, onPaid }: { pubkey: string; invoice: string; onPaid: () => void }) { + const toast = useToast(); + const showMore = useDisclosure(); + + const payWithWebLn = async () => { + try { + if (window.webln && invoice) { + if (!window.webln.enabled) await window.webln.enable(); + await window.webln.sendPayment(invoice); + onPaid(); + } + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + }; + + return ( + + + + + : } + aria-label="More Options" + onClick={showMore.onToggle} + /> + + + {showMore.isOpen && } + + ); +} +function ErrorCard({ pubkey, error }: { pubkey: string; error: any }) { + const showMore = useDisclosure(); + + return ( + + + + + {showMore.isOpen && {error.message}} + + ); +} + +export default function PayStep({ callbacks, onComplete }: { callbacks: PayRequest[]; onComplete: () => void }) { + const [paid, setPaid] = useState([]); + + const [payingAll, setPayingAll] = useState(false); + const payAllWithWebLN = async () => { + if (!window.webln) return; + + setPayingAll(true); + if (!window.webln.enabled) await window.webln.enable(); + + for (const { invoice, pubkey } of callbacks) { + try { + if (invoice && !paid.includes(pubkey)) { + await window.webln.sendPayment(invoice); + setPaid((a) => a.concat(pubkey)); + } + } catch (e) {} + } + setPayingAll(false); + }; + + useEffect(() => { + if (!callbacks.filter((p) => !!p.invoice).some(({ pubkey }) => !paid.includes(pubkey))) { + onComplete(); + } + }, [paid]); + + // if autoPayWithWebLN is enabled, try to pay all immediately + useMount(() => { + if (appSettings.value.autoPayWithWebLN) { + payAllWithWebLN(); + } + }); + + return ( + + {callbacks.map(({ pubkey, invoice, error }) => { + if (paid.includes(pubkey)) + return ( + + + + ); + if (error) return ; + if (invoice) + return ( + setPaid((a) => a.concat(pubkey))} + /> + ); + return null; + })} + + + ); +} diff --git a/src/components/event-zap-modal/zap-options.tsx b/src/components/event-zap-modal/zap-options.tsx new file mode 100644 index 000000000..38c9cb5c9 --- /dev/null +++ b/src/components/event-zap-modal/zap-options.tsx @@ -0,0 +1,27 @@ +import { Button, Flex } from "@chakra-ui/react"; + +import useAppSettings from "../../hooks/use-app-settings"; +import { LightningIcon } from "../icons"; + +export default function CustomZapAmountOptions({ onSelect }: { onSelect: (value: number) => void }) { + const { customZapAmounts } = useAppSettings(); + + return ( + + {customZapAmounts + .split(",") + .map((v) => parseInt(v)) + .map((amount, i) => ( + + ))} + + ); +} diff --git a/src/components/invoice-modal.tsx b/src/components/invoice-modal.tsx index 317535d54..4a0cc1b7b 100644 --- a/src/components/invoice-modal.tsx +++ b/src/components/invoice-modal.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Button, Flex, @@ -15,18 +16,18 @@ import { ExternalLinkIcon, QrCodeIcon } from "./icons"; import QrCodeSvg from "./qr-code-svg"; import { CopyIconButton } from "./copy-icon-button"; -export default function InvoiceModal({ - invoice, - onClose, - onPaid, - ...props -}: Omit & { invoice: string; onPaid: () => void }) { +type CommonProps = { invoice: string; onPaid: () => void }; + +export function InvoiceModalContent({ invoice, onPaid }: CommonProps) { const toast = useToast(); const showQr = useDisclosure(); + const [payingWebLn, setPayingWebLn] = useState(false); + const [payingApp, setPayingApp] = useState(false); const payWithWebLn = async (invoice: string) => { try { if (window.webln && invoice) { + setPayingWebLn(true); if (!window.webln.enabled) await window.webln.enable(); await window.webln.sendPayment(invoice); @@ -35,15 +36,17 @@ export default function InvoiceModal({ } catch (e) { if (e instanceof Error) toast({ description: e.message, status: "error" }); } + setPayingWebLn(false); }; const payWithApp = async (invoice: string) => { + setPayingApp(true); window.open("lightning:" + invoice); const listener = () => { if (document.visibilityState === "visible") { if (onPaid) onPaid(); - onClose(); document.removeEventListener("visibilitychange", listener); + setPayingApp(false); } }; setTimeout(() => { @@ -51,41 +54,59 @@ export default function InvoiceModal({ }, 1000 * 2); }; + return ( + + {showQr.isOpen && } + + + } + aria-label="Show QrCode" + onClick={showQr.onToggle} + variant="solid" + size="md" + /> + + + + {window.webln && ( + + )} + + + + ); +} + +export default function InvoiceModal({ + invoice, + onClose, + onPaid, + ...props +}: Omit & CommonProps) { return ( - - {showQr.isOpen && } - - - } - aria-label="Show QrCode" - onClick={showQr.onToggle} - variant="solid" - size="md" - /> - - - - {window.webln && ( - - )} - - - + { + if (onPaid) onPaid(); + onClose(); + }} + /> diff --git a/src/components/note/note-zap-button.tsx b/src/components/note/note-zap-button.tsx index fc38014cc..a9912f35c 100644 --- a/src/components/note/note-zap-button.tsx +++ b/src/components/note/note-zap-button.tsx @@ -8,7 +8,7 @@ import clientRelaysService from "../../services/client-relays"; import eventZapsService from "../../services/event-zaps"; import { NostrEvent } from "../../types/nostr-event"; import { LightningIcon } from "../icons"; -import ZapModal from "../zap-modal"; +import ZapModal from "../event-zap-modal"; import { useInvoiceModalContext } from "../../providers/invoice-modal"; import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata"; import { getEventUID } from "../../helpers/nostr/events"; diff --git a/src/components/zap-modal.tsx b/src/components/zap-modal.tsx deleted file mode 100644 index c47ff0619..000000000 --- a/src/components/zap-modal.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { - Box, - Button, - Flex, - Input, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalOverlay, - ModalProps, - Text, - useToast, -} from "@chakra-ui/react"; -import dayjs from "dayjs"; -import { Kind } from "nostr-tools"; -import { useForm } from "react-hook-form"; - -import { DraftNostrEvent, NostrEvent, isDTag } from "../types/nostr-event"; -import { UserAvatar } from "./user-avatar"; -import { UserLink } from "./user-link"; -import { parsePaymentRequest, readablizeSats } from "../helpers/bolt11"; -import { LightningIcon } from "./icons"; -import clientRelaysService from "../services/client-relays"; -import { getEventRelays } from "../services/event-relays"; -import { useSigningContext } from "../providers/signing-provider"; -import appSettings from "../services/settings/app-settings"; -import useSubject from "../hooks/use-subject"; -import useUserLNURLMetadata from "../hooks/use-user-lnurl-metadata"; -import { requestZapInvoice } from "../helpers/nostr/zaps"; -import { unique } from "../helpers/array"; -import { useUserRelays } from "../hooks/use-user-relays"; -import { RelayMode } from "../classes/relay"; -import relayScoreboardService from "../services/relay-scoreboard"; -import { useAdditionalRelayContext } from "../providers/additional-relay-context"; -import { getEventCoordinate, isReplaceable } from "../helpers/nostr/events"; -import { EmbedEvent, EmbedProps } from "./embed-event"; - -type FormValues = { - amount: number; - comment: string; -}; - -export type ZapModalProps = Omit & { - pubkey: string; - event?: NostrEvent; - relays?: string[]; - initialComment?: string; - initialAmount?: number; - onInvoice: (invoice: string) => void; - allowComment?: boolean; - showEmbed?: boolean; - embedProps?: EmbedProps; - additionalRelays?: string[]; -}; - -export default function ZapModal({ - event, - pubkey, - relays, - onClose, - initialComment, - initialAmount, - onInvoice, - allowComment = true, - showEmbed = true, - embedProps, - additionalRelays = [], - ...props -}: ZapModalProps) { - const toast = useToast(); - const contextRelays = useAdditionalRelayContext(); - const { requestSignature } = useSigningContext(); - const { customZapAmounts } = useSubject(appSettings); - const userReadRelays = useUserRelays(pubkey) - .filter((r) => r.mode & RelayMode.READ) - .map((r) => r.url); - - const { - register, - handleSubmit, - watch, - setValue, - formState: { errors, isSubmitting }, - } = useForm({ - mode: "onBlur", - defaultValues: { - amount: initialAmount ?? (parseInt(customZapAmounts.split(",")[0]) || 100), - comment: initialComment ?? "", - }, - }); - - const { metadata: lnurlMetadata, address: tipAddress } = useUserLNURLMetadata(pubkey); - - const canZap = lnurlMetadata?.allowsNostr && lnurlMetadata?.nostrPubkey; - const actionName = canZap ? "Zap" : "Tip"; - - const onSubmitZap = handleSubmit(async (values) => { - try { - if (!tipAddress) throw new Error("No lightning address"); - if (lnurlMetadata) { - const amountInMilisat = values.amount * 1000; - - if (amountInMilisat > lnurlMetadata.maxSendable) throw new Error("amount to large"); - if (amountInMilisat < lnurlMetadata.minSendable) throw new Error("amount to small"); - if (canZap) { - const eventRelays = event ? getEventRelays(event.id).value : []; - const eventRelaysRanked = relayScoreboardService.getRankedRelays(eventRelays).slice(0, 4); - const writeRelays = clientRelaysService.getWriteUrls(); - const writeRelaysRanked = relayScoreboardService.getRankedRelays(writeRelays).slice(0, 4); - const userReadRelaysRanked = relayScoreboardService.getRankedRelays(userReadRelays).slice(0, 4); - const contextRelaysRanked = relayScoreboardService.getRankedRelays(contextRelays).slice(0, 4); - - const zapRequest: DraftNostrEvent = { - kind: Kind.ZapRequest, - created_at: dayjs().unix(), - content: values.comment, - tags: [ - ["p", pubkey], - [ - "relays", - ...unique([ - ...contextRelaysRanked, - ...writeRelaysRanked, - ...userReadRelaysRanked, - ...eventRelaysRanked, - ...additionalRelays, - ]), - ], - ["amount", String(amountInMilisat)], - ], - }; - - if (event) { - if (isReplaceable(event.kind) && event.tags.some(isDTag)) { - zapRequest.tags.push(["a", getEventCoordinate(event)]); - } else zapRequest.tags.push(["e", event.id]); - } - - const signed = await requestSignature(zapRequest); - if (signed) { - const payRequest = await requestZapInvoice(signed, lnurlMetadata.callback); - await onInvoice(payRequest); - } - } else { - const callbackUrl = new URL(lnurlMetadata.callback); - callbackUrl.searchParams.append("amount", String(amountInMilisat)); - if (values.comment) callbackUrl.searchParams.append("comment", values.comment); - - const { pr: payRequest } = await fetch(callbackUrl).then((res) => res.json()); - if (payRequest as string) { - const parsed = parsePaymentRequest(payRequest); - if (parsed.amount !== amountInMilisat) throw new Error("incorrect amount"); - - await onInvoice(payRequest); - } else throw new Error("Failed to get invoice"); - } - } else throw new Error("Failed to get LNURL metadata"); - } catch (e) { - if (e instanceof Error) toast({ status: "error", description: e.message }); - } - }); - - return ( - - - - - -
- - - - - - {tipAddress} - - - - {showEmbed && event && } - - {allowComment && (canZap || lnurlMetadata?.commentAllowed) && ( - - )} - - - {customZapAmounts - .split(",") - .map((v) => parseInt(v)) - .map((amount, i) => ( - - ))} - - - - - - - -
-
-
-
- ); -} diff --git a/src/helpers/lnurl.ts b/src/helpers/lnurl.ts index ff4bc9a53..944d9dc8f 100644 --- a/src/helpers/lnurl.ts +++ b/src/helpers/lnurl.ts @@ -1,4 +1,5 @@ import { decodeText } from "./bech32"; +import { parsePaymentRequest } from "./bolt11"; export function isLNURL(lnurl: string) { try { @@ -29,3 +30,17 @@ export function getLudEndpoint(addressOrLNURL: string) { return parseLNURL(addressOrLNURL); } catch (e) {} } + +export async function getInvoiceFromCallbackUrl(callback: URL) { + const amount = callback.searchParams.get("amount"); + if (!amount) throw new Error("Missing amount"); + + const { pr: payRequest } = await fetch(callback).then((res) => res.json()); + + if (payRequest as string) { + const parsed = parsePaymentRequest(payRequest); + if (parsed.amount !== parseInt(amount)) throw new Error("Incorrect amount"); + + return payRequest as string; + } else throw new Error("Failed to get invoice"); +} diff --git a/src/helpers/nostr/zaps.ts b/src/helpers/nostr/zaps.ts index 3aab430ca..36d6baf15 100644 --- a/src/helpers/nostr/zaps.ts +++ b/src/helpers/nostr/zaps.ts @@ -3,7 +3,7 @@ import { isETag, isPTag, NostrEvent } from "../../types/nostr-event"; import { ParsedInvoice, parsePaymentRequest } from "../bolt11"; import { Kind0ParsedContent } from "../user-metadata"; -import { nip57, utils } from "nostr-tools"; +import { utils } from "nostr-tools"; // based on https://github.com/nbd-wtf/nostr-tools/blob/master/nip57.ts export async function getZapEndpoint(metadata: Kind0ParsedContent): Promise { @@ -74,20 +74,16 @@ export function parseZapEvent(event: NostrEvent): ParsedZap { }; } -export async function requestZapInvoice(zapRequest: NostrEvent, lnurl: string) { - const amount = zapRequest.tags.find((t) => t[0] === "amount")?.[1]; - if (!amount) throw new Error("missing amount"); +export type EventSplit = { pubkey: string; percent: number; relay?: string }[]; +export function getZapSplits(event: NostrEvent): EventSplit { + const tags = event.tags.filter((t) => t[0] === "zap" && t[1] && t[3]) as [string, string, string, string][]; - const callbackUrl = new URL(lnurl); - callbackUrl.searchParams.append("amount", amount); - callbackUrl.searchParams.append("nostr", JSON.stringify(zapRequest)); + if (tags.length > 0) { + const targets = tags + .map((t) => ({ pubkey: t[1], relay: t[2], percent: parseFloat(t[3]) })) + .filter((p) => Number.isFinite(p.percent)); - const { pr: payRequest } = await fetch(callbackUrl).then((res) => res.json()); - - if (payRequest as string) { - const parsed = parsePaymentRequest(payRequest); - if (parsed.amount !== parseInt(amount)) throw new Error("incorrect amount"); - - return payRequest as string; - } else throw new Error("Failed to get invoice"); + const total = targets.reduce((v, p) => v + p.percent, 0); + return targets.map((p) => ({ ...p, percent: p.percent / total })); + } else return [{ pubkey: event.pubkey, relay: "", percent: 1 }]; } diff --git a/src/views/goals/components/goal-zap-button.tsx b/src/views/goals/components/goal-zap-button.tsx index a45370597..ee4c9bf5d 100644 --- a/src/views/goals/components/goal-zap-button.tsx +++ b/src/views/goals/components/goal-zap-button.tsx @@ -1,6 +1,6 @@ import { Button, ButtonProps, useDisclosure } from "@chakra-ui/react"; import { NostrEvent } from "../../../types/nostr-event"; -import ZapModal from "../../../components/zap-modal"; +import ZapModal from "../../../components/event-zap-modal"; import eventZapsService from "../../../services/event-zaps"; import { getEventUID } from "../../../helpers/nostr/events"; import { useInvoiceModalContext } from "../../../providers/invoice-modal"; diff --git a/src/views/streams/components/stream-zap-button.tsx b/src/views/streams/components/stream-zap-button.tsx index 394ad7e41..678f133fc 100644 --- a/src/views/streams/components/stream-zap-button.tsx +++ b/src/views/streams/components/stream-zap-button.tsx @@ -3,7 +3,7 @@ import { ParsedStream } from "../../../helpers/nostr/stream"; import { LightningIcon } from "../../../components/icons"; import { useInvoiceModalContext } from "../../../providers/invoice-modal"; import useUserLNURLMetadata from "../../../hooks/use-user-lnurl-metadata"; -import ZapModal from "../../../components/zap-modal"; +import ZapModal from "../../../components/event-zap-modal"; import { useRelaySelectionRelays } from "../../../providers/relay-selection-provider"; import useStreamGoal from "../../../hooks/use-stream-goal"; diff --git a/src/views/user/components/user-zap-button.tsx b/src/views/user/components/user-zap-button.tsx index 65f2f27a1..810a6eac3 100644 --- a/src/views/user/components/user-zap-button.tsx +++ b/src/views/user/components/user-zap-button.tsx @@ -1,7 +1,7 @@ import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react"; import { useUserMetadata } from "../../../hooks/use-user-metadata"; import { LightningIcon } from "../../../components/icons"; -import ZapModal from "../../../components/zap-modal"; +import ZapModal from "../../../components/event-zap-modal"; import { useInvoiceModalContext } from "../../../providers/invoice-modal"; export default function UserZapButton({ pubkey, ...props }: { pubkey: string } & Omit) { From b5d1cbd0415feda2251077d4ee6d5351b8c3f275 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 4 Oct 2023 09:58:41 -0500 Subject: [PATCH 02/42] fix zapping streams --- src/components/event-zap-modal/index.tsx | 13 ++++++++----- src/components/event-zap-modal/input-step.tsx | 2 +- src/components/note/note-zap-button.tsx | 11 ++++------- src/helpers/nostr/zaps.ts | 4 ++-- src/views/goals/components/goal-zap-button.tsx | 7 ++----- src/views/streams/components/stream-zap-button.tsx | 5 +---- src/views/user/components/user-zap-button.tsx | 3 +-- 7 files changed, 19 insertions(+), 26 deletions(-) diff --git a/src/components/event-zap-modal/index.tsx b/src/components/event-zap-modal/index.tsx index df53895e6..c4c200bde 100644 --- a/src/components/event-zap-modal/index.tsx +++ b/src/components/event-zap-modal/index.tsx @@ -110,9 +110,10 @@ async function getPayRequestsForEvent( event: NostrEvent, amount: number, comment?: string, + fallbackPubkey?: string, additionalRelays?: string[], ) { - const splits = getZapSplits(event); + const splits = getZapSplits(event, fallbackPubkey); const draftZapRequests: PayRequest[] = []; for (const { pubkey, percent } of splits) { @@ -134,11 +135,11 @@ export type ZapModalProps = Omit & { relays?: string[]; initialComment?: string; initialAmount?: number; - onInvoice: (invoice: string) => void; allowComment?: boolean; showEmbed?: boolean; embedProps?: EmbedProps; additionalRelays?: string[]; + onZapped: () => void; }; export default function ZapModal({ @@ -148,18 +149,18 @@ export default function ZapModal({ onClose, initialComment, initialAmount, - onInvoice, allowComment = true, showEmbed = true, embedProps, additionalRelays = [], + onZapped, ...props }: ZapModalProps) { const [callbacks, setCallbacks] = useState(); const renderContent = () => { if (callbacks && callbacks.length > 0) { - return ; + return ; } else { return ( { const amountInMSats = values.amount * 1000; if (event) { - setCallbacks(await getPayRequestsForEvent(event, amountInMSats, values.comment, additionalRelays)); + setCallbacks( + await getPayRequestsForEvent(event, amountInMSats, values.comment, pubkey, additionalRelays), + ); } else { const callback = await getPayRequestForPubkey( pubkey, diff --git a/src/components/event-zap-modal/input-step.tsx b/src/components/event-zap-modal/input-step.tsx index cb94b35af..45c402c2a 100644 --- a/src/components/event-zap-modal/input-step.tsx +++ b/src/components/event-zap-modal/input-step.tsx @@ -71,7 +71,7 @@ export default function InputStep({ }, }); - const splits = event ? getZapSplits(event) : []; + const splits = event ? getZapSplits(event, pubkey) : []; const { metadata: lnurlMetadata } = useUserLNURLMetadata(pubkey); const canZap = lnurlMetadata?.allowsNostr && lnurlMetadata?.nostrPubkey; diff --git a/src/components/note/note-zap-button.tsx b/src/components/note/note-zap-button.tsx index a9912f35c..5f7148355 100644 --- a/src/components/note/note-zap-button.tsx +++ b/src/components/note/note-zap-button.tsx @@ -9,7 +9,6 @@ import eventZapsService from "../../services/event-zaps"; import { NostrEvent } from "../../types/nostr-event"; import { LightningIcon } from "../icons"; import ZapModal from "../event-zap-modal"; -import { useInvoiceModalContext } from "../../providers/invoice-modal"; import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata"; import { getEventUID } from "../../helpers/nostr/events"; @@ -22,15 +21,13 @@ export type NoteZapButtonProps = Omit & { export default function NoteZapButton({ event, allowComment, showEventPreview, ...props }: NoteZapButtonProps) { const account = useCurrentAccount(); const { metadata } = useUserLNURLMetadata(event.pubkey); - const { requestPay } = useInvoiceModalContext(); const zaps = useEventZaps(event.id); const { isOpen, onOpen, onClose } = useDisclosure(); const hasZapped = !!account && zaps.some((zap) => zap.request.pubkey === account.pubkey); - const handleInvoice = async (invoice: string) => { + const onZapped = () => { onClose(); - await requestPay(invoice); eventZapsService.requestZaps(getEventUID(event), clientRelaysService.getReadUrls(), true); }; @@ -64,10 +61,10 @@ export default function NoteZapButton({ event, allowComment, showEventPreview, . {isOpen && ( diff --git a/src/helpers/nostr/zaps.ts b/src/helpers/nostr/zaps.ts index 36d6baf15..96ba7861d 100644 --- a/src/helpers/nostr/zaps.ts +++ b/src/helpers/nostr/zaps.ts @@ -75,7 +75,7 @@ export function parseZapEvent(event: NostrEvent): ParsedZap { } export type EventSplit = { pubkey: string; percent: number; relay?: string }[]; -export function getZapSplits(event: NostrEvent): EventSplit { +export function getZapSplits(event: NostrEvent, fallbackPubkey?: string): EventSplit { const tags = event.tags.filter((t) => t[0] === "zap" && t[1] && t[3]) as [string, string, string, string][]; if (tags.length > 0) { @@ -85,5 +85,5 @@ export function getZapSplits(event: NostrEvent): EventSplit { const total = targets.reduce((v, p) => v + p.percent, 0); return targets.map((p) => ({ ...p, percent: p.percent / total })); - } else return [{ pubkey: event.pubkey, relay: "", percent: 1 }]; + } else return [{ pubkey: fallbackPubkey || event.pubkey, relay: "", percent: 1 }]; } diff --git a/src/views/goals/components/goal-zap-button.tsx b/src/views/goals/components/goal-zap-button.tsx index ee4c9bf5d..1856ae3e8 100644 --- a/src/views/goals/components/goal-zap-button.tsx +++ b/src/views/goals/components/goal-zap-button.tsx @@ -3,7 +3,6 @@ import { NostrEvent } from "../../../types/nostr-event"; import ZapModal from "../../../components/event-zap-modal"; import eventZapsService from "../../../services/event-zaps"; import { getEventUID } from "../../../helpers/nostr/events"; -import { useInvoiceModalContext } from "../../../providers/invoice-modal"; import { getGoalRelays } from "../../../helpers/nostr/goal"; import { useReadRelayUrls } from "../../../hooks/use-client-relays"; @@ -12,12 +11,10 @@ export default function GoalZapButton({ ...props }: Omit & { goal: NostrEvent }) { const modal = useDisclosure(); - const { requestPay } = useInvoiceModalContext(); const readRelays = useReadRelayUrls(getGoalRelays(goal)); - const handleInvoice = async (invoice: string) => { + const onZapped = async () => { modal.onClose(); - await requestPay(invoice); setTimeout(() => { eventZapsService.requestZaps(getEventUID(goal), readRelays, true); }, 1000); @@ -33,7 +30,7 @@ export default function GoalZapButton({ isOpen onClose={modal.onClose} event={goal} - onInvoice={handleInvoice} + onZapped={onZapped} pubkey={goal.pubkey} relays={getGoalRelays(goal)} allowComment diff --git a/src/views/streams/components/stream-zap-button.tsx b/src/views/streams/components/stream-zap-button.tsx index 678f133fc..1a7c57f9f 100644 --- a/src/views/streams/components/stream-zap-button.tsx +++ b/src/views/streams/components/stream-zap-button.tsx @@ -1,7 +1,6 @@ import { Button, IconButton, useDisclosure } from "@chakra-ui/react"; import { ParsedStream } from "../../../helpers/nostr/stream"; import { LightningIcon } from "../../../components/icons"; -import { useInvoiceModalContext } from "../../../providers/invoice-modal"; import useUserLNURLMetadata from "../../../hooks/use-user-lnurl-metadata"; import ZapModal from "../../../components/event-zap-modal"; import { useRelaySelectionRelays } from "../../../providers/relay-selection-provider"; @@ -19,7 +18,6 @@ export default function StreamZapButton({ label?: string; }) { const zapModal = useDisclosure(); - const { requestPay } = useInvoiceModalContext(); const zapMetadata = useUserLNURLMetadata(stream.host); const relays = useRelaySelectionRelays(); const goal = useStreamGoal(stream); @@ -50,10 +48,9 @@ export default function StreamZapButton({ isOpen event={zapEvent} pubkey={stream.host} - onInvoice={async (invoice) => { + onZapped={async () => { if (onZap) onZap(); zapModal.onClose(); - await requestPay(invoice); }} onClose={zapModal.onClose} initialComment={initComment} diff --git a/src/views/user/components/user-zap-button.tsx b/src/views/user/components/user-zap-button.tsx index 810a6eac3..28ac4b9b1 100644 --- a/src/views/user/components/user-zap-button.tsx +++ b/src/views/user/components/user-zap-button.tsx @@ -30,8 +30,7 @@ export default function UserZapButton({ pubkey, ...props }: { pubkey: string } & isOpen={isOpen} onClose={onClose} pubkey={pubkey} - onInvoice={async (invoice) => { - await requestPay(invoice); + onZapped={async () => { onClose(); }} /> From 4a7c00b0d0d61341946786ffbde0b5cda31e2663 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 4 Oct 2023 10:33:19 -0500 Subject: [PATCH 03/42] add error boundary to community home --- src/views/communities/components/community-card.tsx | 2 +- src/views/communities/index.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/views/communities/components/community-card.tsx b/src/views/communities/components/community-card.tsx index 9cba7d979..c9ed447a8 100644 --- a/src/views/communities/components/community-card.tsx +++ b/src/views/communities/components/community-card.tsx @@ -1,4 +1,5 @@ import { memo, useRef } from "react"; +import { nip19 } from "nostr-tools"; import { Link as RouterLink } from "react-router-dom"; import { Box, Card, CardProps, Center, Flex, Heading, LinkBox, LinkOverlay, Text } from "@chakra-ui/react"; @@ -9,7 +10,6 @@ import { useRegisterIntersectionEntity } from "../../../providers/intersection-o import { getEventUID } from "../../../helpers/nostr/events"; import { getCommunityImage, getCommunityName } from "../../../helpers/nostr/communities"; import CommunityDescription from "./community-description"; -import { nip19 } from "nostr-tools"; import CommunityModList from "./community-mod-list"; function CommunityCard({ community, ...props }: Omit & { community: NostrEvent }) { diff --git a/src/views/communities/index.tsx b/src/views/communities/index.tsx index 9db7f4a4b..6fa4fe937 100644 --- a/src/views/communities/index.tsx +++ b/src/views/communities/index.tsx @@ -15,6 +15,7 @@ import RelaySelectionProvider, { useRelaySelectionContext } from "../../provider import { COMMUNITY_DEFINITION_KIND, validateCommunity } from "../../helpers/nostr/communities"; import { NostrEvent } from "../../types/nostr-event"; import { NostrQuery } from "../../types/nostr-query"; +import { ErrorBoundary } from "../../components/error-boundary"; function CommunitiesHomePage() { const { filter, listId } = usePeopleListContext(); @@ -47,7 +48,9 @@ function CommunitiesHomePage() { {communities.map((event) => ( - + + + ))} From 2a17d9ecf4bc7a6cd8c36c763fd0a65473ebe3dc Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 4 Oct 2023 11:28:23 -0500 Subject: [PATCH 04/42] Show articles in lists --- .changeset/twelve-chefs-kiss.md | 5 + .../embed-event/event-types/embedded-list.tsx | 23 +-- src/views/lists/components/list-card.tsx | 140 +++++++++++------- src/views/lists/list-details.tsx | 31 ++-- 4 files changed, 120 insertions(+), 79 deletions(-) create mode 100644 .changeset/twelve-chefs-kiss.md diff --git a/.changeset/twelve-chefs-kiss.md b/.changeset/twelve-chefs-kiss.md new file mode 100644 index 000000000..06bd2a89b --- /dev/null +++ b/.changeset/twelve-chefs-kiss.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Show articles in lists diff --git a/src/components/embed-event/event-types/embedded-list.tsx b/src/components/embed-event/event-types/embedded-list.tsx index 57bb1df2a..b69426c99 100644 --- a/src/components/embed-event/event-types/embedded-list.tsx +++ b/src/components/embed-event/event-types/embedded-list.tsx @@ -1,18 +1,16 @@ -import { AvatarGroup, Card, CardBody, CardHeader, CardProps, Flex, Heading, Link, Text } from "@chakra-ui/react"; +import { Card, CardBody, CardHeader, CardProps, Flex, Heading, Link, Text } from "@chakra-ui/react"; import { Link as RouterLink } from "react-router-dom"; import { NostrEvent } from "../../../types/nostr-event"; -import { getEventsFromList, getListName, getPubkeysFromList, isSpecialListKind } from "../../../helpers/nostr/lists"; +import { getListName, isSpecialListKind } from "../../../helpers/nostr/lists"; import { createCoordinate } from "../../../services/replaceable-event-requester"; import { getSharableEventAddress } from "../../../helpers/nip19"; import { UserAvatarLink } from "../../user-avatar-link"; import { UserLink } from "../../user-link"; -import { NoteLink } from "../../note-link"; import ListFeedButton from "../../../views/lists/components/list-feed-button"; +import { ListCardContent } from "../../../views/lists/components/list-card"; export default function EmbeddedList({ list: list, ...props }: Omit & { list: NostrEvent }) { - const people = getPubkeysFromList(list); - const notes = getEventsFromList(list); const link = isSpecialListKind(list.kind) ? createCoordinate(list.kind, list.pubkey) : getSharableEventAddress(list); return ( @@ -31,20 +29,7 @@ export default function EmbeddedList({ list: list, ...props }: Omit - {people.length > 0 && ( - - {people.map(({ pubkey, relay }) => ( - - ))} - - )} - {notes.length > 0 && ( - - {notes.map(({ id, relay }) => ( - - ))} - - )} + ); diff --git a/src/views/lists/components/list-card.tsx b/src/views/lists/components/list-card.tsx index 671c24f45..625de5d70 100644 --- a/src/views/lists/components/list-card.tsx +++ b/src/views/lists/components/list-card.tsx @@ -11,9 +11,10 @@ import { Flex, Heading, Link, + LinkProps, Text, } from "@chakra-ui/react"; -import { nip19 } from "nostr-tools"; +import { Kind, nip19 } from "nostr-tools"; import { UserAvatarLink } from "../../../components/user-avatar-link"; import { UserLink } from "../../../components/user-link"; @@ -36,13 +37,99 @@ import { getEventUID } from "../../../helpers/nostr/events"; import ListMenu from "./list-menu"; import Timestamp from "../../../components/timestamp"; import { COMMUNITY_DEFINITION_KIND } from "../../../helpers/nostr/communities"; +import { getArticleTitle } from "../../../helpers/nostr/long-form"; +import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; -function ListCardRender({ list, ...props }: Omit & { list: NostrEvent }) { +function ArticleLinkLoader({ pointer, ...props }: { pointer: nip19.AddressPointer } & Omit) { + const article = useReplaceableEvent(pointer); + if (article) return ; + return null; +} +function ArticleLink({ article, ...props }: { article: NostrEvent } & Omit) { + const title = getArticleTitle(article); + const naddr = getSharableEventAddress(article); + + return ( + + {title} + + ); +} + +export function ListCardContent({ list, ...props }: Omit & { list: NostrEvent }) { const people = getPubkeysFromList(list); const notes = getEventsFromList(list); const coordinates = getParsedCordsFromList(list); const communities = coordinates.filter((cord) => cord.kind === COMMUNITY_DEFINITION_KIND); + const articles = coordinates.filter((cord) => cord.kind === Kind.Article); const references = getReferencesFromList(list); + + return ( + <> + {people.length > 0 && ( + <> + People ({people.length}): + + {people.map(({ pubkey, relay }) => ( + + ))} + + + )} + {notes.length > 0 && ( + <> + Notes ({notes.length}): + + {notes.slice(0, 4).map(({ id, relay }) => ( + + ))} + + + )} + {references.length > 0 && ( + <> + References ({references.length}) + + {references.slice(0, 3).map(({ url, petname }) => ( + + {petname || url} + + ))} + + + )} + {communities.length > 0 && ( + <> + Communities ({communities.length}): + + {communities.map((pointer) => ( + + {pointer.identifier} + + ))} + + + )} + {articles.length > 0 && ( + <> + Articles ({articles.length}): + + {articles.slice(0, 4).map((pointer) => ( + + ))} + + + )} + + ); +} + +function ListCardRender({ list, ...props }: Omit & { list: NostrEvent }) { const link = isSpecialListKind(list.kind) ? createCoordinate(list.kind, list.pubkey) : getSharableEventAddress(list); // if there is a parent intersection observer, register this card @@ -62,54 +149,7 @@ function ListCardRender({ list, ...props }: Omit & { list - {people.length > 0 && ( - <> - People ({people.length}): - - {people.map(({ pubkey, relay }) => ( - - ))} - - - )} - {notes.length > 0 && ( - <> - Notes ({notes.length}): - - {notes.slice(0, 4).map(({ id, relay }) => ( - - ))} - - - )} - {references.length > 0 && ( - <> - References ({references.length}) - - {references.slice(0, 3).map(({ url, petname }) => ( - - {petname || url} - - ))} - - - )} - {communities.length > 0 && ( - <> - Communities ({communities.length}): - - {communities.map((pointer) => ( - - {pointer.identifier} - - ))} - - - )} + Created by: diff --git a/src/views/lists/list-details.tsx b/src/views/lists/list-details.tsx index c2ebe73e4..ffff03761 100644 --- a/src/views/lists/list-details.tsx +++ b/src/views/lists/list-details.tsx @@ -1,8 +1,8 @@ import { useNavigate, useParams } from "react-router-dom"; -import { nip19 } from "nostr-tools"; +import { Kind, nip19 } from "nostr-tools"; import { UserLink } from "../../components/user-link"; -import { Button, Divider, Flex, Heading, SimpleGrid, Spacer } from "@chakra-ui/react"; +import { Button, Flex, Heading, SimpleGrid, Spacer } from "@chakra-ui/react"; import { ArrowLeftSIcon } from "../../components/icons"; import { useCurrentAccount } from "../../hooks/use-current-account"; import { useDeleteEventContext } from "../../providers/delete-event-provider"; @@ -26,6 +26,8 @@ import ListFeedButton from "./components/list-feed-button"; import VerticalPageLayout from "../../components/vertical-page-layout"; import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities"; import { EmbedEventPointer } from "../../components/embed-event"; +import { encodePointer } from "../../helpers/nip19"; +import { DecodeResult } from "nostr-tools/lib/nip19"; function useListCoordinate() { const { addr } = useParams() as { addr: string }; @@ -61,6 +63,7 @@ export default function ListDetailsView() { const notes = getEventsFromList(list); const coordinates = getParsedCordsFromList(list); const communities = coordinates.filter((cord) => cord.kind === COMMUNITY_DEFINITION_KIND); + const articles = coordinates.filter((cord) => cord.kind === Kind.Article); const references = getReferencesFromList(list); return ( @@ -88,8 +91,7 @@ export default function ListDetailsView() { {people.length > 0 && ( <> - People - + People {people.map(({ pubkey, relay }) => ( @@ -100,8 +102,7 @@ export default function ListDetailsView() { {notes.length > 0 && ( <> - Notes - + Notes {notes.map(({ id, relay }) => ( @@ -114,8 +115,7 @@ export default function ListDetailsView() { {references.length > 0 && ( <> - References - + References {references.map(({ url, petname }) => ( @@ -131,8 +131,7 @@ export default function ListDetailsView() { {communities.length > 0 && ( <> - Communities - + Communities {communities.map((pointer) => ( @@ -140,6 +139,18 @@ export default function ListDetailsView() { )} + + {articles.length > 0 && ( + <> + Articles + + {articles.map((pointer) => { + const decode: DecodeResult = { type: "naddr", data: pointer }; + return ; + })} + + + )} ); } From 997fc0a19a8784fd21e1fa1dde2863dd6986b64d Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 4 Oct 2023 12:26:38 -0500 Subject: [PATCH 05/42] add version button to settings view --- .github/workflows/docker-image.yml | 6 ++++++ .github/workflows/pages.yml | 4 ++++ dockerfile | 2 ++ package.json | 2 +- src/components/version-button.tsx | 28 ++++++++++++++++++++++++++++ src/views/settings/index.tsx | 2 ++ 6 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/components/version-button.tsx diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index c2d55b7d0..99abfc239 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -44,6 +44,12 @@ jobs: type=ref,event=branch type=ref,event=pr type=sha + + - name: Set build env + run: | + echo "VITE_COMMIT_HASH=$(echo $GITHUB_SHA | cut -c 1-7)" >> $GITHUB_ENV + echo "VITE_APP_VERSION=$(jq -r .version ./package.json)" >> $GITHUB_ENV + - name: Build and push Docker image uses: docker/build-push-action@v5 with: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 18cabc550..65c3a7593 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -37,6 +37,10 @@ jobs: cache: "npm" - name: Install dependencies run: yarn install + - name: Set build env + run: | + echo "VITE_COMMIT_HASH=$(echo $GITHUB_SHA | cut -c 1-7)" >> $GITHUB_ENV + echo "VITE_APP_VERSION=$(jq -r .version ./package.json)" >> $GITHUB_ENV - name: Build run: yarn build - name: Setup Pages diff --git a/dockerfile b/dockerfile index a7ddb193c..9e8fd620f 100644 --- a/dockerfile +++ b/dockerfile @@ -2,6 +2,8 @@ FROM node:18 WORKDIR /app COPY . /app/ +ENV VITE_COMMIT_HASH="" +ENV VITE_APP_VERSION="custom" RUN yarn install && yarn build FROM nginx:stable-alpine-slim diff --git a/package.json b/package.json index ae307de18..051838a6b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "MIT", "scripts": { "start": "vite serve", - "dev": "vite serve", + "dev": "VITE_APP_VERSION=development vite serve", "build": "tsc --project tsconfig.json && vite build", "format": "prettier --ignore-path .prettierignore -w .", "e2e": "cypress open", diff --git a/src/components/version-button.tsx b/src/components/version-button.tsx new file mode 100644 index 000000000..bcf0ef3ad --- /dev/null +++ b/src/components/version-button.tsx @@ -0,0 +1,28 @@ +import { Button, ButtonProps } from "@chakra-ui/react"; +import { CheckIcon, ClipboardIcon } from "./icons"; +import { useState } from "react"; + +export default function VersionButton({ ...props }: Omit) { + const [copied, setCopied] = useState(false); + const version = [import.meta.env.VITE_APP_VERSION, import.meta.env.VITE_COMMIT_HASH].filter(Boolean).join("-"); + + if (!version) return null; + + return ( + + ); +} diff --git a/src/views/settings/index.tsx b/src/views/settings/index.tsx index 7ac11b7ea..f2f8df4de 100644 --- a/src/views/settings/index.tsx +++ b/src/views/settings/index.tsx @@ -9,6 +9,7 @@ import PrivacySettings from "./privacy-settings"; import useAppSettings from "../../hooks/use-app-settings"; import { FormProvider, useForm } from "react-hook-form"; import VerticalPageLayout from "../../components/vertical-page-layout"; +import VersionButton from "../../components/version-button"; export default function SettingsView() { const toast = useToast(); @@ -46,6 +47,7 @@ export default function SettingsView() { Github + {mentions.length > 0 && } - + +