feat: subscriptions

This commit is contained in:
2023-04-13 19:43:43 +01:00
parent c3c1e02ad8
commit f0c5c33c48
19 changed files with 531 additions and 58 deletions

View 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>
);
}

View File

@ -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) {

View File

@ -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]);

View File

@ -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(() => {

View File

@ -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 {

View File

@ -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 />

View File

@ -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));
}
}
}

View File

@ -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}>

View 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" />
:&nbsp;
<time dateTime={created.toISOString()}>
<FormattedDate value={created} dateStyle="full" />
</time>
</p>
{daysToExpire >= 1 && (
<p className="f-1">
<FormattedMessage defaultMessage="Expires" />
:&nbsp;
<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" />
:&nbsp;
<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>}
</>
);
}

View 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;
}
}

View 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[];

View 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;
}
}

View File

@ -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({

View 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];
}

View File

@ -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>;
}
/**

View File

@ -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;

View File

@ -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 />,