diff --git a/.github/workflows/deploy-next.yml b/.github/workflows/deploy-next.yml index 66e27bc46..b38f14059 100644 --- a/.github/workflows/deploy-next.yml +++ b/.github/workflows/deploy-next.yml @@ -34,6 +34,8 @@ jobs: - name: Build env: VITE_TENOR_API_KEY: ${{ secrets.VITE_TENOR_API_KEY }} + VITE_PAYWALL_NIP05: "/.well-known/nostr.json" + VITE_PAYWALL_MESSAGE: "This is the latest alpha build of noStrudel.\nIf your enjoying the new features consider supporting the project by donating some sats and adding your message on the support page." run: pnpm build - name: Redirect 404 to Index for SPA diff --git a/.gitignore b/.gitignore index 587bbcb36..1be530786 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ dist node_modules stats.html exe/bin +.env diff --git a/src/components/layout/components/support-paywall.tsx b/src/components/layout/components/support-paywall.tsx new file mode 100644 index 000000000..e247c663f --- /dev/null +++ b/src/components/layout/components/support-paywall.tsx @@ -0,0 +1,46 @@ +import { useObservable } from "applesauce-react/hooks"; + +import { paywall, hidePaywall } from "../../../services/paywall"; +import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from "@chakra-ui/react"; +import { unixNow } from "applesauce-core/helpers"; +import RouterLink from "../../router-link"; +import { useMatch } from "react-router-dom"; +import { LightningIcon } from "../../icons"; +import { PAYWALL_MESSAGE } from "../../../env"; + +export default function SupportPaywall() { + const isSupportPage = useMatch("/support"); + const paid = useObservable(paywall); + + const dismiss = () => { + hidePaywall.next(unixNow() + 60 * 60 * 24); + }; + + if (!paid && !isSupportPage) + return ( + + + + Support the app + + {PAYWALL_MESSAGE || "If your enjoying this app consider supporting the developer by donating some sats"} + + + + + + + + + ); + else return null; +} diff --git a/src/components/layout/desktop/index.tsx b/src/components/layout/desktop/index.tsx index c51064a6c..650b85afc 100644 --- a/src/components/layout/desktop/index.tsx +++ b/src/components/layout/desktop/index.tsx @@ -4,10 +4,12 @@ import { Outlet } from "react-router-dom"; import DesktopSideNav from "./side-nav"; import { ErrorBoundary } from "../../error-boundary"; +import SupportPaywall from "../components/support-paywall"; export default function DesktopLayout() { return ( <> + }> diff --git a/src/components/layout/mobile/index.tsx b/src/components/layout/mobile/index.tsx index e8d21470a..8a86fe773 100644 --- a/src/components/layout/mobile/index.tsx +++ b/src/components/layout/mobile/index.tsx @@ -1,13 +1,15 @@ +import { Suspense } from "react"; import { Outlet } from "react-router-dom"; +import { Spinner } from "@chakra-ui/react"; import MobileBottomNav from "./bottom-nav"; import { ErrorBoundary } from "../../error-boundary"; -import { Suspense } from "react"; -import { Spinner } from "@chakra-ui/react"; +import SupportPaywall from "../components/support-paywall"; export default function MobileLayout() { return ( <> + }> diff --git a/src/env.ts b/src/env.ts index e1285ddc3..8214bd930 100644 --- a/src/env.ts +++ b/src/env.ts @@ -5,3 +5,6 @@ export const CAP_IS_WEB = platform === "web"; export const CAP_IS_NATIVE = platform === "ios" || platform === "android"; export const CAP_IS_ANDROID = platform === "android"; export const CAP_IS_IOS = platform === "ios"; + +export const PAYWALL_NIP05 = import.meta.env.VITE_PAYWALL_NIP05 as string | undefined; +export const PAYWALL_MESSAGE = import.meta.env.VITE_PAYWALL_MESSAGE as string | undefined; diff --git a/src/services/paywall.ts b/src/services/paywall.ts new file mode 100644 index 000000000..e7e7f303f --- /dev/null +++ b/src/services/paywall.ts @@ -0,0 +1,68 @@ +import { + BehaviorSubject, + combineLatest, + filter, + from, + map, + merge, + Observable, + of, + shareReplay, + startWith, + Subject, + switchMap, + tap, +} from "rxjs"; +import { DomainIdentityJson } from "applesauce-loaders/helpers/dns-identity"; +import { unixNow } from "applesauce-core/helpers"; + +import accounts from "./accounts"; +import { PAYWALL_NIP05 } from "../env"; +import { logger } from "../helpers/debug"; + +const log = logger.extend("paywall"); + +export const hidePaywall = new BehaviorSubject( + localStorage.getItem("paywall-dismiss") ? parseInt(localStorage.getItem("paywall-dismiss")!) : null, +); + +hidePaywall.subscribe((ts) => { + if (ts) localStorage.setItem("paywall-dismiss", String(ts)); +}); + +let paywall: Observable; +if (PAYWALL_NIP05) { + const accountPaid = accounts.active$.pipe( + // ignore empty accounts + filter((a) => !!a), + // fetch the identity document + switchMap((account) => { + log(`Fetching NIP-05 document`); + const document = from(fetch(PAYWALL_NIP05!).then((res) => res.json() as Promise)); + return combineLatest([of(account), document]); + }), + // check if account is in document + map(([account, document]) => { + log(`Found document`, document); + return document.names ? Object.values(document.names).includes(account.pubkey) : false; + }), + // start with true until document is checked + startWith(true), + tap((paid) => log(`Account paid ${paid}`)), + ); + + const dismiss = hidePaywall.pipe( + map((hideUntil) => (hideUntil ? hideUntil > unixNow() : false)), + tap((dismissed) => log(`Paywall dismissed ${dismissed}`)), + ); + + paywall = combineLatest([dismiss, accountPaid]).pipe( + map(([dismiss, account]) => dismiss || account), + // share results for UI + shareReplay(1), + ); +} else { + paywall = of(true); +} + +export { paywall };