commit
0e75f47d1d
@ -385,5 +385,14 @@
|
|||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
/>
|
/>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<symbol id="diamond" viewBox="0 0 22 20" fill="none">
|
||||||
|
<path
|
||||||
|
d="M1.49954 7H20.4995M8.99954 1L6.99954 7L10.9995 18.5L14.9995 7L12.9995 1M11.6141 18.2625L20.5727 7.51215C20.7246 7.32995 20.8005 7.23885 20.8295 7.13717C20.8551 7.04751 20.8551 6.95249 20.8295 6.86283C20.8005 6.76114 20.7246 6.67005 20.5727 6.48785L16.2394 1.28785C16.1512 1.18204 16.1072 1.12914 16.0531 1.09111C16.0052 1.05741 15.9518 1.03238 15.8953 1.01717C15.8314 1 15.7626 1 15.6248 1H6.37424C6.2365 1 6.16764 1 6.10382 1.01717C6.04728 1.03238 5.99385 1.05741 5.94596 1.09111C5.89192 1.12914 5.84783 1.18204 5.75966 1.28785L1.42633 6.48785C1.2745 6.67004 1.19858 6.76114 1.16957 6.86283C1.144 6.95249 1.144 7.04751 1.16957 7.13716C1.19858 7.23885 1.2745 7.32995 1.42633 7.51215L10.385 18.2625C10.596 18.5158 10.7015 18.6424 10.8279 18.6886C10.9387 18.7291 11.0603 18.7291 11.1712 18.6886C11.2975 18.6424 11.4031 18.5158 11.6141 18.2625Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</symbol>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
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>";
|
return "<CANT DECRYPT>";
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
if (hasNip07 && !privKey) {
|
||||||
return await barrierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.content));
|
return await barrierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.content));
|
||||||
} else if (privKey) {
|
} else if (privKey) {
|
||||||
|
@ -3,7 +3,7 @@ import { useDispatch, useSelector } from "react-redux";
|
|||||||
import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
|
import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit";
|
||||||
import { TaggedRawEvent, HexKey, Lists, EventKind } from "@snort/nostr";
|
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 { makeNotification } from "Notifications";
|
||||||
import {
|
import {
|
||||||
setFollows,
|
setFollows,
|
||||||
@ -15,15 +15,18 @@ import {
|
|||||||
setBlocked,
|
setBlocked,
|
||||||
sendNotification,
|
sendNotification,
|
||||||
setLatestNotifications,
|
setLatestNotifications,
|
||||||
|
addSubscription,
|
||||||
} from "State/Login";
|
} from "State/Login";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { barrierNip07 } from "Feed/EventPublisher";
|
import useEventPublisher, { barrierNip07 } from "Feed/EventPublisher";
|
||||||
import { getMutedKeys } from "Feed/MuteList";
|
import { getMutedKeys } from "Feed/MuteList";
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import { FlatNoteStore, RequestBuilder } from "System";
|
import { FlatNoteStore, RequestBuilder } from "System";
|
||||||
import useRequestBuilder from "Hooks/useRequestBuilder";
|
import useRequestBuilder from "Hooks/useRequestBuilder";
|
||||||
import { EventExt } from "System/EventExt";
|
import { EventExt } from "System/EventExt";
|
||||||
import { DmCache } from "Cache";
|
import { DmCache } from "Cache";
|
||||||
|
import { SnortPubKey } from "Const";
|
||||||
|
import { SubscriptionEvent } from "Subscription";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Managed loading data for the current logged in user
|
* Managed loading data for the current logged in user
|
||||||
@ -37,6 +40,7 @@ export default function useLoginFeed() {
|
|||||||
readNotifications,
|
readNotifications,
|
||||||
} = useSelector((s: RootState) => s.login);
|
} = useSelector((s: RootState) => s.login);
|
||||||
const { isMuted } = useModeration();
|
const { isMuted } = useModeration();
|
||||||
|
const publisher = useEventPublisher();
|
||||||
|
|
||||||
const subLogin = useMemo(() => {
|
const subLogin = useMemo(() => {
|
||||||
if (!pubKey) return null;
|
if (!pubKey) return null;
|
||||||
@ -47,6 +51,11 @@ export default function useLoginFeed() {
|
|||||||
});
|
});
|
||||||
b.withFilter().authors([pubKey]).kinds([EventKind.ContactList]);
|
b.withFilter().authors([pubKey]).kinds([EventKind.ContactList]);
|
||||||
b.withFilter().kinds([EventKind.TextNote]).tag("p", [pubKey]).limit(1);
|
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();
|
const dmSince = DmCache.newest();
|
||||||
b.withFilter().authors([pubKey]).kinds([EventKind.DirectMessage]).since(dmSince);
|
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);
|
const dms = loginFeed.data.filter(a => a.kind === EventKind.DirectMessage);
|
||||||
DmCache.bulkSet(dms);
|
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]);
|
}, [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 { ApiHost, KieranPubKey, SnortPubKey } from "Const";
|
||||||
import ProfilePreview from "Element/ProfilePreview";
|
import ProfilePreview from "Element/ProfilePreview";
|
||||||
import ZapButton from "Element/ZapButton";
|
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 { bech32ToHex } from "Util";
|
||||||
|
import SnortApi, { RevenueSplit, RevenueToday } from "SnortApi";
|
||||||
|
|
||||||
const Developers = [
|
const Developers = [
|
||||||
bech32ToHex(KieranPubKey), // kieran
|
bech32ToHex(KieranPubKey), // kieran
|
||||||
@ -42,29 +44,16 @@ const Translators = [
|
|||||||
|
|
||||||
export const DonateLNURL = "donate@snort.social";
|
export const DonateLNURL = "donate@snort.social";
|
||||||
|
|
||||||
interface Splits {
|
|
||||||
pubKey: string;
|
|
||||||
split: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TotalToday {
|
|
||||||
donations: number;
|
|
||||||
nip5: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DonatePage = () => {
|
const DonatePage = () => {
|
||||||
const [splits, setSplits] = useState<Splits[]>([]);
|
const [splits, setSplits] = useState<RevenueSplit[]>([]);
|
||||||
const [today, setSumToday] = useState<TotalToday>();
|
const [today, setSumToday] = useState<RevenueToday>();
|
||||||
|
const api = new SnortApi(ApiHost);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const rsp = await fetch(`${ApiHost}/api/v1/revenue/splits`);
|
const rsp = await api.revenueSplits();
|
||||||
if (rsp.ok) {
|
setSplits(rsp);
|
||||||
setSplits(await rsp.json());
|
const rsp2 = await api.revenueToday();
|
||||||
}
|
setSumToday(rsp2);
|
||||||
const rsp2 = await fetch(`${ApiHost}/api/v1/revenue/today`);
|
|
||||||
if (rsp2.ok) {
|
|
||||||
setSumToday(await rsp2.json());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
.logo {
|
.logo {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 29px;
|
font-size: 29px;
|
||||||
line-height: 23px;
|
line-height: 23px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
@ -10,14 +15,7 @@ header {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 72px;
|
padding: 4px 12px;
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 720px) {
|
|
||||||
header {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header .pfp .avatar-wrapper {
|
header .pfp .avatar-wrapper {
|
||||||
|
@ -24,6 +24,7 @@ import { DefaultRelays, SnortPubKey } from "Const";
|
|||||||
import SubDebug from "Element/SubDebug";
|
import SubDebug from "Element/SubDebug";
|
||||||
import { preload } from "Cache";
|
import { preload } from "Cache";
|
||||||
import { useDmCache } from "Hooks/useDmsCache";
|
import { useDmCache } from "Hooks/useDmsCache";
|
||||||
|
import { mapPlanName } from "./subscribe";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -32,7 +33,9 @@ export default function Layout() {
|
|||||||
const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing;
|
const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing;
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const navigate = useNavigate();
|
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 [pageClass, setPageClass] = useState("page");
|
||||||
const pub = useEventPublisher();
|
const pub = useEventPublisher();
|
||||||
useLoginFeed();
|
useLoginFeed();
|
||||||
@ -45,7 +48,7 @@ export default function Layout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const shouldHideNoteCreator = useMemo(() => {
|
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));
|
return isReplyNoteCreatorShowing || hideOn.some(a => location.pathname.startsWith(a));
|
||||||
}, [location, isReplyNoteCreatorShowing]);
|
}, [location, isReplyNoteCreatorShowing]);
|
||||||
|
|
||||||
@ -172,8 +175,15 @@ export default function Layout() {
|
|||||||
{!shouldHideHeader && (
|
{!shouldHideHeader && (
|
||||||
<header>
|
<header>
|
||||||
<div className="logo" onClick={() => navigate("/")}>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{publicKey ? (
|
{publicKey ? (
|
||||||
<AccountHeader />
|
<AccountHeader />
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useIntl, FormattedMessage } from "react-intl";
|
import { useIntl, FormattedMessage } from "react-intl";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { ApiHost } from "Const";
|
import { ApiHost } from "Const";
|
||||||
import Logo from "Element/Logo";
|
import Logo from "Element/Logo";
|
||||||
@ -8,12 +9,10 @@ import AsyncButton from "Element/AsyncButton";
|
|||||||
import FollowListBase from "Element/FollowListBase";
|
import FollowListBase from "Element/FollowListBase";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { bech32ToHex } from "Util";
|
import { bech32ToHex } from "Util";
|
||||||
import { useNavigate } from "react-router-dom";
|
import SnortApi from "SnortApi";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
const TwitterFollowsApi = `${ApiHost}/api/v1/twitter/follows-for-nostr`;
|
|
||||||
|
|
||||||
export default function ImportFollows() {
|
export default function ImportFollows() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const currentFollows = useSelector((s: RootState) => s.login.follows);
|
const currentFollows = useSelector((s: RootState) => s.login.follows);
|
||||||
@ -21,6 +20,7 @@ export default function ImportFollows() {
|
|||||||
const [twitterUsername, setTwitterUsername] = useState<string>("");
|
const [twitterUsername, setTwitterUsername] = useState<string>("");
|
||||||
const [follows, setFollows] = useState<string[]>([]);
|
const [follows, setFollows] = useState<string[]>([]);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
|
const api = new SnortApi(ApiHost);
|
||||||
|
|
||||||
const sortedTwitterFollows = useMemo(() => {
|
const sortedTwitterFollows = useMemo(() => {
|
||||||
return follows.map(a => bech32ToHex(a)).sort(a => (currentFollows.includes(a) ? 1 : -1));
|
return follows.map(a => bech32ToHex(a)).sort(a => (currentFollows.includes(a) ? 1 : -1));
|
||||||
@ -30,22 +30,19 @@ export default function ImportFollows() {
|
|||||||
setFollows([]);
|
setFollows([]);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const rsp = await fetch(`${TwitterFollowsApi}?username=${twitterUsername}`);
|
const rsp = await api.twitterImport(twitterUsername);
|
||||||
const data = await rsp.json();
|
if (Array.isArray(rsp) && rsp.length === 0) {
|
||||||
if (rsp.ok) {
|
setError(formatMessage(messages.NoUsersFound, { twitterUsername }));
|
||||||
if (Array.isArray(data) && data.length === 0) {
|
|
||||||
setError(formatMessage(messages.NoUsersFound, { twitterUsername }));
|
|
||||||
} else {
|
|
||||||
setFollows(data);
|
|
||||||
}
|
|
||||||
} else if ("error" in data) {
|
|
||||||
setError(data.error);
|
|
||||||
} else {
|
} else {
|
||||||
setError(formatMessage(messages.FailedToLoad));
|
setFollows(rsp);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(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>
|
||||||
<div className="settings-row" onClick={() => navigate("handle")}>
|
<div className="settings-row" onClick={() => navigate("handle")}>
|
||||||
<Icon name="badge" />
|
<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" />
|
<Icon name="arrowFront" />
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-row" onClick={handleLogout}>
|
<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 { RelaySettings } from "@snort/nostr";
|
||||||
import type { AppDispatch, RootState } from "State/Store";
|
import type { AppDispatch, RootState } from "State/Store";
|
||||||
import { ImgProxySettings } from "Hooks/useImgProxy";
|
import { ImgProxySettings } from "Hooks/useImgProxy";
|
||||||
import { sanitizeRelayUrl, unwrap } from "Util";
|
import { dedupeById, sanitizeRelayUrl, unwrap } from "Util";
|
||||||
import { DmCache } from "Cache";
|
import { DmCache } from "Cache";
|
||||||
|
import { getCurrentSubscription, SubscriptionEvent } from "Subscription";
|
||||||
|
|
||||||
const PrivateKeyItem = "secret";
|
const PrivateKeyItem = "secret";
|
||||||
const PublicKeyItem = "pubkey";
|
const PublicKeyItem = "pubkey";
|
||||||
@ -205,6 +206,16 @@ export interface LoginStore {
|
|||||||
* Users cusom preferences
|
* Users cusom preferences
|
||||||
*/
|
*/
|
||||||
preferences: UserPreferences;
|
preferences: UserPreferences;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription events for Snort subscriptions
|
||||||
|
*/
|
||||||
|
subscriptions: Array<SubscriptionEvent>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current Snort subscription
|
||||||
|
*/
|
||||||
|
subscription?: SubscriptionEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DefaultImgProxy = {
|
export const DefaultImgProxy = {
|
||||||
@ -235,6 +246,7 @@ export const InitState = {
|
|||||||
readNotifications: new Date().getTime(),
|
readNotifications: new Date().getTime(),
|
||||||
dms: [],
|
dms: [],
|
||||||
dmInteraction: 0,
|
dmInteraction: 0,
|
||||||
|
subscriptions: [],
|
||||||
preferences: {
|
preferences: {
|
||||||
enableReactions: true,
|
enableReactions: true,
|
||||||
reactionEmoji: "+",
|
reactionEmoji: "+",
|
||||||
@ -465,6 +477,10 @@ const LoginSlice = createSlice({
|
|||||||
state.preferences = action.payload;
|
state.preferences = action.payload;
|
||||||
window.localStorage.setItem(UserPreferencesKey, JSON.stringify(state.preferences));
|
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,
|
markNotificationsRead,
|
||||||
setLatestNotifications,
|
setLatestNotifications,
|
||||||
setPreferences,
|
setPreferences,
|
||||||
|
addSubscription,
|
||||||
} = LoginSlice.actions;
|
} = LoginSlice.actions;
|
||||||
|
|
||||||
export function sendNotification({
|
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[];
|
return deduped.list as TaggedRawEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dedupeById(events: TaggedRawEvent[]) {
|
export function dedupeById<T extends { id: string }>(events: Array<T>) {
|
||||||
const deduped = events.reduce(
|
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)) {
|
if (seen.has(ev.id)) {
|
||||||
return { list, seen };
|
return { list, seen };
|
||||||
}
|
}
|
||||||
@ -193,7 +193,7 @@ export function dedupeById(events: TaggedRawEvent[]) {
|
|||||||
},
|
},
|
||||||
{ list: [], seen: new Set([]) }
|
{ 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;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card .card-title {
|
||||||
|
font-size: x-large;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
|
@ -9,6 +9,7 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
|||||||
|
|
||||||
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
|
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
|
||||||
import { IntlProvider } from "IntlProvider";
|
import { IntlProvider } from "IntlProvider";
|
||||||
|
import { unwrap } from "Util";
|
||||||
import Store from "State/Store";
|
import Store from "State/Store";
|
||||||
import Layout from "Pages/Layout";
|
import Layout from "Pages/Layout";
|
||||||
import LoginPage from "Pages/Login";
|
import LoginPage from "Pages/Login";
|
||||||
@ -28,7 +29,7 @@ import { NewUserRoutes } from "Pages/new";
|
|||||||
import { WalletRoutes } from "Pages/WalletPage";
|
import { WalletRoutes } from "Pages/WalletPage";
|
||||||
import NostrLinkHandler from "Pages/NostrLinkHandler";
|
import NostrLinkHandler from "Pages/NostrLinkHandler";
|
||||||
import Thread from "Element/Thread";
|
import Thread from "Element/Thread";
|
||||||
import { unwrap } from "Util";
|
import { SubscribeRoutes } from "Pages/subscribe";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP query provider
|
* HTTP query provider
|
||||||
@ -94,6 +95,7 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
...NewUserRoutes,
|
...NewUserRoutes,
|
||||||
...WalletRoutes,
|
...WalletRoutes,
|
||||||
|
...SubscribeRoutes,
|
||||||
{
|
{
|
||||||
path: "/*",
|
path: "/*",
|
||||||
element: <NostrLinkHandler />,
|
element: <NostrLinkHandler />,
|
||||||
|
@ -9,6 +9,7 @@ enum EventKind {
|
|||||||
Repost = 6, // NIP-18
|
Repost = 6, // NIP-18
|
||||||
Reaction = 7, // NIP-25
|
Reaction = 7, // NIP-25
|
||||||
BadgeAward = 8, // NIP-58
|
BadgeAward = 8, // NIP-58
|
||||||
|
SnortSubscriptions = 1000, // NIP-XX
|
||||||
Polls = 6969, // NIP-69
|
Polls = 6969, // NIP-69
|
||||||
Relays = 10002, // NIP-65
|
Relays = 10002, // NIP-65
|
||||||
Ephemeral = 20_000,
|
Ephemeral = 20_000,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user