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"))) && (
- }
- href="https://geyser.fund/project/nostrudel"
- isExternal
- onClick={(e) => {
- e.preventDefault();
- donateModal.onOpen();
- }}
- {...buttonProps}
- >
- Donate
-
- )}
- {donateModal.isOpen && (
- setLastDonate(dayjs().unix())}
- />
- )}
+ }
+ colorScheme={active === "support" ? "primary" : undefined}
+ {...buttonProps}
+ >
+ Support
+
{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();
+ }}
+ />
+ ) : (
+ }
+ colorScheme="primary"
+ mb="4"
+ w="full"
+ maxW="sm"
+ onClick={form.onOpen}
+ >
+ Support
+
+ )}
+
+ {(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" },