feat: subscriptions

This commit is contained in:
Kieran 2023-04-13 19:43:43 +01:00
parent c3c1e02ad8
commit f0c5c33c48
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
19 changed files with 531 additions and 58 deletions

View File

@ -385,5 +385,14 @@
stroke-linejoin="round"
/>
</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>
</svg>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

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

View File

@ -9,6 +9,7 @@ enum EventKind {
Repost = 6, // NIP-18
Reaction = 7, // NIP-25
BadgeAward = 8, // NIP-58
SnortSubscriptions = 1000, // NIP-XX
Polls = 6969, // NIP-69
Relays = 10002, // NIP-65
Ephemeral = 20_000,