feat: subscriptions
This commit is contained in:
9
packages/app/src/Element/PageSpinner.tsx
Normal file
9
packages/app/src/Element/PageSpinner.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import Spinner from "Icons/Spinner";
|
||||
|
||||
export default function PageSpinner() {
|
||||
return (
|
||||
<div className="flex f-center">
|
||||
<Spinner width={50} height={50} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -354,7 +354,7 @@ export default function useEventPublisher() {
|
||||
return "<CANT DECRYPT>";
|
||||
}
|
||||
try {
|
||||
const otherPubKey = note.pubkey === pubKey ? unwrap(note.tags.filter(a => a[0] === "p")[0][1]) : note.pubkey;
|
||||
const otherPubKey = note.pubkey === pubKey ? unwrap(note.tags.find(a => a[0] === "p")?.[1]) : note.pubkey;
|
||||
if (hasNip07 && !privKey) {
|
||||
return await barrierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.content));
|
||||
} else if (privKey) {
|
||||
|
@ -3,7 +3,7 @@ import { useDispatch, useSelector } from "react-redux";
|
||||
import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
|
||||
import { TaggedRawEvent, HexKey, Lists, EventKind } from "@snort/nostr";
|
||||
|
||||
import { getNewest, getNewestEventTagsByKey, unwrap } from "Util";
|
||||
import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "Util";
|
||||
import { makeNotification } from "Notifications";
|
||||
import {
|
||||
setFollows,
|
||||
@ -15,15 +15,18 @@ import {
|
||||
setBlocked,
|
||||
sendNotification,
|
||||
setLatestNotifications,
|
||||
addSubscription,
|
||||
} from "State/Login";
|
||||
import { RootState } from "State/Store";
|
||||
import { barrierNip07 } from "Feed/EventPublisher";
|
||||
import useEventPublisher, { barrierNip07 } from "Feed/EventPublisher";
|
||||
import { getMutedKeys } from "Feed/MuteList";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { FlatNoteStore, RequestBuilder } from "System";
|
||||
import useRequestBuilder from "Hooks/useRequestBuilder";
|
||||
import { EventExt } from "System/EventExt";
|
||||
import { DmCache } from "Cache";
|
||||
import { SnortPubKey } from "Const";
|
||||
import { SubscriptionEvent } from "Subscription";
|
||||
|
||||
/**
|
||||
* Managed loading data for the current logged in user
|
||||
@ -37,6 +40,7 @@ export default function useLoginFeed() {
|
||||
readNotifications,
|
||||
} = useSelector((s: RootState) => s.login);
|
||||
const { isMuted } = useModeration();
|
||||
const publisher = useEventPublisher();
|
||||
|
||||
const subLogin = useMemo(() => {
|
||||
if (!pubKey) return null;
|
||||
@ -47,6 +51,11 @@ export default function useLoginFeed() {
|
||||
});
|
||||
b.withFilter().authors([pubKey]).kinds([EventKind.ContactList]);
|
||||
b.withFilter().kinds([EventKind.TextNote]).tag("p", [pubKey]).limit(1);
|
||||
b.withFilter()
|
||||
.kinds([EventKind.SnortSubscriptions])
|
||||
.authors([bech32ToHex(SnortPubKey)])
|
||||
.tag("p", [pubKey])
|
||||
.limit(1);
|
||||
|
||||
const dmSince = DmCache.newest();
|
||||
b.withFilter().authors([pubKey]).kinds([EventKind.DirectMessage]).since(dmSince);
|
||||
@ -85,6 +94,22 @@ export default function useLoginFeed() {
|
||||
|
||||
const dms = loginFeed.data.filter(a => a.kind === EventKind.DirectMessage);
|
||||
DmCache.bulkSet(dms);
|
||||
|
||||
const subs = loginFeed.data.filter(
|
||||
a => a.kind === EventKind.SnortSubscriptions && a.pubkey === bech32ToHex(SnortPubKey)
|
||||
);
|
||||
Promise.all(
|
||||
subs.map(async a => {
|
||||
const dx = await publisher.decryptDm(a);
|
||||
if (dx) {
|
||||
const ex = JSON.parse(dx);
|
||||
return {
|
||||
id: a.id,
|
||||
...ex,
|
||||
} as SubscriptionEvent;
|
||||
}
|
||||
})
|
||||
).then(a => dispatch(addSubscription(a.filter(a => a !== undefined).map(unwrap))));
|
||||
}
|
||||
}, [dispatch, loginFeed]);
|
||||
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
|
||||
import { ApiHost, KieranPubKey, SnortPubKey } from "Const";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import ZapButton from "Element/ZapButton";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { bech32ToHex } from "Util";
|
||||
import SnortApi, { RevenueSplit, RevenueToday } from "SnortApi";
|
||||
|
||||
const Developers = [
|
||||
bech32ToHex(KieranPubKey), // kieran
|
||||
@ -42,29 +44,16 @@ const Translators = [
|
||||
|
||||
export const DonateLNURL = "donate@snort.social";
|
||||
|
||||
interface Splits {
|
||||
pubKey: string;
|
||||
split: number;
|
||||
}
|
||||
|
||||
interface TotalToday {
|
||||
donations: number;
|
||||
nip5: number;
|
||||
}
|
||||
|
||||
const DonatePage = () => {
|
||||
const [splits, setSplits] = useState<Splits[]>([]);
|
||||
const [today, setSumToday] = useState<TotalToday>();
|
||||
const [splits, setSplits] = useState<RevenueSplit[]>([]);
|
||||
const [today, setSumToday] = useState<RevenueToday>();
|
||||
const api = new SnortApi(ApiHost);
|
||||
|
||||
async function loadData() {
|
||||
const rsp = await fetch(`${ApiHost}/api/v1/revenue/splits`);
|
||||
if (rsp.ok) {
|
||||
setSplits(await rsp.json());
|
||||
}
|
||||
const rsp2 = await fetch(`${ApiHost}/api/v1/revenue/today`);
|
||||
if (rsp2.ok) {
|
||||
setSumToday(await rsp2.json());
|
||||
}
|
||||
const rsp = await api.revenueSplits();
|
||||
setSplits(rsp);
|
||||
const rsp2 = await api.revenueToday();
|
||||
setSumToday(rsp2);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -1,8 +1,13 @@
|
||||
.logo {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-weight: 700;
|
||||
font-size: 29px;
|
||||
line-height: 23px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
@ -10,14 +15,7 @@ header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 72px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
header {
|
||||
padding: 0;
|
||||
}
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
header .pfp .avatar-wrapper {
|
||||
|
@ -24,6 +24,7 @@ import { DefaultRelays, SnortPubKey } from "Const";
|
||||
import SubDebug from "Element/SubDebug";
|
||||
import { preload } from "Cache";
|
||||
import { useDmCache } from "Hooks/useDmsCache";
|
||||
import { mapPlanName } from "./subscribe";
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
@ -32,7 +33,9 @@ export default function Layout() {
|
||||
const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing;
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { loggedOut, publicKey, relays, preferences, newUserKey } = useSelector((s: RootState) => s.login);
|
||||
const { loggedOut, publicKey, relays, preferences, newUserKey, subscription } = useSelector(
|
||||
(s: RootState) => s.login
|
||||
);
|
||||
const [pageClass, setPageClass] = useState("page");
|
||||
const pub = useEventPublisher();
|
||||
useLoginFeed();
|
||||
@ -45,7 +48,7 @@ export default function Layout() {
|
||||
};
|
||||
|
||||
const shouldHideNoteCreator = useMemo(() => {
|
||||
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/p/", "/e"];
|
||||
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/p/", "/e", "/subscribe"];
|
||||
return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
|
||||
}, [location, isReplyNoteCreatorShowing]);
|
||||
|
||||
@ -172,8 +175,15 @@ export default function Layout() {
|
||||
{!shouldHideHeader && (
|
||||
<header>
|
||||
<div className="logo" onClick={() => navigate("/")}>
|
||||
Snort
|
||||
<h1>Snort</h1>
|
||||
{subscription && (
|
||||
<small className="flex">
|
||||
<Icon name="diamond" size={10} className="mr5" />
|
||||
{mapPlanName(subscription.type)}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{publicKey ? (
|
||||
<AccountHeader />
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiHost } from "Const";
|
||||
import Logo from "Element/Logo";
|
||||
@ -8,12 +9,10 @@ import AsyncButton from "Element/AsyncButton";
|
||||
import FollowListBase from "Element/FollowListBase";
|
||||
import { RootState } from "State/Store";
|
||||
import { bech32ToHex } from "Util";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import SnortApi from "SnortApi";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
const TwitterFollowsApi = `${ApiHost}/api/v1/twitter/follows-for-nostr`;
|
||||
|
||||
export default function ImportFollows() {
|
||||
const navigate = useNavigate();
|
||||
const currentFollows = useSelector((s: RootState) => s.login.follows);
|
||||
@ -21,6 +20,7 @@ export default function ImportFollows() {
|
||||
const [twitterUsername, setTwitterUsername] = useState<string>("");
|
||||
const [follows, setFollows] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
const api = new SnortApi(ApiHost);
|
||||
|
||||
const sortedTwitterFollows = useMemo(() => {
|
||||
return follows.map(a => bech32ToHex(a)).sort(a => (currentFollows.includes(a) ? 1 : -1));
|
||||
@ -30,22 +30,19 @@ export default function ImportFollows() {
|
||||
setFollows([]);
|
||||
setError("");
|
||||
try {
|
||||
const rsp = await fetch(`${TwitterFollowsApi}?username=${twitterUsername}`);
|
||||
const data = await rsp.json();
|
||||
if (rsp.ok) {
|
||||
if (Array.isArray(data) && data.length === 0) {
|
||||
setError(formatMessage(messages.NoUsersFound, { twitterUsername }));
|
||||
} else {
|
||||
setFollows(data);
|
||||
}
|
||||
} else if ("error" in data) {
|
||||
setError(data.error);
|
||||
const rsp = await api.twitterImport(twitterUsername);
|
||||
if (Array.isArray(rsp) && rsp.length === 0) {
|
||||
setError(formatMessage(messages.NoUsersFound, { twitterUsername }));
|
||||
} else {
|
||||
setError(formatMessage(messages.FailedToLoad));
|
||||
setFollows(rsp);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
setError(formatMessage(messages.FailedToLoad));
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError(formatMessage(messages.FailedToLoad));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -49,7 +49,12 @@ const SettingsIndex = () => {
|
||||
</div>
|
||||
<div className="settings-row" onClick={() => navigate("handle")}>
|
||||
<Icon name="badge" />
|
||||
<FormattedMessage defaultMessage="Manage Nostr Adddress (NIP-05)" />
|
||||
<FormattedMessage defaultMessage="Snort Nostr Adddress" />
|
||||
<Icon name="arrowFront" />
|
||||
</div>
|
||||
<div className="settings-row" onClick={() => navigate("/subscribe/manage")}>
|
||||
<Icon name="diamond" />
|
||||
<FormattedMessage defaultMessage="Snort Subscription" />
|
||||
<Icon name="arrowFront" />
|
||||
</div>
|
||||
<div className="settings-row" onClick={handleLogout}>
|
||||
|
123
packages/app/src/Pages/subscribe/ManageSubscription.tsx
Normal file
123
packages/app/src/Pages/subscribe/ManageSubscription.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedDate, FormattedMessage, FormattedNumber } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import PageSpinner from "Element/PageSpinner";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import SnortApi, { Subscription } from "SnortApi";
|
||||
import { mapPlanName } from ".";
|
||||
import Icon from "Icons/Icon";
|
||||
|
||||
export default function ManageSubscriptionPage() {
|
||||
const publisher = useEventPublisher();
|
||||
const api = new SnortApi(undefined, publisher);
|
||||
|
||||
const [subs, setSubs] = useState<Array<Subscription>>();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const s = await api.listSubscriptions();
|
||||
setSubs(s);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError("Unknown error");
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (subs === undefined) {
|
||||
return <PageSpinner />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Subscriptions" />
|
||||
</h2>
|
||||
{subs.map(a => {
|
||||
const created = new Date(a.created);
|
||||
const expires = new Date(a.expires);
|
||||
const now = new Date();
|
||||
const daysToExpire = Math.floor((expires.getTime() - now.getTime()) / 8.64e7);
|
||||
const hoursToExpire = Math.floor((expires.getTime() - now.getTime()) / 3.6e6);
|
||||
const isExpired = expires < now;
|
||||
return (
|
||||
<div key={a.id} className="card">
|
||||
<div className="flex card-title">
|
||||
<Icon name="badge" className="mr5" size={25} />
|
||||
{mapPlanName(a.type)}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<p className="f-1">
|
||||
<FormattedMessage defaultMessage="Created" />
|
||||
:
|
||||
<time dateTime={created.toISOString()}>
|
||||
<FormattedDate value={created} dateStyle="full" />
|
||||
</time>
|
||||
</p>
|
||||
{daysToExpire >= 1 && (
|
||||
<p className="f-1">
|
||||
<FormattedMessage defaultMessage="Expires" />
|
||||
:
|
||||
<time dateTime={expires.toISOString()}>
|
||||
<FormattedMessage
|
||||
defaultMessage="{n} days"
|
||||
values={{
|
||||
n: <FormattedNumber value={daysToExpire} maximumFractionDigits={0} />,
|
||||
}}
|
||||
/>
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
{daysToExpire >= 0 && daysToExpire < 1 && (
|
||||
<p className="f-1">
|
||||
<FormattedMessage defaultMessage="Expires" />
|
||||
:
|
||||
<time dateTime={expires.toISOString()}>
|
||||
<FormattedMessage
|
||||
defaultMessage="{n} hours"
|
||||
values={{
|
||||
n: <FormattedNumber value={hoursToExpire} maximumFractionDigits={0} />,
|
||||
}}
|
||||
/>
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
{daysToExpire < 0 && (
|
||||
<p className="f-1 error">
|
||||
<FormattedMessage defaultMessage="Expired" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isExpired && (
|
||||
<div className="flex">
|
||||
<button>
|
||||
<FormattedMessage defaultMessage="Renew" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{subs.length === 0 && (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="It looks like you dont have any subscriptions, you can get one {link}"
|
||||
values={{
|
||||
link: (
|
||||
<Link to="/subscribe">
|
||||
<FormattedMessage defaultMessage="here" />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{error && <b className="error">{error}</b>}
|
||||
</>
|
||||
);
|
||||
}
|
24
packages/app/src/Pages/subscribe/index.css
Normal file
24
packages/app/src/Pages/subscribe/index.css
Normal file
@ -0,0 +1,24 @@
|
||||
.subscribe-page > div.card {
|
||||
margin: 5px;
|
||||
min-height: 350px;
|
||||
user-select: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subscribe-page h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subscribe-page ul {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.subscribe-page {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.subscribe-page > div.card {
|
||||
flex: unset;
|
||||
}
|
||||
}
|
113
packages/app/src/Pages/subscribe/index.tsx
Normal file
113
packages/app/src/Pages/subscribe/index.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import "./index.css";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { RouteObject } from "react-router-dom";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
import { LockedFeatures, Plans, SubscriptionType } from "Subscription";
|
||||
import ManageSubscriptionPage from "Pages/subscribe/ManageSubscription";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import SnortApi from "SnortApi";
|
||||
import SendSats from "Element/SendSats";
|
||||
|
||||
export function mapPlanName(id: number) {
|
||||
switch (id) {
|
||||
case SubscriptionType.Supporter:
|
||||
return <FormattedMessage defaultMessage="Supporter" />;
|
||||
case SubscriptionType.Premium:
|
||||
return <FormattedMessage defaultMessage="Premium" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function mapFeatureName(k: LockedFeatures) {
|
||||
switch (k) {
|
||||
case LockedFeatures.MultiAccount:
|
||||
return <FormattedMessage defaultMessage="Multi account support" />;
|
||||
case LockedFeatures.NostrAddress:
|
||||
return <FormattedMessage defaultMessage="Snort nostr address" />;
|
||||
case LockedFeatures.Badge:
|
||||
return <FormattedMessage defaultMessage="Supporter Badge" />;
|
||||
case LockedFeatures.DeepL:
|
||||
return <FormattedMessage defaultMessage="DeepL translations" />;
|
||||
case LockedFeatures.RelayRetention:
|
||||
return <FormattedMessage defaultMessage="Unlimited note retention on Snort relay" />;
|
||||
case LockedFeatures.RelayBackup:
|
||||
return <FormattedMessage defaultMessage="Downloadable backups from Snort relay" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function SubscribePage() {
|
||||
const publisher = useEventPublisher();
|
||||
const api = new SnortApi(undefined, publisher);
|
||||
const [invoice, setInvoice] = useState("");
|
||||
|
||||
async function subscribe(type: number) {
|
||||
const rsp = await api.createSubscription(type);
|
||||
setInvoice(rsp.pr);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex subscribe-page">
|
||||
{Plans.map(a => {
|
||||
const lower = Plans.filter(b => b.id < a.id);
|
||||
return (
|
||||
<div className={`card flex f-col${a.disabled ? " disabled" : ""}`}>
|
||||
<div className="f-grow">
|
||||
<h2>{mapPlanName(a.id)}</h2>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="Support Snort every month for {price} sats and receive the following rewards"
|
||||
values={{
|
||||
price: <b>{formatShort(a.price)}</b>,
|
||||
}}
|
||||
/>
|
||||
:
|
||||
</p>
|
||||
<ul>
|
||||
{a.unlocks.map(b => (
|
||||
<li>{mapFeatureName(b)}</li>
|
||||
))}
|
||||
{lower.map(b => (
|
||||
<li>
|
||||
<FormattedMessage
|
||||
defaultMessage="Everything in {plan}"
|
||||
values={{
|
||||
plan: mapPlanName(b.id),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex f-center w-max mb10">
|
||||
<AsyncButton className="button" disabled={a.disabled} onClick={() => subscribe(a.id)}>
|
||||
{a.disabled ? (
|
||||
<FormattedMessage defaultMessage="Coming soon" />
|
||||
) : (
|
||||
<FormattedMessage
|
||||
defaultMessage="Subscribe for {amount}/mo"
|
||||
values={{ amount: formatShort(a.price) }}
|
||||
/>
|
||||
)}
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<SendSats invoice={invoice} show={invoice !== ""} onClose={() => setInvoice("")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SubscribeRoutes = [
|
||||
{
|
||||
path: "/subscribe",
|
||||
element: <SubscribePage />,
|
||||
},
|
||||
{
|
||||
path: "/subscribe/manage",
|
||||
element: <ManageSubscriptionPage />,
|
||||
},
|
||||
] as RouteObject[];
|
100
packages/app/src/SnortApi.ts
Normal file
100
packages/app/src/SnortApi.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { ApiHost } from "Const";
|
||||
import { EventPublisher } from "Feed/EventPublisher";
|
||||
import { SubscriptionType } from "Subscription";
|
||||
|
||||
export interface RevenueToday {
|
||||
donations: number;
|
||||
nip5: number;
|
||||
}
|
||||
|
||||
export interface RevenueSplit {
|
||||
pubKey: string;
|
||||
split: number;
|
||||
}
|
||||
|
||||
export interface InvoiceResponse {
|
||||
pr: string;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
type: SubscriptionType;
|
||||
created: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
export default class SnortApi {
|
||||
#url: string;
|
||||
#publisher?: EventPublisher;
|
||||
|
||||
constructor(url?: string, publisher?: EventPublisher) {
|
||||
this.#url = new URL(url ?? ApiHost).toString();
|
||||
this.#publisher = publisher;
|
||||
}
|
||||
|
||||
revenueSplits() {
|
||||
return this.#getJson<Array<RevenueSplit>>("api/v1/revenue/splits");
|
||||
}
|
||||
|
||||
revenueToday() {
|
||||
return this.#getJson<RevenueToday>("api/v1/revenue/today");
|
||||
}
|
||||
|
||||
twitterImport(username: string) {
|
||||
return this.#getJson<Array<string>>(`api/v1/twitter/follows-for-nostr?username=${encodeURIComponent(username)}`);
|
||||
}
|
||||
|
||||
createSubscription(type: number) {
|
||||
return this.#getJsonAuthd<InvoiceResponse>(`api/v1/subscription?type=${type}`, "PUT");
|
||||
}
|
||||
|
||||
listSubscriptions() {
|
||||
return this.#getJsonAuthd<Array<Subscription>>("api/v1/subscription");
|
||||
}
|
||||
|
||||
async #getJsonAuthd<T>(
|
||||
path: string,
|
||||
method?: "GET" | string,
|
||||
body?: { [key: string]: string },
|
||||
headers?: { [key: string]: string }
|
||||
): Promise<T> {
|
||||
if (!this.#publisher) {
|
||||
throw new Error("Publisher not set");
|
||||
}
|
||||
const auth = await this.#publisher.generic("", 27_235, [
|
||||
["url", `${this.#url}${path}`],
|
||||
["method", method ?? "GET"],
|
||||
]);
|
||||
if (!auth) {
|
||||
throw new Error("Failed to create auth event");
|
||||
}
|
||||
|
||||
return this.#getJson<T>(path, method, body, {
|
||||
...headers,
|
||||
authorization: `Nostr ${window.btoa(JSON.stringify(auth))}`,
|
||||
});
|
||||
}
|
||||
|
||||
async #getJson<T>(
|
||||
path: string,
|
||||
method?: "GET" | string,
|
||||
body?: { [key: string]: string },
|
||||
headers?: { [key: string]: string }
|
||||
): Promise<T> {
|
||||
const rsp = await fetch(`${this.#url}${path}`, {
|
||||
method: method,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
|
||||
const obj = await rsp.json();
|
||||
if ("error" in obj) {
|
||||
throw new Error(obj.error);
|
||||
}
|
||||
return obj as T;
|
||||
}
|
||||
}
|
@ -6,8 +6,9 @@ import { DefaultRelays } from "Const";
|
||||
import { RelaySettings } from "@snort/nostr";
|
||||
import type { AppDispatch, RootState } from "State/Store";
|
||||
import { ImgProxySettings } from "Hooks/useImgProxy";
|
||||
import { sanitizeRelayUrl, unwrap } from "Util";
|
||||
import { dedupeById, sanitizeRelayUrl, unwrap } from "Util";
|
||||
import { DmCache } from "Cache";
|
||||
import { getCurrentSubscription, SubscriptionEvent } from "Subscription";
|
||||
|
||||
const PrivateKeyItem = "secret";
|
||||
const PublicKeyItem = "pubkey";
|
||||
@ -205,6 +206,16 @@ export interface LoginStore {
|
||||
* Users cusom preferences
|
||||
*/
|
||||
preferences: UserPreferences;
|
||||
|
||||
/**
|
||||
* Subscription events for Snort subscriptions
|
||||
*/
|
||||
subscriptions: Array<SubscriptionEvent>;
|
||||
|
||||
/**
|
||||
* Current Snort subscription
|
||||
*/
|
||||
subscription?: SubscriptionEvent;
|
||||
}
|
||||
|
||||
export const DefaultImgProxy = {
|
||||
@ -235,6 +246,7 @@ export const InitState = {
|
||||
readNotifications: new Date().getTime(),
|
||||
dms: [],
|
||||
dmInteraction: 0,
|
||||
subscriptions: [],
|
||||
preferences: {
|
||||
enableReactions: true,
|
||||
reactionEmoji: "+",
|
||||
@ -465,6 +477,10 @@ const LoginSlice = createSlice({
|
||||
state.preferences = action.payload;
|
||||
window.localStorage.setItem(UserPreferencesKey, JSON.stringify(state.preferences));
|
||||
},
|
||||
addSubscription: (state, action: PayloadAction<Array<SubscriptionEvent>>) => {
|
||||
state.subscriptions = dedupeById([...state.subscriptions, ...action.payload]);
|
||||
state.subscription = getCurrentSubscription(state.subscriptions);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -487,6 +503,7 @@ export const {
|
||||
markNotificationsRead,
|
||||
setLatestNotifications,
|
||||
setPreferences,
|
||||
addSubscription,
|
||||
} = LoginSlice.actions;
|
||||
|
||||
export function sendNotification({
|
||||
|
46
packages/app/src/Subscription/index.ts
Normal file
46
packages/app/src/Subscription/index.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { unixNow } from "Util";
|
||||
|
||||
export enum SubscriptionType {
|
||||
Supporter = 0,
|
||||
Premium = 1,
|
||||
}
|
||||
|
||||
export enum LockedFeatures {
|
||||
MultiAccount = 1,
|
||||
NostrAddress = 2,
|
||||
Badge = 3,
|
||||
DeepL = 4,
|
||||
RelayRetention = 5,
|
||||
RelayBackup = 6,
|
||||
}
|
||||
|
||||
export const Plans = [
|
||||
{
|
||||
id: SubscriptionType.Supporter,
|
||||
price: 5_000,
|
||||
disabled: true,
|
||||
unlocks: [LockedFeatures.MultiAccount, LockedFeatures.NostrAddress, LockedFeatures.Badge],
|
||||
},
|
||||
{
|
||||
id: SubscriptionType.Premium,
|
||||
price: 20_000,
|
||||
disabled: true,
|
||||
unlocks: [LockedFeatures.DeepL, LockedFeatures.RelayBackup, LockedFeatures.RelayRetention],
|
||||
},
|
||||
];
|
||||
|
||||
export interface SubscriptionEvent {
|
||||
id: string;
|
||||
type: SubscriptionType;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function getActiveSubscriptions(s: Array<SubscriptionEvent>) {
|
||||
const now = unixNow();
|
||||
return s.filter(a => a.start <= now && a.end > now);
|
||||
}
|
||||
|
||||
export function getCurrentSubscription(s: Array<SubscriptionEvent>) {
|
||||
return getActiveSubscriptions(s)[0];
|
||||
}
|
@ -179,9 +179,9 @@ export function dedupeByPubkey(events: TaggedRawEvent[]) {
|
||||
return deduped.list as TaggedRawEvent[];
|
||||
}
|
||||
|
||||
export function dedupeById(events: TaggedRawEvent[]) {
|
||||
export function dedupeById<T extends { id: string }>(events: Array<T>) {
|
||||
const deduped = events.reduce(
|
||||
({ list, seen }: { list: TaggedRawEvent[]; seen: Set<HexKey> }, ev) => {
|
||||
({ list, seen }: { list: Array<T>; seen: Set<string> }, ev) => {
|
||||
if (seen.has(ev.id)) {
|
||||
return { list, seen };
|
||||
}
|
||||
@ -193,7 +193,7 @@ export function dedupeById(events: TaggedRawEvent[]) {
|
||||
},
|
||||
{ list: [], seen: new Set([]) }
|
||||
);
|
||||
return deduped.list as TaggedRawEvent[];
|
||||
return deduped.list as Array<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -125,6 +125,11 @@ html.light .card {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.card .card-title {
|
||||
font-size: x-large;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
|
@ -9,6 +9,7 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
|
||||
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
|
||||
import { IntlProvider } from "IntlProvider";
|
||||
import { unwrap } from "Util";
|
||||
import Store from "State/Store";
|
||||
import Layout from "Pages/Layout";
|
||||
import LoginPage from "Pages/Login";
|
||||
@ -28,7 +29,7 @@ import { NewUserRoutes } from "Pages/new";
|
||||
import { WalletRoutes } from "Pages/WalletPage";
|
||||
import NostrLinkHandler from "Pages/NostrLinkHandler";
|
||||
import Thread from "Element/Thread";
|
||||
import { unwrap } from "Util";
|
||||
import { SubscribeRoutes } from "Pages/subscribe";
|
||||
|
||||
/**
|
||||
* HTTP query provider
|
||||
@ -94,6 +95,7 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
...NewUserRoutes,
|
||||
...WalletRoutes,
|
||||
...SubscribeRoutes,
|
||||
{
|
||||
path: "/*",
|
||||
element: <NostrLinkHandler />,
|
||||
|
Reference in New Issue
Block a user