From f0c5c33c48569894c557de33e4f42f92b35965c0 Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 13 Apr 2023 19:43:43 +0100 Subject: [PATCH] feat: subscriptions --- packages/app/public/icons.svg | 9 ++ packages/app/src/Element/PageSpinner.tsx | 9 ++ packages/app/src/Feed/EventPublisher.ts | 2 +- packages/app/src/Feed/LoginFeed.ts | 29 ++++- packages/app/src/Pages/DonatePage.tsx | 35 ++--- packages/app/src/Pages/Layout.css | 14 +- packages/app/src/Pages/Layout.tsx | 16 ++- packages/app/src/Pages/new/ImportFollows.tsx | 27 ++-- packages/app/src/Pages/settings/Index.tsx | 7 +- .../Pages/subscribe/ManageSubscription.tsx | 123 ++++++++++++++++++ packages/app/src/Pages/subscribe/index.css | 24 ++++ packages/app/src/Pages/subscribe/index.tsx | 113 ++++++++++++++++ packages/app/src/SnortApi.ts | 100 ++++++++++++++ packages/app/src/State/Login.ts | 19 ++- packages/app/src/Subscription/index.ts | 46 +++++++ packages/app/src/Util.ts | 6 +- packages/app/src/index.css | 5 + packages/app/src/index.tsx | 4 +- packages/nostr/src/legacy/EventKind.ts | 1 + 19 files changed, 531 insertions(+), 58 deletions(-) create mode 100644 packages/app/src/Element/PageSpinner.tsx create mode 100644 packages/app/src/Pages/subscribe/ManageSubscription.tsx create mode 100644 packages/app/src/Pages/subscribe/index.css create mode 100644 packages/app/src/Pages/subscribe/index.tsx create mode 100644 packages/app/src/SnortApi.ts create mode 100644 packages/app/src/Subscription/index.ts diff --git a/packages/app/public/icons.svg b/packages/app/public/icons.svg index ea768eedf..873faecf2 100644 --- a/packages/app/public/icons.svg +++ b/packages/app/public/icons.svg @@ -385,5 +385,14 @@ stroke-linejoin="round" /> + + + \ No newline at end of file diff --git a/packages/app/src/Element/PageSpinner.tsx b/packages/app/src/Element/PageSpinner.tsx new file mode 100644 index 000000000..f6e10d74e --- /dev/null +++ b/packages/app/src/Element/PageSpinner.tsx @@ -0,0 +1,9 @@ +import Spinner from "Icons/Spinner"; + +export default function PageSpinner() { + return ( +
+ +
+ ); +} diff --git a/packages/app/src/Feed/EventPublisher.ts b/packages/app/src/Feed/EventPublisher.ts index c97aa6a6d..92cf560cc 100644 --- a/packages/app/src/Feed/EventPublisher.ts +++ b/packages/app/src/Feed/EventPublisher.ts @@ -354,7 +354,7 @@ export default function useEventPublisher() { return ""; } 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) { diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index 3c13c0136..44277aba1 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -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]); diff --git a/packages/app/src/Pages/DonatePage.tsx b/packages/app/src/Pages/DonatePage.tsx index aa0eb3f75..08091f045 100644 --- a/packages/app/src/Pages/DonatePage.tsx +++ b/packages/app/src/Pages/DonatePage.tsx @@ -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([]); - const [today, setSumToday] = useState(); + const [splits, setSplits] = useState([]); + const [today, setSumToday] = useState(); + 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(() => { diff --git a/packages/app/src/Pages/Layout.css b/packages/app/src/Pages/Layout.css index 521d306e4..def9d952b 100644 --- a/packages/app/src/Pages/Layout.css +++ b/packages/app/src/Pages/Layout.css @@ -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 { diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index 3bee284f6..300862630 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -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 && (
navigate("/")}> - Snort +

Snort

+ {subscription && ( + + + {mapPlanName(subscription.type)} + + )}
+
{publicKey ? ( diff --git a/packages/app/src/Pages/new/ImportFollows.tsx b/packages/app/src/Pages/new/ImportFollows.tsx index 61ad77ec6..1ac5e458c 100644 --- a/packages/app/src/Pages/new/ImportFollows.tsx +++ b/packages/app/src/Pages/new/ImportFollows.tsx @@ -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(""); const [follows, setFollows] = useState([]); const [error, setError] = useState(""); + 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)); + } } } diff --git a/packages/app/src/Pages/settings/Index.tsx b/packages/app/src/Pages/settings/Index.tsx index d5f7190af..d04332d16 100644 --- a/packages/app/src/Pages/settings/Index.tsx +++ b/packages/app/src/Pages/settings/Index.tsx @@ -49,7 +49,12 @@ const SettingsIndex = () => {
navigate("handle")}> - + + +
+
navigate("/subscribe/manage")}> + +
diff --git a/packages/app/src/Pages/subscribe/ManageSubscription.tsx b/packages/app/src/Pages/subscribe/ManageSubscription.tsx new file mode 100644 index 000000000..6ed3a7879 --- /dev/null +++ b/packages/app/src/Pages/subscribe/ManageSubscription.tsx @@ -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>(); + 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 ; + } + return ( + <> +

+ +

+ {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 ( +
+
+ + {mapPlanName(a.type)} +
+
+

+ + :  + +

+ {daysToExpire >= 1 && ( +

+ + :  + +

+ )} + {daysToExpire >= 0 && daysToExpire < 1 && ( +

+ + :  + +

+ )} + {daysToExpire < 0 && ( +

+ +

+ )} +
+ {isExpired && ( +
+ +
+ )} +
+ ); + })} + {subs.length === 0 && ( +

+ + + + ), + }} + /> +

