From 694e261e85b08753d0c8638e7bea98c082589768 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Sun, 1 Dec 2024 12:04:05 -0600 Subject: [PATCH] Add top zappers support page --- .changeset/tender-brooms-nail.md | 5 + package.json | 2 + pnpm-lock.yaml | 16 +++ src/app.tsx | 5 + src/components/event-zap-modal/index.tsx | 18 ++- src/components/event-zap-modal/pay-step.tsx | 20 ++- src/components/invoice-modal.tsx | 4 +- src/components/layout/nav-items.tsx | 42 ++---- src/components/post-modal/index.tsx | 2 - .../post-modal/insert-image-button.tsx | 12 +- src/components/qr-code/qr-code-svg.tsx | 10 +- src/const.ts | 2 + .../support/components/not-quite-top-zap.tsx | 54 +++++++ src/views/support/components/other-zap.tsx | 52 +++++++ src/views/support/components/support-form.tsx | 106 ++++++++++++++ src/views/support/components/top-zap.tsx | 52 +++++++ src/views/support/index.tsx | 136 ++++++++++++++++++ src/views/user/about/user-recent-events.tsx | 3 + 18 files changed, 490 insertions(+), 51 deletions(-) create mode 100644 .changeset/tender-brooms-nail.md create mode 100644 src/views/support/components/not-quite-top-zap.tsx create mode 100644 src/views/support/components/other-zap.tsx create mode 100644 src/views/support/components/support-form.tsx create mode 100644 src/views/support/components/top-zap.tsx create mode 100644 src/views/support/index.tsx diff --git a/.changeset/tender-brooms-nail.md b/.changeset/tender-brooms-nail.md new file mode 100644 index 000000000..f66bf4559 --- /dev/null +++ b/.changeset/tender-brooms-nail.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add top zappers support page diff --git a/package.json b/package.json index 67987e225..25ed6d01c 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "blossom-client-sdk": "next", "blossom-drive-sdk": "^0.4.1", "blurhash": "^2.0.5", + "canvas-confetti": "^1.9.3", "chart.js": "^4.4.6", "cheerio": "^1.0.0", "chroma-js": "^2.6.0", @@ -120,6 +121,7 @@ }, "devDependencies": { "@changesets/cli": "^2.27.10", + "@types/canvas-confetti": "^1.6.4", "@types/chroma-js": "^2.4.4", "@types/debug": "^4.1.12", "@types/dom-serial": "^1.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a57b2d103..c0566298f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: blurhash: specifier: ^2.0.5 version: 2.0.5 + canvas-confetti: + specifier: ^1.9.3 + version: 1.9.3 chart.js: specifier: ^4.4.6 version: 4.4.6 @@ -319,6 +322,9 @@ importers: '@changesets/cli': specifier: ^2.27.10 version: 2.27.10 + '@types/canvas-confetti': + specifier: ^1.6.4 + version: 1.6.4 '@types/chroma-js': specifier: ^2.4.4 version: 2.4.4 @@ -1671,6 +1677,9 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/canvas-confetti@1.6.4': + resolution: {integrity: sha512-fNyZ/Fdw/Y92X0vv7B+BD6ysHL4xVU5dJcgzgxLdGbn8O3PezZNIJpml44lKM0nsGur+o/6+NZbZeNTt00U1uA==} + '@types/chroma-js@2.4.4': resolution: {integrity: sha512-/DTccpHTaKomqussrn+ciEvfW4k6NAHzNzs/sts1TCqg333qNxOhy8TNIoQCmbGG3Tl8KdEhkGAssb1n3mTXiQ==} @@ -2045,6 +2054,9 @@ packages: resolution: {integrity: sha512-eNycxGS7oQ3IS/9QQY41f/aQjiO9Y/MtedhCgSdsbLSxC9EyUD8L3ehl/Q3Kfmvt8um79S45PBV+5Rxm5ztdSw==} engines: {node: '>=12'} + canvas-confetti@1.9.3: + resolution: {integrity: sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6100,6 +6112,8 @@ snapshots: dependencies: '@babel/types': 7.26.0 + '@types/canvas-confetti@1.6.4': {} + '@types/chroma-js@2.4.4': {} '@types/chrome@0.0.74': @@ -6578,6 +6592,8 @@ snapshots: dependencies: tinycolor2: 1.6.0 + canvas-confetti@1.9.3: {} + ccount@2.0.1: {} chalk@4.1.2: diff --git a/src/app.tsx b/src/app.tsx index 5e0ca33a5..565810d03 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -105,6 +105,7 @@ import AccountSettings from "./views/settings/accounts"; import ArticlesHomeView from "./views/articles"; import ArticleView from "./views/articles/article"; import WalletView from "./views/wallet"; +import SupportView from "./views/support"; const TracksView = lazy(() => import("./views/tracks")); const UserTracksTab = lazy(() => import("./views/user/tracks")); const UserVideosTab = lazy(() => import("./views/user/videos")); @@ -501,6 +502,10 @@ const router = createHashRouter([ path: "streams", element: , }, + { + path: "support", + children: [{ path: "", element: }], + }, { path: "tracks", element: , diff --git a/src/components/event-zap-modal/index.tsx b/src/components/event-zap-modal/index.tsx index 306f9af9d..3d8247231 100644 --- a/src/components/event-zap-modal/index.tsx +++ b/src/components/event-zap-modal/index.tsx @@ -31,7 +31,17 @@ import { eventStore, queryStore } from "../../services/event-store"; export type PayRequest = { invoice?: string; pubkey: string; error?: any }; // TODO: this is way to complicated, it needs to be broken into multiple parts / hooks -async function getPayRequestForPubkey( + +/** + * + * @param pubkey pubkey to be zapped + * @param event event to be zapped + * @param amount amount in msats + * @param comment zap comment + * @param additionalRelays extra relays to set the zap to + * @returns + */ +export async function getPayRequestForPubkey( pubkey: string, event: NostrEvent | undefined, amount: number, @@ -65,12 +75,12 @@ async function getPayRequestForPubkey( const mailboxes = eventStore.getReplaceable(kinds.RelayList, pubkey); const userInbox = mailboxes ? getInboxes(mailboxes).slice(0, 4) : []; - const eventRelays = event ? getEventRelayHints(event, 4) : []; + const eventRelays = event ? getEventRelayHints(event, 2) : []; const accountMailboxes = account ? eventStore.getReplaceable(kinds.RelayList, account?.pubkey) : undefined; const outbox = relayScoreboardService .getRankedRelays(accountMailboxes ? getOutboxes(accountMailboxes) : []) - .slice(0, 4); - const additional = relayScoreboardService.getRankedRelays(additionalRelays); + .slice(0, 2); + const additional = additionalRelays ? relayScoreboardService.getRankedRelays(additionalRelays) : []; // create zap request const zapRequest: DraftNostrEvent = { diff --git a/src/components/event-zap-modal/pay-step.tsx b/src/components/event-zap-modal/pay-step.tsx index 97a855988..096cbf494 100644 --- a/src/components/event-zap-modal/pay-step.tsx +++ b/src/components/event-zap-modal/pay-step.tsx @@ -1,5 +1,15 @@ import { useMount } from "react-use"; -import { Alert, Button, ButtonGroup, Flex, IconButton, Spacer, useDisclosure, useToast } from "@chakra-ui/react"; +import { + Alert, + Button, + ButtonGroup, + Flex, + FlexProps, + IconButton, + Spacer, + useDisclosure, + useToast, +} from "@chakra-ui/react"; import { PayRequest } from "."; import UserAvatar from "../user/user-avatar"; @@ -77,7 +87,11 @@ function ErrorCard({ pubkey, error }: { pubkey: string; error: any }) { ); } -export default function PayStep({ callbacks, onComplete }: { callbacks: PayRequest[]; onComplete: () => void }) { +export default function PayStep({ + callbacks, + onComplete, + ...props +}: Omit & { callbacks: PayRequest[]; onComplete: () => void }) { const [paid, setPaid] = useState([]); const { autoPayWithWebLN } = useAppSettings(); @@ -115,7 +129,7 @@ export default function PayStep({ callbacks, onComplete }: { callbacks: PayReque }); return ( - + {callbacks.map(({ pubkey, invoice, error }) => { if (paid.includes(pubkey)) return ( diff --git a/src/components/invoice-modal.tsx b/src/components/invoice-modal.tsx index d347a0a2c..6d86c1cdb 100644 --- a/src/components/invoice-modal.tsx +++ b/src/components/invoice-modal.tsx @@ -57,9 +57,9 @@ export function InvoiceModalContent({ invoice, onPaid }: CommonProps) { return ( - {showQr.isOpen && } + {showQr.isOpen && } - + {}} /> } aria-label="Show QrCode" diff --git a/src/components/layout/nav-items.tsx b/src/components/layout/nav-items.tsx index 9d057c746..c5410f53c 100644 --- a/src/components/layout/nav-items.tsx +++ b/src/components/layout/nav-items.tsx @@ -1,8 +1,7 @@ import { useMemo } from "react"; -import { Box, Button, ButtonProps, Icon, Link, Text, others, useDisclosure } from "@chakra-ui/react"; +import { Box, Button, ButtonProps, Text } from "@chakra-ui/react"; import { Link as RouterLink, useLocation } from "react-router-dom"; import { nip19 } from "nostr-tools"; -import dayjs from "dayjs"; import { DirectMessagesIcon, @@ -17,8 +16,6 @@ import { } from "../icons"; import useCurrentAccount from "../../hooks/use-current-account"; import accountService from "../../services/account"; -import { useLocalStorage } from "react-use"; -import ZapModal from "../event-zap-modal"; import PuzzlePiece01 from "../icons/puzzle-piece-01"; import Package from "../icons/package"; import Rocket02 from "../icons/rocket-02"; @@ -27,15 +24,11 @@ import KeyboardShortcut from "../keyboard-shortcut"; import useRecentIds from "../../hooks/use-recent-ids"; import { internalApps, internalTools } from "../../views/other-stuff/apps"; import { App, AppIcon } from "../../views/other-stuff/component/app-card"; -import Wallet02 from "../icons/wallet-02"; export default function NavItems() { const location = useLocation(); const account = useCurrentAccount(); - const donateModal = useDisclosure(); - const [lastDonate, setLastDonate] = useLocalStorage("last-donate"); - const showShortcuts = useBreakpointValue({ base: false, md: true }); const buttonProps: ButtonProps = { @@ -70,6 +63,7 @@ export default function NavItems() { else if (location.pathname.startsWith("/torrents")) active = "tools"; else if (location.pathname.startsWith("/map")) active = "tools"; else if (location.pathname.startsWith("/profile")) active = "profile"; + else if (location.pathname.startsWith("/support")) active = "support"; else if (location.pathname.startsWith("/other-stuff")) active = "other-stuff"; else if ( account && @@ -227,29 +221,15 @@ export default function NavItems() { > Settings - {(lastDonate === undefined || dayjs.unix(lastDonate).isBefore(dayjs().subtract(1, "week"))) && ( - - )} - {donateModal.isOpen && ( - setLastDonate(dayjs().unix())} - /> - )} + {account && ( + + + + + ); +} diff --git a/src/views/support/components/top-zap.tsx b/src/views/support/components/top-zap.tsx new file mode 100644 index 000000000..6d46401e7 --- /dev/null +++ b/src/views/support/components/top-zap.tsx @@ -0,0 +1,52 @@ +import { Card, CardBody, CardHeader, Flex, Spacer, Text } from "@chakra-ui/react"; +import { getZapPayment, getZapRequest, getZapSender } from "applesauce-core/helpers"; +import { NostrEvent } from "nostr-tools"; + +import UserAvatar from "../../../components/user/user-avatar"; +import UserLink from "../../../components/user/user-link"; +import TextNoteContents from "../../../components/note/timeline-note/text-note-contents"; +import { LightningIcon } from "../../../components/icons"; +import Timestamp from "../../../components/timestamp"; +import DebugEventButton from "../../../components/debug-modal/debug-event-button"; +import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref"; +import { TrustProvider } from "../../../providers/local/trust-provider"; + +export function TopZap({ zap }: { zap: NostrEvent }) { + const sender = getZapSender(zap); + const request = getZapRequest(zap); + const payment = getZapPayment(zap); + + const ref = useEventIntersectionRef(zap); + + return ( + + + + + {payment?.amount && ( + + {(payment.amount / 1000).toLocaleString()} + + )} + + + + + + + + + + + + + {request.content && ( + + + + + + )} + + ); +} diff --git a/src/views/support/index.tsx b/src/views/support/index.tsx new file mode 100644 index 000000000..6a87bdd87 --- /dev/null +++ b/src/views/support/index.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react"; +import { Box, Button, Divider, Flex, Heading, Text, useDisclosure } from "@chakra-ui/react"; +import { useStoreQuery } from "applesauce-react/hooks"; +import { TimelineQuery } from "applesauce-core/queries"; +import { kinds } from "nostr-tools"; +import { getTagValue, getZapPayment, unixNow } from "applesauce-core/helpers"; +import confetti from "canvas-confetti"; +import dayjs from "dayjs"; + +import VerticalPageLayout from "../../components/vertical-page-layout"; +import { SUPPORT_PUBKEY } from "../../const"; +import { isProfileZap } from "../../helpers/nostr/zaps"; +import useTimelineLoader from "../../hooks/use-timeline-loader"; +import { useReadRelays } from "../../hooks/use-client-relays"; +import { useUserInbox } from "../../hooks/use-user-mailboxes"; +import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; +import IntersectionObserverProvider from "../../providers/local/intersection-observer"; +import { TopZap } from "./components/top-zap"; +import { NotQuiteTopZap } from "./components/not-quite-top-zap"; +import { OtherZap } from "./components/other-zap"; +import SupportForm from "./components/support-form"; +import { LightningIcon } from "../../components/icons"; +import { PayRequest } from "../../components/event-zap-modal"; +import PayStep from "../../components/event-zap-modal/pay-step"; +import { eventStore } from "../../services/event-store"; + +function randomInRange(min: number, max: number) { + return Math.random() * (max - min) + min; +} + +function fireworks(duration: number = 10_000) { + const animationEnd = Date.now() + duration; + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 }; + + const interval = setInterval(function () { + const timeLeft = animationEnd - Date.now(); + + if (timeLeft <= 0) { + return clearInterval(interval); + } + + const particleCount = 50 * (timeLeft / duration); + // since particles fall down, start a bit higher than random + confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } }); + confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } }); + }, 250); +} + +const aMonthAgo = dayjs().subtract(1, "month").unix(); + +export default function SupportView() { + const zaps = useStoreQuery(TimelineQuery, [{ kinds: [kinds.Zap], "#p": [SUPPORT_PUBKEY], since: aMonthAgo }]); + const form = useDisclosure(); + const [request, setRequest] = useState(); + + const supportInbox = useUserInbox(SUPPORT_PUBKEY); + const readRelays = useReadRelays(supportInbox); + const { loader } = useTimelineLoader("support-zaps", readRelays, { + kinds: [kinds.Zap], + "#p": [SUPPORT_PUBKEY], + }); + const callback = useTimelineCurserIntersectionCallback(loader); + + const support = zaps + ?.filter((zap) => isProfileZap(zap)) + .sort((a, b) => (getZapPayment(b)?.amount ?? 0) - (getZapPayment(a)?.amount ?? 0) || b.created_at - a.created_at); + + // close the pay request when new zap is received + useEffect(() => { + if (request) { + const sub = eventStore.stream([{ kinds: [kinds.Zap], since: unixNow() }]).subscribe((event) => { + try { + const bont11 = getTagValue(event, "bolt11"); + + if (bont11 === request.invoice) { + setRequest(undefined); + + fireworks(); + } + } catch (error) {} + }); + + return () => sub.unsubscribe(); + } + }, [request]); + + return ( + <> + + + + TOP ZAPS + In the last month + + {support?.[0] && } + {support && support.length > 1 && ( + + + {support?.[1] && } + {support?.[2] && } + + + )} + + {request ? ( + setRequest(undefined)} w="full" maxW="2xl" mb="4" /> + ) : form.isOpen ? ( + { + setRequest(r); + form.onClose(); + }} + /> + ) : ( + + )} + + {(support?.length ?? 0) > 3 && <>{support?.slice(3).map((zap) => )}} + + + + ); +} diff --git a/src/views/user/about/user-recent-events.tsx b/src/views/user/about/user-recent-events.tsx index 6d1fcdad8..993fe987f 100644 --- a/src/views/user/about/user-recent-events.tsx +++ b/src/views/user/about/user-recent-events.tsx @@ -8,6 +8,7 @@ import { ChannelsIcon, CommunityIcon, DirectMessagesIcon, + EmojiPacksIcon, ListsIcon, NotesIcon, RelayIcon, @@ -95,6 +96,8 @@ const KnownKinds: KnownKind[] = [ { kind: kinds.Report, name: "Report", icon: AlertTriangle, link: (_e, p) => `/u/${npubEncode(p)}/reports` }, + { kind: kinds.Emojisets, name: "Emojis", icon: EmojiPacksIcon, link: (_e, p) => `/u/${npubEncode(p)}/emojis` }, + { kind: kinds.Handlerinformation, name: "Application" }, { kind: kinds.Handlerrecommendation, name: "App recommendation" },