+ )} + {error && {error}} + + ); +} diff --git a/packages/app/src/Pages/subscribe/index.css b/packages/app/src/Pages/subscribe/index.css new file mode 100644 index 000000000..7ed57188f --- /dev/null +++ b/packages/app/src/Pages/subscribe/index.css @@ -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; + } +} diff --git a/packages/app/src/Pages/subscribe/index.tsx b/packages/app/src/Pages/subscribe/index.tsx new file mode 100644 index 000000000..316a7409f --- /dev/null +++ b/packages/app/src/Pages/subscribe/index.tsx @@ -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 ; + case SubscriptionType.Premium: + return ; + } +} + +export function mapFeatureName(k: LockedFeatures) { + switch (k) { + case LockedFeatures.MultiAccount: + return ; + case LockedFeatures.NostrAddress: + return ; + case LockedFeatures.Badge: + return ; + case LockedFeatures.DeepL: + return ; + case LockedFeatures.RelayRetention: + return ; + case LockedFeatures.RelayBackup: + return ; + } +} + +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 ( +
+ {Plans.map(a => { + const lower = Plans.filter(b => b.id < a.id); + return ( +
+
+

{mapPlanName(a.id)}

+

+ {formatShort(a.price)}, + }} + /> + : +

+
    + {a.unlocks.map(b => ( +
  • {mapFeatureName(b)}
  • + ))} + {lower.map(b => ( +
  • + +
  • + ))} +
+
+
+ subscribe(a.id)}> + {a.disabled ? ( + + ) : ( + + )} + +
+
+ ); + })} + setInvoice("")} /> +
+ ); +} + +export const SubscribeRoutes = [ + { + path: "/subscribe", + element: , + }, + { + path: "/subscribe/manage", + element: , + }, +] as RouteObject[]; diff --git a/packages/app/src/SnortApi.ts b/packages/app/src/SnortApi.ts new file mode 100644 index 000000000..c168c7dda --- /dev/null +++ b/packages/app/src/SnortApi.ts @@ -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>("api/v1/revenue/splits"); + } + + revenueToday() { + return this.#getJson("api/v1/revenue/today"); + } + + twitterImport(username: string) { + return this.#getJson>(`api/v1/twitter/follows-for-nostr?username=${encodeURIComponent(username)}`); + } + + createSubscription(type: number) { + return this.#getJsonAuthd(`api/v1/subscription?type=${type}`, "PUT"); + } + + listSubscriptions() { + return this.#getJsonAuthd>("api/v1/subscription"); + } + + async #getJsonAuthd( + path: string, + method?: "GET" | string, + body?: { [key: string]: string }, + headers?: { [key: string]: string } + ): Promise { + 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(path, method, body, { + ...headers, + authorization: `Nostr ${window.btoa(JSON.stringify(auth))}`, + }); + } + + async #getJson( + path: string, + method?: "GET" | string, + body?: { [key: string]: string }, + headers?: { [key: string]: string } + ): Promise { + 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; + } +} diff --git a/packages/app/src/State/Login.ts b/packages/app/src/State/Login.ts index 6995d3200..5430f94a5 100644 --- a/packages/app/src/State/Login.ts +++ b/packages/app/src/State/Login.ts @@ -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; + + /** + * 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>) => { + 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({ diff --git a/packages/app/src/Subscription/index.ts b/packages/app/src/Subscription/index.ts new file mode 100644 index 000000000..a3db06fad --- /dev/null +++ b/packages/app/src/Subscription/index.ts @@ -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) { + const now = unixNow(); + return s.filter(a => a.start <= now && a.end > now); +} + +export function getCurrentSubscription(s: Array) { + return getActiveSubscriptions(s)[0]; +} diff --git a/packages/app/src/Util.ts b/packages/app/src/Util.ts index 1a44d78e1..6cf49248e 100644 --- a/packages/app/src/Util.ts +++ b/packages/app/src/Util.ts @@ -179,9 +179,9 @@ export function dedupeByPubkey(events: TaggedRawEvent[]) { return deduped.list as TaggedRawEvent[]; } -export function dedupeById(events: TaggedRawEvent[]) { +export function dedupeById(events: Array) { const deduped = events.reduce( - ({ list, seen }: { list: TaggedRawEvent[]; seen: Set }, ev) => { + ({ list, seen }: { list: Array; seen: Set }, 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; } /** diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 4474f2fe6..9f6c335c2 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -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; diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index c78d20060..0f4f420c8 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -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: , diff --git a/packages/nostr/src/legacy/EventKind.ts b/packages/nostr/src/legacy/EventKind.ts index 516ffb4fb..370f56d7f 100644 --- a/packages/nostr/src/legacy/EventKind.ts +++ b/packages/nostr/src/legacy/EventKind.ts @@ -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,