From fe788853c907fa4cbe922afc9e254122e9a4cffa Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 14 Apr 2023 12:33:19 +0100 Subject: [PATCH 1/8] feat: multi-account system --- packages/app/src/Const.ts | 9 + packages/app/src/Element/Bookmarks.tsx | 6 +- packages/app/src/Element/DM.tsx | 10 +- packages/app/src/Element/FollowButton.tsx | 8 +- packages/app/src/Element/LogoutButton.tsx | 14 +- packages/app/src/Element/MixCloudEmbed.tsx | 7 +- packages/app/src/Element/Nip05.tsx | 2 +- packages/app/src/Element/Nip5Service.tsx | 7 +- packages/app/src/Element/Note.tsx | 27 +- packages/app/src/Element/NoteFooter.tsx | 36 +- packages/app/src/Element/Poll.tsx | 6 +- packages/app/src/Element/Relay.tsx | 27 +- packages/app/src/Element/RevealMedia.tsx | 10 +- packages/app/src/Element/SendSats.tsx | 5 +- packages/app/src/Element/Zap.tsx | 3 +- packages/app/src/ExternalStore.ts | 41 ++ packages/app/src/Feed/BookmarkFeed.tsx | 7 +- packages/app/src/Feed/EventPublisher.ts | 14 +- packages/app/src/Feed/FollowsFeed.ts | 7 +- packages/app/src/Feed/LoginFeed.ts | 77 +-- packages/app/src/Feed/MuteList.ts | 12 +- packages/app/src/Feed/PinnedFeed.tsx | 6 +- packages/app/src/Feed/ThreadFeed.ts | 6 +- packages/app/src/Feed/TimelineFeed.ts | 6 +- packages/app/src/Hooks/useImgProxy.ts | 5 +- packages/app/src/Hooks/useLogin.tsx | 9 + packages/app/src/Hooks/useModeration.tsx | 91 ++- .../app/src/Hooks/useNotelistSubscription.ts | 5 +- packages/app/src/IntlProvider.tsx | 4 +- packages/app/src/Login/Functions.ts | 143 +++++ packages/app/src/Login/LoginSession.ts | 88 +++ packages/app/src/Login/MultiAccountStore.ts | 181 ++++++ packages/app/src/Login/Preferences.ts | 90 +++ packages/app/src/Login/index.ts | 6 + packages/app/src/Notifications.ts | 26 +- packages/app/src/Pages/ChatPage.tsx | 10 +- packages/app/src/Pages/HashTagsPage.tsx | 34 +- packages/app/src/Pages/Layout.tsx | 57 +- packages/app/src/Pages/Login.tsx | 64 +-- packages/app/src/Pages/MessagesPage.tsx | 36 +- packages/app/src/Pages/NostrLinkHandler.tsx | 17 +- packages/app/src/Pages/Notifications.tsx | 18 +- packages/app/src/Pages/ProfilePage.tsx | 5 +- packages/app/src/Pages/Root.tsx | 19 +- .../app/src/Pages/new/DiscoverFollows.tsx | 11 +- packages/app/src/Pages/new/GetVerified.tsx | 5 +- packages/app/src/Pages/new/ImportFollows.tsx | 7 +- packages/app/src/Pages/new/NewUserFlow.tsx | 5 +- packages/app/src/Pages/settings/Index.tsx | 14 +- .../app/src/Pages/settings/Preferences.tsx | 142 ++--- packages/app/src/Pages/settings/Profile.tsx | 12 +- packages/app/src/Pages/settings/RelayInfo.tsx | 8 +- packages/app/src/Pages/settings/Relays.tsx | 24 +- packages/app/src/State/Login.ts | 537 ------------------ packages/app/src/State/Store.ts | 2 - packages/app/src/Tasks/TaskList.tsx | 5 +- packages/app/src/Upload/index.ts | 5 +- packages/app/src/Util.ts | 8 + 58 files changed, 966 insertions(+), 1080 deletions(-) create mode 100644 packages/app/src/ExternalStore.ts create mode 100644 packages/app/src/Hooks/useLogin.tsx create mode 100644 packages/app/src/Login/Functions.ts create mode 100644 packages/app/src/Login/LoginSession.ts create mode 100644 packages/app/src/Login/MultiAccountStore.ts create mode 100644 packages/app/src/Login/Preferences.ts create mode 100644 packages/app/src/Login/index.ts delete mode 100644 packages/app/src/State/Login.ts diff --git a/packages/app/src/Const.ts b/packages/app/src/Const.ts index 47bd2ef9..acdaa7ef 100644 --- a/packages/app/src/Const.ts +++ b/packages/app/src/Const.ts @@ -74,6 +74,15 @@ export const RecommendedFollows = [ "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha ]; +/** + * Snort imgproxy details + */ +export const DefaultImgProxy = { + url: "https://imgproxy.snort.social", + key: "a82fcf26aa0ccb55dfc6b4bd6a1c90744d3be0f38429f21a8828b43449ce7cebe6bdc2b09a827311bef37b18ce35cb1e6b1c60387a254541afa9e5b4264ae942", + salt: "a897770d9abf163de055e9617891214e75a9016d748f8ef865e6ffbcb9ed932295659549773a22a019a5f06d0b440c320be411e3fddfe784e199e4f03d74bd9b", +}; + /** * NIP06-defined derivation path for private keys */ diff --git a/packages/app/src/Element/Bookmarks.tsx b/packages/app/src/Element/Bookmarks.tsx index f74d6dbb..dda75eec 100644 --- a/packages/app/src/Element/Bookmarks.tsx +++ b/packages/app/src/Element/Bookmarks.tsx @@ -1,13 +1,13 @@ import { useState, useMemo, ChangeEvent } from "react"; -import { useSelector } from "react-redux"; import { FormattedMessage } from "react-intl"; import { HexKey, TaggedRawEvent } from "@snort/nostr"; import Note from "Element/Note"; -import { RootState } from "State/Store"; +import useLogin from "Hooks/useLogin"; import { UserCache } from "Cache/UserCache"; import messages from "./messages"; + interface BookmarksProps { pubkey: HexKey; bookmarks: readonly TaggedRawEvent[]; @@ -16,7 +16,7 @@ interface BookmarksProps { const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => { const [onlyPubkey, setOnlyPubkey] = useState("all"); - const loginPubKey = useSelector((s: RootState) => s.login.publicKey); + const loginPubKey = useLogin().publicKey; const ps = useMemo(() => { return [...new Set(bookmarks.map(ev => ev.pubkey))]; }, [bookmarks]); diff --git a/packages/app/src/Element/DM.tsx b/packages/app/src/Element/DM.tsx index 8ebb5498..72d2c1a0 100644 --- a/packages/app/src/Element/DM.tsx +++ b/packages/app/src/Element/DM.tsx @@ -1,17 +1,15 @@ import "./DM.css"; import { useEffect, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; import { useIntl } from "react-intl"; import { useInView } from "react-intersection-observer"; -import { HexKey, TaggedRawEvent } from "@snort/nostr"; +import { TaggedRawEvent } from "@snort/nostr"; import useEventPublisher from "Feed/EventPublisher"; import NoteTime from "Element/NoteTime"; import Text from "Element/Text"; import { setLastReadDm } from "Pages/MessagesPage"; -import { RootState } from "State/Store"; -import { incDmInteraction } from "State/Login"; import { unwrap } from "Util"; +import useLogin from "Hooks/useLogin"; import messages from "./messages"; @@ -20,8 +18,7 @@ export type DMProps = { }; export default function DM(props: DMProps) { - const dispatch = useDispatch(); - const pubKey = useSelector(s => s.login.publicKey); + const pubKey = useLogin().publicKey; const publisher = useEventPublisher(); const [content, setContent] = useState("Loading..."); const [decrypted, setDecrypted] = useState(false); @@ -35,7 +32,6 @@ export default function DM(props: DMProps) { setContent(decrypted || ""); if (!isMe) { setLastReadDm(props.data.pubkey); - dispatch(incDmInteraction()); } } diff --git a/packages/app/src/Element/FollowButton.tsx b/packages/app/src/Element/FollowButton.tsx index 93eaab98..159e2060 100644 --- a/packages/app/src/Element/FollowButton.tsx +++ b/packages/app/src/Element/FollowButton.tsx @@ -1,10 +1,10 @@ import "./FollowButton.css"; -import { useSelector } from "react-redux"; import { FormattedMessage } from "react-intl"; -import useEventPublisher from "Feed/EventPublisher"; import { HexKey } from "@snort/nostr"; -import { RootState } from "State/Store"; + +import useEventPublisher from "Feed/EventPublisher"; import { parseId } from "Util"; +import useLogin from "Hooks/useLogin"; import messages from "./messages"; @@ -15,7 +15,7 @@ export interface FollowButtonProps { export default function FollowButton(props: FollowButtonProps) { const pubkey = parseId(props.pubkey); const publiser = useEventPublisher(); - const isFollowing = useSelector(s => s.login.follows?.includes(pubkey) ?? false); + const isFollowing = useLogin().follows.item.includes(pubkey); const baseClassname = `${props.className} follow-button`; async function follow(pubkey: HexKey) { diff --git a/packages/app/src/Element/LogoutButton.tsx b/packages/app/src/Element/LogoutButton.tsx index 99e6b5c0..4a941434 100644 --- a/packages/app/src/Element/LogoutButton.tsx +++ b/packages/app/src/Element/LogoutButton.tsx @@ -1,24 +1,22 @@ -import { useDispatch } from "react-redux"; import { FormattedMessage } from "react-intl"; import { useNavigate } from "react-router-dom"; -import { logout } from "State/Login"; +import { logout } from "Login"; +import useLogin from "Hooks/useLogin"; import messages from "./messages"; export default function LogoutButton() { - const dispatch = useDispatch(); const navigate = useNavigate(); + const publicKey = useLogin().publicKey; + if (!publicKey) return; return ( diff --git a/packages/app/src/Element/MixCloudEmbed.tsx b/packages/app/src/Element/MixCloudEmbed.tsx index c24629a0..d3c5aa01 100644 --- a/packages/app/src/Element/MixCloudEmbed.tsx +++ b/packages/app/src/Element/MixCloudEmbed.tsx @@ -1,14 +1,11 @@ import { MixCloudRegex } from "Const"; -import { useSelector } from "react-redux"; -import { RootState } from "State/Store"; +import useLogin from "Hooks/useLogin"; const MixCloudEmbed = ({ link }: { link: string }) => { const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2); - const lightTheme = useSelector(s => s.login.preferences.theme === "light"); - + const lightTheme = useLogin().preferences.theme === "light"; const lightParams = lightTheme ? "light=1" : "light=0"; - return ( <>
diff --git a/packages/app/src/Element/Nip05.tsx b/packages/app/src/Element/Nip05.tsx index 975a3482..3ae6f093 100644 --- a/packages/app/src/Element/Nip05.tsx +++ b/packages/app/src/Element/Nip05.tsx @@ -1,7 +1,7 @@ import "./Nip05.css"; import { useQuery } from "react-query"; -import Icon from "Icons/Icon"; import { HexKey } from "@snort/nostr"; +import Icon from "Icons/Icon"; interface NostrJson { names: Record; diff --git a/packages/app/src/Element/Nip5Service.tsx b/packages/app/src/Element/Nip5Service.tsx index 8f10835d..b28e688a 100644 --- a/packages/app/src/Element/Nip5Service.tsx +++ b/packages/app/src/Element/Nip5Service.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState, ChangeEvent } from "react"; import { useIntl, FormattedMessage } from "react-intl"; -import { useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; +import { UserMetadata } from "@snort/nostr"; import { unwrap } from "Util"; import { formatShort } from "Number"; @@ -20,10 +20,9 @@ import Copy from "Element/Copy"; import { useUserProfile } from "Hooks/useUserProfile"; import useEventPublisher from "Feed/EventPublisher"; import { debounce } from "Util"; -import { UserMetadata } from "@snort/nostr"; +import useLogin from "Hooks/useLogin"; import messages from "./messages"; -import { RootState } from "State/Store"; type Nip05ServiceProps = { name: string; @@ -40,7 +39,7 @@ export default function Nip5Service(props: Nip05ServiceProps) { const navigate = useNavigate(); const { helpText = true } = props; const { formatMessage } = useIntl(); - const pubkey = useSelector((s: RootState) => s.login.publicKey); + const pubkey = useLogin().publicKey; const user = useUserProfile(pubkey); const publisher = useEventPublisher(); const svc = useMemo(() => new ServiceProvider(props.service), [props.service]); diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index 91276548..f34d2126 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -1,7 +1,6 @@ import "./Note.css"; import React, { useMemo, useState, useLayoutEffect, ReactNode } from "react"; import { useNavigate, Link } from "react-router-dom"; -import { useSelector, useDispatch } from "react-redux"; import { useInView } from "react-intersection-observer"; import { useIntl, FormattedMessage } from "react-intl"; import { TaggedRawEvent, HexKey, EventKind, NostrPrefix } from "@snort/nostr"; @@ -25,13 +24,13 @@ import NoteFooter, { Translation } from "Element/NoteFooter"; import NoteTime from "Element/NoteTime"; import Reveal from "Element/Reveal"; import useModeration from "Hooks/useModeration"; -import { setPinned, setBookmarked } from "State/Login"; -import type { RootState } from "State/Store"; import { UserCache } from "Cache/UserCache"; import Poll from "Element/Poll"; +import { EventExt } from "System/EventExt"; +import useLogin from "Hooks/useLogin"; +import { setBookmarked, setPinned } from "Login"; import messages from "./messages"; -import { EventExt } from "System/EventExt"; export interface NoteProps { data: TaggedRawEvent; @@ -72,7 +71,6 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => { export default function Note(props: NoteProps) { const navigate = useNavigate(); - const dispatch = useDispatch(); const { data: ev, related, highlight, options: opt, ignoreModeration = false } = props; const [showReactions, setShowReactions] = useState(false); const deletions = useMemo(() => getReactions(related, ev.id, EventKind.Deletion), [related]); @@ -82,7 +80,8 @@ export default function Note(props: NoteProps) { const [extendable, setExtendable] = useState(false); const [showMore, setShowMore] = useState(false); const baseClassName = `note card ${props.className ? props.className : ""}`; - const { pinned, bookmarked } = useSelector((s: RootState) => s.login); + const login = useLogin(); + const { pinned, bookmarked } = login; const publisher = useEventPublisher(); const [translated, setTranslated] = useState(); const { formatMessage } = useIntl(); @@ -135,10 +134,12 @@ export default function Note(props: NoteProps) { async function unpin(id: HexKey) { if (options.canUnpin) { if (window.confirm(formatMessage(messages.ConfirmUnpin))) { - const es = pinned.filter(e => e !== id); + const es = pinned.item.filter(e => e !== id); const ev = await publisher.pinned(es); - publisher.broadcast(ev); - dispatch(setPinned({ keys: es, createdAt: new Date().getTime() })); + if (ev) { + publisher.broadcast(ev); + setPinned(login, es, ev.created_at * 1000); + } } } } @@ -146,10 +147,12 @@ export default function Note(props: NoteProps) { async function unbookmark(id: HexKey) { if (options.canUnbookmark) { if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) { - const es = bookmarked.filter(e => e !== id); + const es = bookmarked.item.filter(e => e !== id); const ev = await publisher.bookmarked(es); - publisher.broadcast(ev); - dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() })); + if (ev) { + publisher.broadcast(ev); + setBookmarked(login, es, ev.created_at * 1000); + } } } } diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index 33fdd589..d4ece503 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -17,13 +17,14 @@ import SendSats from "Element/SendSats"; import { ParsedZap, ZapsSummary } from "Element/Zap"; import { useUserProfile } from "Hooks/useUserProfile"; import { RootState } from "State/Store"; -import { UserPreferences, setPinned, setBookmarked } from "State/Login"; import { setReplyTo, setShow, reset } from "State/NoteCreator"; import useModeration from "Hooks/useModeration"; import { SnortPubKey, TranslateHost } from "Const"; import { LNURL } from "LNURL"; import { DonateLNURL } from "Pages/DonatePage"; import { useWallet } from "Wallet"; +import useLogin from "Hooks/useLogin"; +import { setBookmarked, setPinned } from "Login"; import messages from "./messages"; @@ -94,10 +95,9 @@ export default function NoteFooter(props: NoteFooterProps) { const { ev, showReactions, setShowReactions, positive, negative, reposts, zaps } = props; const dispatch = useDispatch(); const { formatMessage } = useIntl(); - const { pinned, bookmarked } = useSelector((s: RootState) => s.login); - const login = useSelector(s => s.login.publicKey); + const login = useLogin(); + const { pinned, bookmarked, publicKey, preferences: prefs } = login; const { mute, block } = useModeration(); - const prefs = useSelector(s => s.login.preferences); const author = useUserProfile(ev.pubkey); const publisher = useEventPublisher(); const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show); @@ -108,13 +108,13 @@ export default function NoteFooter(props: NoteFooterProps) { const walletState = useWallet(); const wallet = walletState.wallet; - const isMine = ev.pubkey === login; + const isMine = ev.pubkey === publicKey; const lang = window.navigator.language; const langNames = new Intl.DisplayNames([...window.navigator.languages], { type: "language", }); const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0); - const didZap = ZapCache.has(ev.id) || zaps.some(a => a.sender === login); + const didZap = ZapCache.has(ev.id) || zaps.some(a => a.sender === publicKey); const longPress = useLongPress( e => { e.stopPropagation(); @@ -126,11 +126,11 @@ export default function NoteFooter(props: NoteFooterProps) { ); function hasReacted(emoji: string) { - return positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login); + return positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey); } function hasReposted() { - return reposts.some(a => a.pubkey === login); + return reposts.some(a => a.pubkey === publicKey); } async function react(content: string) { @@ -320,17 +320,21 @@ export default function NoteFooter(props: NoteFooterProps) { } async function pin(id: HexKey) { - const es = [...pinned, id]; + const es = [...pinned.item, id]; const ev = await publisher.pinned(es); - publisher.broadcast(ev); - dispatch(setPinned({ keys: es, createdAt: new Date().getTime() })); + if (ev) { + publisher.broadcast(ev); + setPinned(login, es, ev.created_at * 1000); + } } async function bookmark(id: HexKey) { - const es = [...bookmarked, id]; + const es = [...bookmarked.item, id]; const ev = await publisher.bookmarked(es); - publisher.broadcast(ev); - dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() })); + if (ev) { + publisher.broadcast(ev); + setBookmarked(login, es, ev.created_at * 1000); + } } async function copyEvent() { @@ -355,13 +359,13 @@ export default function NoteFooter(props: NoteFooterProps) { - {!pinned.includes(ev.id) && ( + {!pinned.item.includes(ev.id) && ( pin(ev.id)}> )} - {!bookmarked.includes(ev.id) && ( + {!bookmarked.item.includes(ev.id) && ( bookmark(ev.id)}> diff --git a/packages/app/src/Element/Poll.tsx b/packages/app/src/Element/Poll.tsx index df0d4083..562526da 100644 --- a/packages/app/src/Element/Poll.tsx +++ b/packages/app/src/Element/Poll.tsx @@ -1,12 +1,10 @@ import { TaggedRawEvent } from "@snort/nostr"; import { useState } from "react"; -import { useSelector } from "react-redux"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import { ParsedZap } from "Element/Zap"; import Text from "Element/Text"; import useEventPublisher from "Feed/EventPublisher"; -import { RootState } from "State/Store"; import { useWallet } from "Wallet"; import { useUserProfile } from "Hooks/useUserProfile"; import { LNURL } from "LNURL"; @@ -14,6 +12,7 @@ import { unwrap } from "Util"; import { formatShort } from "Number"; import Spinner from "Icons/Spinner"; import SendSats from "Element/SendSats"; +import useLogin from "Hooks/useLogin"; interface PollProps { ev: TaggedRawEvent; @@ -24,8 +23,7 @@ export default function Poll(props: PollProps) { const { formatMessage } = useIntl(); const publisher = useEventPublisher(); const { wallet } = useWallet(); - const prefs = useSelector((s: RootState) => s.login.preferences); - const myPubKey = useSelector((s: RootState) => s.login.publicKey); + const { preferences: prefs, publicKey: myPubKey } = useLogin(); const pollerProfile = useUserProfile(props.ev.pubkey); const [error, setError] = useState(""); const [invoice, setInvoice] = useState(""); diff --git a/packages/app/src/Element/Relay.tsx b/packages/app/src/Element/Relay.tsx index ff69c018..989f22cd 100644 --- a/packages/app/src/Element/Relay.tsx +++ b/packages/app/src/Element/Relay.tsx @@ -2,7 +2,6 @@ import "./Relay.css"; import { useMemo } from "react"; import { useIntl, FormattedMessage } from "react-intl"; import { useNavigate } from "react-router-dom"; -import { useDispatch, useSelector } from "react-redux"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faPlug, @@ -16,35 +15,33 @@ import { import { RelaySettings } from "@snort/nostr"; import useRelayState from "Feed/RelayState"; -import { setRelays } from "State/Login"; -import { RootState } from "State/Store"; import { System } from "System"; -import { getRelayName, unwrap } from "Util"; +import { getRelayName, unixNowMs, unwrap } from "Util"; import messages from "./messages"; +import useLogin from "Hooks/useLogin"; +import { setRelays } from "Login"; export interface RelayProps { addr: string; } export default function Relay(props: RelayProps) { - const dispatch = useDispatch(); const { formatMessage } = useIntl(); const navigate = useNavigate(); - const allRelaySettings = useSelector>(s => s.login.relays); - const relaySettings = unwrap(allRelaySettings[props.addr] ?? System.Sockets.get(props.addr)?.Settings ?? {}); + const login = useLogin(); + const relaySettings = unwrap(login.relays.item[props.addr] ?? System.Sockets.get(props.addr)?.Settings ?? {}); const state = useRelayState(props.addr); const name = useMemo(() => getRelayName(props.addr), [props.addr]); function configure(o: RelaySettings) { - dispatch( - setRelays({ - relays: { - ...allRelaySettings, - [props.addr]: o, - }, - createdAt: Math.floor(new Date().getTime() / 1000), - }) + setRelays( + login, + { + ...login.relays.item, + [props.addr]: o, + }, + unixNowMs() ); } diff --git a/packages/app/src/Element/RevealMedia.tsx b/packages/app/src/Element/RevealMedia.tsx index 5feeceb7..754b8b48 100644 --- a/packages/app/src/Element/RevealMedia.tsx +++ b/packages/app/src/Element/RevealMedia.tsx @@ -1,9 +1,8 @@ import { FormattedMessage } from "react-intl"; -import { useSelector } from "react-redux"; import MediaLink from "Element/MediaLink"; import Reveal from "Element/Reveal"; -import { RootState } from "State/Store"; +import useLogin from "Hooks/useLogin"; interface RevealMediaProps { creator: string; @@ -11,11 +10,10 @@ interface RevealMediaProps { } export default function RevealMedia(props: RevealMediaProps) { - const pref = useSelector((s: RootState) => s.login.preferences); - const follows = useSelector((s: RootState) => s.login.follows); - const publicKey = useSelector((s: RootState) => s.login.publicKey); + const login = useLogin(); + const { preferences: pref, follows, publicKey } = login; - const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.includes(props.creator); + const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.item.includes(props.creator); const isMine = props.creator === publicKey; const hideMedia = pref.autoLoadMedia === "none" || (!isMine && hideNonFollows); const hostname = new URL(props.link).hostname; diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx index 825eef5f..287356a1 100644 --- a/packages/app/src/Element/SendSats.tsx +++ b/packages/app/src/Element/SendSats.tsx @@ -1,11 +1,9 @@ import "./SendSats.css"; import React, { useEffect, useMemo, useState } from "react"; import { useIntl, FormattedMessage } from "react-intl"; -import { useSelector } from "react-redux"; import { HexKey, RawEvent } from "@snort/nostr"; import { formatShort } from "Number"; -import { RootState } from "State/Store"; import Icon from "Icons/Icon"; import useEventPublisher from "Feed/EventPublisher"; import ProfileImage from "Element/ProfileImage"; @@ -18,6 +16,7 @@ import { useWallet } from "Wallet"; import { EventExt } from "System/EventExt"; import messages from "./messages"; +import useLogin from "Hooks/useLogin"; enum ZapType { PublicZap = 1, @@ -41,7 +40,7 @@ export interface SendSatsProps { export default function SendSats(props: SendSatsProps) { const onClose = props.onClose || (() => undefined); const { note, author, target } = props; - const defaultZapAmount = useSelector((s: RootState) => s.login.preferences.defaultZapAmount); + const defaultZapAmount = useLogin().preferences.defaultZapAmount; const amounts = [defaultZapAmount, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000]; const emojis: Record = { 1_000: "👍", diff --git a/packages/app/src/Element/Zap.tsx b/packages/app/src/Element/Zap.tsx index fe162024..d3891fa9 100644 --- a/packages/app/src/Element/Zap.tsx +++ b/packages/app/src/Element/Zap.tsx @@ -13,6 +13,7 @@ import { findTag } from "Util"; import { UserCache } from "Cache/UserCache"; import messages from "./messages"; +import useLogin from "Hooks/useLogin"; function getInvoice(zap: TaggedRawEvent): InvoiceDetails | undefined { const bolt11 = findTag(zap, "bolt11"); @@ -103,7 +104,7 @@ export interface ParsedZap { const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => { const { amount, content, sender, valid, receiver } = zap; - const pubKey = useSelector((s: RootState) => s.login.publicKey); + const pubKey = useLogin().publicKey; return valid && sender ? (
diff --git a/packages/app/src/ExternalStore.ts b/packages/app/src/ExternalStore.ts new file mode 100644 index 00000000..f9b77a88 --- /dev/null +++ b/packages/app/src/ExternalStore.ts @@ -0,0 +1,41 @@ +type HookFn = () => void; + +interface HookFilter { + fn: HookFn; +} + +/** + * Simple React hookable store with manual change notifications + */ +export default abstract class ExternalStore { + #hooks: Array = []; + #snapshot: Readonly = {} as Readonly; + #changed = true; + + hook(fn: HookFn) { + this.#hooks.push({ + fn, + }); + return () => { + const idx = this.#hooks.findIndex(a => a.fn === fn); + if (idx >= 0) { + this.#hooks.splice(idx, 1); + } + }; + } + + snapshot() { + if (this.#changed) { + this.#snapshot = this.takeSnapshot(); + this.#changed = false; + } + return this.#snapshot; + } + + protected notifyChange() { + this.#changed = true; + this.#hooks.forEach(h => h.fn()); + } + + abstract takeSnapshot(): TSnapshot; +} diff --git a/packages/app/src/Feed/BookmarkFeed.tsx b/packages/app/src/Feed/BookmarkFeed.tsx index b70bd42d..3412a75a 100644 --- a/packages/app/src/Feed/BookmarkFeed.tsx +++ b/packages/app/src/Feed/BookmarkFeed.tsx @@ -1,10 +1,9 @@ -import { useSelector } from "react-redux"; import { HexKey, Lists } from "@snort/nostr"; -import { RootState } from "State/Store"; import useNotelistSubscription from "Hooks/useNotelistSubscription"; +import useLogin from "Hooks/useLogin"; export default function useBookmarkFeed(pubkey?: HexKey) { - const { bookmarked } = useSelector((s: RootState) => s.login); - return useNotelistSubscription(pubkey, Lists.Bookmarked, bookmarked); + const { bookmarked } = useLogin(); + return useNotelistSubscription(pubkey, Lists.Bookmarked, bookmarked.item); } diff --git a/packages/app/src/Feed/EventPublisher.ts b/packages/app/src/Feed/EventPublisher.ts index 92cf560c..a5421a11 100644 --- a/packages/app/src/Feed/EventPublisher.ts +++ b/packages/app/src/Feed/EventPublisher.ts @@ -1,13 +1,12 @@ import { useMemo } from "react"; -import { useSelector } from "react-redux"; import * as secp from "@noble/secp256k1"; import { EventKind, RelaySettings, TaggedRawEvent, HexKey, RawEvent, u256, UserMetadata, Lists } from "@snort/nostr"; -import { RootState } from "State/Store"; import { bech32ToHex, delay, unwrap } from "Util"; import { DefaultRelays, HashtagRegex } from "Const"; import { System } from "System"; import { EventExt } from "System/EventExt"; +import useLogin from "Hooks/useLogin"; declare global { interface Window { @@ -26,10 +25,7 @@ declare global { export type EventPublisher = ReturnType; export default function useEventPublisher() { - const pubKey = useSelector(s => s.login.publicKey); - const privKey = useSelector(s => s.login.privateKey); - const follows = useSelector(s => s.login.follows); - const relays = useSelector((s: RootState) => s.login.relays); + const { publicKey: pubKey, privateKey: privKey, follows, relays } = useLogin(); const hasNip07 = "nostr" in window; async function signEvent(ev: RawEvent): Promise { @@ -270,7 +266,7 @@ export default function useEventPublisher() { if (pubKey) { const ev = EventExt.forPubKey(pubKey, EventKind.ContactList); ev.content = JSON.stringify(relays); - for (const pk of follows) { + for (const pk of follows.item) { ev.tags.push(["p", pk]); } @@ -297,7 +293,7 @@ export default function useEventPublisher() { if (pubKey) { const ev = EventExt.forPubKey(pubKey, EventKind.ContactList); ev.content = JSON.stringify(newRelays ?? relays); - const temp = new Set(follows); + const temp = new Set(follows.item); if (Array.isArray(pkAdd)) { pkAdd.forEach(a => temp.add(a)); } else { @@ -317,7 +313,7 @@ export default function useEventPublisher() { if (pubKey) { const ev = EventExt.forPubKey(pubKey, EventKind.ContactList); ev.content = JSON.stringify(relays); - for (const pk of follows) { + for (const pk of follows.item) { if (pk === pkRemove || pk.length !== 64) { continue; } diff --git a/packages/app/src/Feed/FollowsFeed.ts b/packages/app/src/Feed/FollowsFeed.ts index 813dfd8d..499af525 100644 --- a/packages/app/src/Feed/FollowsFeed.ts +++ b/packages/app/src/Feed/FollowsFeed.ts @@ -1,13 +1,12 @@ import { useMemo } from "react"; -import { useSelector } from "react-redux"; import { HexKey, TaggedRawEvent, EventKind } from "@snort/nostr"; -import { RootState } from "State/Store"; import { PubkeyReplaceableNoteStore, RequestBuilder } from "System"; import useRequestBuilder from "Hooks/useRequestBuilder"; +import useLogin from "Hooks/useLogin"; export default function useFollowsFeed(pubkey?: HexKey) { - const { publicKey, follows } = useSelector((s: RootState) => s.login); + const { publicKey, follows } = useLogin(); const isMe = publicKey === pubkey; const sub = useMemo(() => { @@ -20,7 +19,7 @@ export default function useFollowsFeed(pubkey?: HexKey) { const contactFeed = useRequestBuilder(PubkeyReplaceableNoteStore, sub); return useMemo(() => { if (isMe) { - return follows; + return follows.item; } return getFollowing(contactFeed.data ?? [], pubkey); diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index 44277aba..325fbdf9 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -1,23 +1,8 @@ import { useEffect, useMemo } from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit"; import { TaggedRawEvent, HexKey, Lists, EventKind } from "@snort/nostr"; import { bech32ToHex, getNewest, getNewestEventTagsByKey, unwrap } from "Util"; -import { makeNotification } from "Notifications"; -import { - setFollows, - setRelays, - setMuted, - setTags, - setPinned, - setBookmarked, - setBlocked, - sendNotification, - setLatestNotifications, - addSubscription, -} from "State/Login"; -import { RootState } from "State/Store"; +import { makeNotification, sendNotification } from "Notifications"; import useEventPublisher, { barrierNip07 } from "Feed/EventPublisher"; import { getMutedKeys } from "Feed/MuteList"; import useModeration from "Hooks/useModeration"; @@ -25,6 +10,8 @@ import { FlatNoteStore, RequestBuilder } from "System"; import useRequestBuilder from "Hooks/useRequestBuilder"; import { EventExt } from "System/EventExt"; import { DmCache } from "Cache"; +import useLogin from "Hooks/useLogin"; +import { addSubscription, setBlocked, setBookmarked, setFollows, setMuted, setPinned, setRelays, setTags } from "Login"; import { SnortPubKey } from "Const"; import { SubscriptionEvent } from "Subscription"; @@ -32,13 +19,8 @@ import { SubscriptionEvent } from "Subscription"; * Managed loading data for the current logged in user */ export default function useLoginFeed() { - const dispatch = useDispatch(); - const { - publicKey: pubKey, - privateKey: privKey, - latestMuted, - readNotifications, - } = useSelector((s: RootState) => s.login); + const login = useLogin(); + const { publicKey: pubKey, privateKey: privKey, readNotifications, muted: stateMuted } = login; const { isMuted } = useModeration(); const publisher = useEventPublisher(); @@ -86,10 +68,10 @@ export default function useLoginFeed() { if (contactList) { if (contactList.content !== "" && contactList.content !== "{}") { const relays = JSON.parse(contactList.content); - dispatch(setRelays({ relays, createdAt: contactList.created_at })); + setRelays(login, relays, contactList.created_at * 1000); } const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]); - dispatch(setFollows({ keys: pTags, createdAt: contactList.created_at })); + setFollows(login, pTags, contactList.created_at * 1000); } const dms = loginFeed.data.filter(a => a.kind === EventKind.DirectMessage); @@ -109,9 +91,9 @@ export default function useLoginFeed() { } as SubscriptionEvent; } }) - ).then(a => dispatch(addSubscription(a.filter(a => a !== undefined).map(unwrap)))); + ).then(a => addSubscription(login, ...a.filter(a => a !== undefined).map(unwrap))); } - }, [dispatch, loginFeed]); + }, [loginFeed]); // send out notifications useEffect(() => { @@ -119,34 +101,26 @@ export default function useLoginFeed() { const replies = loginFeed.data.filter( a => a.kind === EventKind.TextNote && !isMuted(a.pubkey) && a.created_at > readNotifications ); - replies.forEach(nx => { - dispatch(setLatestNotifications(nx.created_at)); - makeNotification(nx).then(notification => { - if (notification) { - (dispatch as ThunkDispatch)(sendNotification(notification)); - } - }); + replies.forEach(async nx => { + const n = await makeNotification(nx); + if (n) { + sendNotification(login, n); + } }); } - }, [dispatch, loginFeed, readNotifications]); + }, [loginFeed, readNotifications]); function handleMutedFeed(mutedFeed: TaggedRawEvent[]) { const muted = getMutedKeys(mutedFeed); - dispatch(setMuted(muted)); + setMuted(login, muted.keys, muted.createdAt * 1000); - const newest = getNewest(mutedFeed); - if (newest && newest.content.length > 0 && pubKey && newest.created_at > latestMuted) { - decryptBlocked(newest, pubKey, privKey) + if (muted.raw && (muted.raw?.content?.length ?? 0) > 0 && pubKey) { + decryptBlocked(muted.raw, pubKey, privKey) .then(plaintext => { try { const blocked = JSON.parse(plaintext); const keys = blocked.filter((p: string) => p && p.length === 2 && p[0] === "p").map((p: string) => p[1]); - dispatch( - setBlocked({ - keys, - createdAt: newest.created_at, - }) - ); + setBlocked(login, keys, unwrap(muted.raw).created_at * 1000); } catch (error) { console.debug("Couldn't parse JSON"); } @@ -158,26 +132,21 @@ export default function useLoginFeed() { function handlePinnedFeed(pinnedFeed: TaggedRawEvent[]) { const newest = getNewestEventTagsByKey(pinnedFeed, "e"); if (newest) { - dispatch(setPinned(newest)); + setPinned(login, newest.keys, newest.createdAt * 1000); } } function handleTagFeed(tagFeed: TaggedRawEvent[]) { const newest = getNewestEventTagsByKey(tagFeed, "t"); if (newest) { - dispatch( - setTags({ - tags: newest.keys, - createdAt: newest.createdAt, - }) - ); + setTags(login, newest.keys, newest.createdAt * 1000); } } function handleBookmarkFeed(bookmarkFeed: TaggedRawEvent[]) { const newest = getNewestEventTagsByKey(bookmarkFeed, "e"); if (newest) { - dispatch(setBookmarked(newest)); + setBookmarked(login, newest.keys, newest.createdAt * 1000); } } @@ -200,7 +169,7 @@ export default function useLoginFeed() { const bookmarkFeed = getList(listsFeed.data, Lists.Bookmarked); handleBookmarkFeed(bookmarkFeed); } - }, [dispatch, listsFeed]); + }, [listsFeed]); /*const fRelays = useRelaysFeedFollows(follows); useEffect(() => { diff --git a/packages/app/src/Feed/MuteList.ts b/packages/app/src/Feed/MuteList.ts index bbfa8cf1..4d551448 100644 --- a/packages/app/src/Feed/MuteList.ts +++ b/packages/app/src/Feed/MuteList.ts @@ -1,15 +1,13 @@ import { useMemo } from "react"; -import { useSelector } from "react-redux"; - -import { getNewest } from "Util"; import { HexKey, TaggedRawEvent, Lists, EventKind } from "@snort/nostr"; -import { RootState } from "State/Store"; +import { getNewest } from "Util"; import { ParameterizedReplaceableNoteStore, RequestBuilder } from "System"; import useRequestBuilder from "Hooks/useRequestBuilder"; +import useLogin from "Hooks/useLogin"; export default function useMutedFeed(pubkey?: HexKey) { - const { publicKey, muted } = useSelector((s: RootState) => s.login); + const { publicKey, muted } = useLogin(); const isMe = publicKey === pubkey; const sub = useMemo(() => { @@ -28,18 +26,20 @@ export default function useMutedFeed(pubkey?: HexKey) { return []; }, [mutedFeed, pubkey]); - return isMe ? muted : mutedList; + return isMe ? muted.item : mutedList; } export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number; keys: HexKey[]; + raw?: TaggedRawEvent; } { const newest = getNewest(rawNotes); if (newest) { const { created_at, tags } = newest; const keys = tags.filter(t => t[0] === "p").map(t => t[1]); return { + raw: newest, keys, createdAt: created_at, }; diff --git a/packages/app/src/Feed/PinnedFeed.tsx b/packages/app/src/Feed/PinnedFeed.tsx index c487a114..f8c6b5af 100644 --- a/packages/app/src/Feed/PinnedFeed.tsx +++ b/packages/app/src/Feed/PinnedFeed.tsx @@ -1,10 +1,8 @@ -import { useSelector } from "react-redux"; - -import { RootState } from "State/Store"; import { HexKey, Lists } from "@snort/nostr"; import useNotelistSubscription from "Hooks/useNotelistSubscription"; +import useLogin from "Hooks/useLogin"; export default function usePinnedFeed(pubkey?: HexKey) { - const { pinned } = useSelector((s: RootState) => s.login); + const pinned = useLogin().pinned.item; return useNotelistSubscription(pubkey, Lists.Pinned, pinned); } diff --git a/packages/app/src/Feed/ThreadFeed.ts b/packages/app/src/Feed/ThreadFeed.ts index 66596257..c2675e99 100644 --- a/packages/app/src/Feed/ThreadFeed.ts +++ b/packages/app/src/Feed/ThreadFeed.ts @@ -1,17 +1,15 @@ import { useEffect, useMemo, useState } from "react"; -import { useSelector } from "react-redux"; import { u256, EventKind } from "@snort/nostr"; -import { RootState } from "State/Store"; -import { UserPreferences } from "State/Login"; import { appendDedupe, NostrLink } from "Util"; import { FlatNoteStore, RequestBuilder } from "System"; import useRequestBuilder from "Hooks/useRequestBuilder"; +import useLogin from "Hooks/useLogin"; export default function useThreadFeed(link: NostrLink) { const [trackingEvents, setTrackingEvent] = useState([link.id]); const [allEvents, setAllEvents] = useState([link.id]); - const pref = useSelector(s => s.login.preferences); + const pref = useLogin().preferences; const sub = useMemo(() => { const sub = new RequestBuilder(`thread:${link.id.substring(0, 8)}`); diff --git a/packages/app/src/Feed/TimelineFeed.ts b/packages/app/src/Feed/TimelineFeed.ts index dc80ddeb..c45526c2 100644 --- a/packages/app/src/Feed/TimelineFeed.ts +++ b/packages/app/src/Feed/TimelineFeed.ts @@ -1,13 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useSelector } from "react-redux"; import { EventKind, u256 } from "@snort/nostr"; import { unixNow, unwrap, tagFilterOfTextRepost } from "Util"; -import { RootState } from "State/Store"; -import { UserPreferences } from "State/Login"; import { FlatNoteStore, RequestBuilder } from "System"; import useRequestBuilder from "Hooks/useRequestBuilder"; import useTimelineWindow from "Hooks/useTimelineWindow"; +import useLogin from "Hooks/useLogin"; export interface TimelineFeedOptions { method: "TIME_RANGE" | "LIMIT_UNTIL"; @@ -31,7 +29,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel }); const [trackingEvents, setTrackingEvent] = useState([]); const [trackingParentEvents, setTrackingParentEvents] = useState([]); - const pref = useSelector(s => s.login.preferences); + const pref = useLogin().preferences; const createBuilder = useCallback(() => { if (subject.type !== "global" && subject.items.length === 0) { diff --git a/packages/app/src/Hooks/useImgProxy.ts b/packages/app/src/Hooks/useImgProxy.ts index b2370811..901a1b96 100644 --- a/packages/app/src/Hooks/useImgProxy.ts +++ b/packages/app/src/Hooks/useImgProxy.ts @@ -1,8 +1,7 @@ import * as secp from "@noble/secp256k1"; import * as base64 from "@protobufjs/base64"; -import { useSelector } from "react-redux"; -import { RootState } from "State/Store"; import { hmacSha256, unwrap } from "Util"; +import useLogin from "Hooks/useLogin"; export interface ImgProxySettings { url: string; @@ -11,7 +10,7 @@ export interface ImgProxySettings { } export default function useImgProxy() { - const settings = useSelector((s: RootState) => s.login.preferences.imgProxyConfig); + const settings = useLogin().preferences.imgProxyConfig; const te = new TextEncoder(); function urlSafe(s: string) { diff --git a/packages/app/src/Hooks/useLogin.tsx b/packages/app/src/Hooks/useLogin.tsx new file mode 100644 index 00000000..b4ffedff --- /dev/null +++ b/packages/app/src/Hooks/useLogin.tsx @@ -0,0 +1,9 @@ +import { LoginStore } from "Login"; +import { useSyncExternalStore } from "react"; + +export default function useLogin() { + return useSyncExternalStore( + s => LoginStore.hook(s), + () => LoginStore.snapshot() + ); +} diff --git a/packages/app/src/Hooks/useModeration.tsx b/packages/app/src/Hooks/useModeration.tsx index 92a10eee..f8467409 100644 --- a/packages/app/src/Hooks/useModeration.tsx +++ b/packages/app/src/Hooks/useModeration.tsx @@ -1,95 +1,72 @@ -import { useSelector, useDispatch } from "react-redux"; - -import type { RootState } from "State/Store"; import { HexKey } from "@snort/nostr"; import useEventPublisher from "Feed/EventPublisher"; -import { setMuted, setBlocked } from "State/Login"; +import useLogin from "Hooks/useLogin"; +import { setBlocked, setMuted } from "Login"; +import { appendDedupe } from "Util"; export default function useModeration() { - const dispatch = useDispatch(); - const { blocked, muted } = useSelector((s: RootState) => s.login); + const login = useLogin(); + const { muted, blocked } = login; const publisher = useEventPublisher(); async function setMutedList(pub: HexKey[], priv: HexKey[]) { try { const ev = await publisher.muted(pub, priv); - console.debug(ev); - publisher.broadcast(ev); + if (ev) { + publisher.broadcast(ev); + return ev.created_at * 1000; + } } catch (error) { console.debug("Couldn't change mute list"); } + return 0; } function isMuted(id: HexKey) { - return muted.includes(id) || blocked.includes(id); + return muted.item.includes(id) || blocked.item.includes(id); } function isBlocked(id: HexKey) { - return blocked.includes(id); + return blocked.item.includes(id); } - function unmute(id: HexKey) { - const newMuted = muted.filter(p => p !== id); - dispatch( - setMuted({ - createdAt: new Date().getTime(), - keys: newMuted, - }) - ); - setMutedList(newMuted, blocked); + async function unmute(id: HexKey) { + const newMuted = muted.item.filter(p => p !== id); + const ts = await setMutedList(newMuted, blocked.item); + setMuted(login, newMuted, ts); } - function unblock(id: HexKey) { - const newBlocked = blocked.filter(p => p !== id); - dispatch( - setBlocked({ - createdAt: new Date().getTime(), - keys: newBlocked, - }) - ); - setMutedList(muted, newBlocked); + async function unblock(id: HexKey) { + const newBlocked = blocked.item.filter(p => p !== id); + const ts = await setMutedList(muted.item, newBlocked); + setBlocked(login, newBlocked, ts); } - function mute(id: HexKey) { - const newMuted = muted.includes(id) ? muted : muted.concat([id]); - setMutedList(newMuted, blocked); - dispatch( - setMuted({ - createdAt: new Date().getTime(), - keys: newMuted, - }) - ); + async function mute(id: HexKey) { + const newMuted = muted.item.includes(id) ? muted.item : muted.item.concat([id]); + const ts = await setMutedList(newMuted, blocked.item); + setMuted(login, newMuted, ts); } - function block(id: HexKey) { - const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id]); - setMutedList(muted, newBlocked); - dispatch( - setBlocked({ - createdAt: new Date().getTime(), - keys: newBlocked, - }) - ); + async function block(id: HexKey) { + const newBlocked = blocked.item.includes(id) ? blocked.item : blocked.item.concat([id]); + const ts = await setMutedList(muted.item, newBlocked); + setBlocked(login, newBlocked, ts); } - function muteAll(ids: HexKey[]) { - const newMuted = Array.from(new Set(muted.concat(ids))); - setMutedList(newMuted, blocked); - dispatch( - setMuted({ - createdAt: new Date().getTime(), - keys: newMuted, - }) - ); + async function muteAll(ids: HexKey[]) { + const newMuted = appendDedupe(muted.item, ids); + const ts = await setMutedList(newMuted, blocked.item); + setMuted(login, newMuted, ts); } return { - muted, + muted: muted.item, mute, muteAll, unmute, isMuted, - blocked, + blocked: blocked.item, block, unblock, isBlocked, diff --git a/packages/app/src/Hooks/useNotelistSubscription.ts b/packages/app/src/Hooks/useNotelistSubscription.ts index 0ea08d4f..5e82d6a6 100644 --- a/packages/app/src/Hooks/useNotelistSubscription.ts +++ b/packages/app/src/Hooks/useNotelistSubscription.ts @@ -1,13 +1,12 @@ import { useMemo } from "react"; -import { useSelector } from "react-redux"; import { HexKey, Lists, EventKind } from "@snort/nostr"; -import { RootState } from "State/Store"; import { FlatNoteStore, ParameterizedReplaceableNoteStore, RequestBuilder } from "System"; import useRequestBuilder from "Hooks/useRequestBuilder"; +import useLogin from "Hooks/useLogin"; export default function useNotelistSubscription(pubkey: HexKey | undefined, l: Lists, defaultIds: HexKey[]) { - const { preferences, publicKey } = useSelector((s: RootState) => s.login); + const { preferences, publicKey } = useLogin(); const isMe = publicKey === pubkey; const sub = useMemo(() => { diff --git a/packages/app/src/IntlProvider.tsx b/packages/app/src/IntlProvider.tsx index 2219df89..ed3f5493 100644 --- a/packages/app/src/IntlProvider.tsx +++ b/packages/app/src/IntlProvider.tsx @@ -1,7 +1,6 @@ import { type ReactNode } from "react"; import { IntlProvider as ReactIntlProvider } from "react-intl"; -import { ReadPreferences } from "State/Login"; import enMessages from "translations/en.json"; import esMessages from "translations/es_ES.json"; import zhMessages from "translations/zh_CN.json"; @@ -16,6 +15,7 @@ import deMessages from "translations/de_DE.json"; import ruMessages from "translations/ru_RU.json"; import svMessages from "translations/sv_SE.json"; import hrMessages from "translations/hr_HR.json"; +import useLogin from "Hooks/useLogin"; const DefaultLocale = "en-US"; @@ -73,7 +73,7 @@ const getMessages = (locale: string) => { }; export const IntlProvider = ({ children }: { children: ReactNode }) => { - const { language } = ReadPreferences(); + const { language } = useLogin().preferences; const locale = language ?? getLocale(); return ( diff --git a/packages/app/src/Login/Functions.ts b/packages/app/src/Login/Functions.ts new file mode 100644 index 00000000..5b0257a3 --- /dev/null +++ b/packages/app/src/Login/Functions.ts @@ -0,0 +1,143 @@ +import { HexKey, RelaySettings } from "@snort/nostr"; +import * as secp from "@noble/secp256k1"; + +import { DefaultRelays, SnortPubKey } from "Const"; +import { EventPublisher } from "Feed/EventPublisher"; +import { LoginStore, UserPreferences, LoginSession } from "Login"; +import { generateBip39Entropy, entropyToDerivedKey } from "nip6"; +import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unixNowMs } from "Util"; +import { getCurrentSubscription, SubscriptionEvent } from "Subscription"; + +export function setRelays(state: LoginSession, relays: Record, createdAt: number) { + if (state.relays.timestamp > createdAt) { + return; + } + + // filter out non-websocket urls + const filtered = new Map(); + for (const [k, v] of Object.entries(relays)) { + if (k.startsWith("wss://") || k.startsWith("ws://")) { + const url = sanitizeRelayUrl(k); + if (url) { + filtered.set(url, v as RelaySettings); + } + } + } + state.relays.item = Object.fromEntries(filtered.entries()); + state.relays.timestamp = createdAt; + LoginStore.updateSession(state); +} + +export function removeRelay(state: LoginSession, addr: string) { + delete state.relays.item[addr]; + LoginStore.updateSession(state); +} + +export function updatePreferences(state: LoginSession, p: UserPreferences) { + state.preferences = p; + LoginStore.updateSession(state); +} + +export function logout(k: HexKey) { + LoginStore.removeSession(k); +} + +export function markNotificationsRead(state: LoginSession) { + state.readNotifications = unixNowMs(); + LoginStore.updateSession(state); +} + +export function clearEntropy(state: LoginSession) { + state.generatedEntropy = undefined; + LoginStore.updateSession(state); +} + +/** + * Generate a new key and login with this generated key + */ +export async function generateNewLogin(publisher: EventPublisher) { + const ent = generateBip39Entropy(); + const entHex = secp.utils.bytesToHex(ent); + const newKeyHex = entropyToDerivedKey(ent); + let newRelays: Record = {}; + + try { + const rsp = await fetch("https://api.nostr.watch/v1/online"); + if (rsp.ok) { + const online: string[] = await rsp.json(); + const pickRandom = randomSample(online, 4); + const relayObjects = pickRandom.map(a => [a, { read: true, write: true }]); + newRelays = { + ...Object.fromEntries(relayObjects), + ...Object.fromEntries(DefaultRelays.entries()), + }; + } + } catch (e) { + console.warn(e); + } + + const ev = await publisher.addFollow([bech32ToHex(SnortPubKey), newKeyHex], newRelays); + publisher.broadcast(ev); + + LoginStore.loginWithPrivateKey(newKeyHex, entHex); +} + +export function setTags(state: LoginSession, tags: Array, ts: number) { + if (state.tags.timestamp > ts) { + return; + } + state.tags.item = tags; + state.tags.timestamp = ts; + LoginStore.updateSession(state); +} + +export function setMuted(state: LoginSession, muted: Array, ts: number) { + if (state.muted.timestamp > ts) { + return; + } + state.muted.item = muted; + state.muted.timestamp = ts; + LoginStore.updateSession(state); +} + +export function setBlocked(state: LoginSession, blocked: Array, ts: number) { + if (state.blocked.timestamp > ts) { + return; + } + state.blocked.item = blocked; + state.blocked.timestamp = ts; + LoginStore.updateSession(state); +} + +export function setFollows(state: LoginSession, follows: Array, ts: number) { + if (state.follows.timestamp > ts) { + return; + } + state.follows.item = follows; + state.follows.timestamp = ts; + LoginStore.updateSession(state); +} + +export function setPinned(state: LoginSession, pinned: Array, ts: number) { + if (state.pinned.timestamp > ts) { + return; + } + state.pinned.item = pinned; + state.pinned.timestamp = ts; + LoginStore.updateSession(state); +} + +export function setBookmarked(state: LoginSession, bookmarked: Array, ts: number) { + if (state.bookmarked.timestamp > ts) { + return; + } + state.bookmarked.item = bookmarked; + state.bookmarked.timestamp = ts; + LoginStore.updateSession(state); +} + +export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[]) { + state.subscriptions = dedupeById([...state.subscriptions, ...subs]); + state.currentSubscription = getCurrentSubscription(state.subscriptions); + LoginStore.updateSession(state); +} \ No newline at end of file diff --git a/packages/app/src/Login/LoginSession.ts b/packages/app/src/Login/LoginSession.ts new file mode 100644 index 00000000..dd4776b6 --- /dev/null +++ b/packages/app/src/Login/LoginSession.ts @@ -0,0 +1,88 @@ +import { HexKey, RelaySettings, u256 } from "@snort/nostr"; +import { UserPreferences } from "Login"; +import { SubscriptionEvent } from "Subscription"; + +/** + * Stores latest copy of an item + */ +interface Newest { + item: T; + timestamp: number; +} + +export interface LoginSession { + /** + * Current user private key + */ + privateKey?: HexKey; + + /** + * BIP39-generated, hex-encoded entropy + */ + generatedEntropy?: string; + + /** + * Current users public key + */ + publicKey?: HexKey; + + /** + * All the logged in users relays + */ + relays: Newest>; + + /** + * A list of pubkeys this user follows + */ + follows: Newest>; + + /** + * A list of tags this user follows + */ + tags: Newest>; + + /** + * A list of event ids this user has pinned + */ + pinned: Newest>; + + /** + * A list of event ids this user has bookmarked + */ + bookmarked: Newest>; + + /** + * A list of pubkeys this user has muted + */ + muted: Newest>; + + /** + * A list of pubkeys this user has muted privately + */ + blocked: Newest>; + + /** + * Latest notification + */ + latestNotification: number; + + /** + * Timestamp of last read notification + */ + readNotifications: number; + + /** + * Users cusom preferences + */ + preferences: UserPreferences; + + /** + * Snort subscriptions licences + */ + subscriptions: Array; + + /** + * Current active subscription + */ + currentSubscription?: SubscriptionEvent; +} diff --git a/packages/app/src/Login/MultiAccountStore.ts b/packages/app/src/Login/MultiAccountStore.ts new file mode 100644 index 00000000..57e62230 --- /dev/null +++ b/packages/app/src/Login/MultiAccountStore.ts @@ -0,0 +1,181 @@ +import * as secp from "@noble/secp256k1"; +import { HexKey, RelaySettings } from "@snort/nostr"; + +import { DefaultRelays } from "Const"; +import ExternalStore from "ExternalStore"; +import { LoginSession } from "Login"; +import { deepClone, sanitizeRelayUrl, unwrap } from "Util"; +import { DefaultPreferences, UserPreferences } from "./Preferences"; + +const AccountStoreKey = "sessions"; +const LoggedOut = { + preferences: DefaultPreferences, + tags: { + item: [], + timestamp: 0, + }, + follows: { + item: [], + timestamp: 0, + }, + muted: { + item: [], + timestamp: 0, + }, + blocked: { + item: [], + timestamp: 0, + }, + bookmarked: { + item: [], + timestamp: 0, + }, + pinned: { + item: [], + timestamp: 0, + }, + relays: { + item: Object.fromEntries([...DefaultRelays.entries()].map(a => [unwrap(sanitizeRelayUrl(a[0])), a[1]])), + timestamp: 0, + }, + latestNotification: 0, + readNotifications: 0, + subscriptions: [] +} as LoginSession; +const LegacyKeys = { + PrivateKeyItem: "secret", + PublicKeyItem: "pubkey", + NotificationsReadItem: "notifications-read", + UserPreferencesKey: "preferences", + RelayListKey: "last-relays", + FollowList: "last-follows", +}; + +export class MultiAccountStore extends ExternalStore { + #activeAccount?: HexKey; + #accounts: Map; + + constructor() { + super(); + const existing = window.localStorage.getItem(AccountStoreKey); + if (existing) { + this.#accounts = new Map((JSON.parse(existing) as Array).map(a => [unwrap(a.publicKey), a])); + } else { + this.#accounts = new Map(); + } + this.#migrate(); + if (!this.#activeAccount) { + this.#activeAccount = this.#accounts.keys().next().value; + } + } + + getSessions() { + return [...this.#accounts.keys()]; + } + + loginWithPubkey(key: HexKey, relays?: Record) { + if (this.#accounts.has(key)) { + throw new Error("Already logged in with this pubkey"); + } + const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries()); + const newSession = { + ...LoggedOut, + publicKey: key, + relays: { + item: initRelays, + timestamp: 1, + }, + preferences: deepClone(DefaultPreferences), + } as LoginSession; + + this.#accounts.set(key, newSession); + this.#activeAccount = key; + this.#save(); + return newSession; + } + + loginWithPrivateKey(key: HexKey, entropy?: string) { + const pubKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(key)); + if (this.#accounts.has(pubKey)) { + throw new Error("Already logged in with this pubkey"); + } + this.#accounts.set(pubKey, { + ...LoggedOut, + privateKey: key, + publicKey: pubKey, + generatedEntropy: entropy, + preferences: deepClone(DefaultPreferences), + } as LoginSession); + this.#activeAccount = pubKey; + this.#save(); + } + + updateSession(s: LoginSession) { + const pk = unwrap(s.publicKey); + if (this.#accounts.has(pk)) { + this.#accounts.set(pk, s); + this.#save(); + } + } + + removeSession(k: string) { + if (this.#accounts.delete(k)) { + this.#save(); + } + } + + takeSnapshot(): LoginSession { + const s = this.#activeAccount ? this.#accounts.get(this.#activeAccount) : undefined; + if (!s) return LoggedOut; + + return deepClone(s); + } + + #migrate() { + let didMigrate = false; + const oldPreferences = window.localStorage.getItem(LegacyKeys.UserPreferencesKey); + const pref: UserPreferences = oldPreferences ? JSON.parse(oldPreferences) : deepClone(DefaultPreferences); + window.localStorage.removeItem(LegacyKeys.UserPreferencesKey); + + const privKey = window.localStorage.getItem(LegacyKeys.PrivateKeyItem); + if (privKey) { + const pubKey = secp.utils.bytesToHex(secp.schnorr.getPublicKey(privKey)); + this.#accounts.set(pubKey, { + ...LoggedOut, + privateKey: privKey, + publicKey: pubKey, + preferences: pref, + } as LoginSession); + window.localStorage.removeItem(LegacyKeys.PrivateKeyItem); + window.localStorage.removeItem(LegacyKeys.PublicKeyItem); + didMigrate = true; + } + + const pubKey = window.localStorage.getItem(LegacyKeys.PublicKeyItem); + if (pubKey) { + this.#accounts.set(pubKey, { + ...LoggedOut, + publicKey: pubKey, + preferences: pref, + } as LoginSession); + window.localStorage.removeItem(LegacyKeys.PublicKeyItem); + didMigrate = true; + } + + window.localStorage.removeItem(LegacyKeys.RelayListKey); + window.localStorage.removeItem(LegacyKeys.FollowList); + window.localStorage.removeItem(LegacyKeys.NotificationsReadItem); + if (didMigrate) { + console.debug("Finished migration to MultiAccountStore"); + this.#save(); + } + } + + #save() { + if (!this.#activeAccount && this.#accounts.size > 0) { + this.#activeAccount = [...this.#accounts.keys()][0]; + } + window.localStorage.setItem(AccountStoreKey, JSON.stringify([...this.#accounts.values()])); + this.notifyChange(); + } +} diff --git a/packages/app/src/Login/Preferences.ts b/packages/app/src/Login/Preferences.ts new file mode 100644 index 00000000..005714d1 --- /dev/null +++ b/packages/app/src/Login/Preferences.ts @@ -0,0 +1,90 @@ +import { DefaultImgProxy } from "Const"; +import { ImgProxySettings } from "Hooks/useImgProxy"; + +export interface UserPreferences { + /** + * User selected language + */ + language?: string; + + /** + * Enable reactions / reposts / zaps + */ + enableReactions: boolean; + + /** + * Reaction emoji + */ + reactionEmoji: string; + + /** + * Automatically load media (show link only) (bandwidth/privacy) + */ + autoLoadMedia: "none" | "follows-only" | "all"; + + /** + * Select between light/dark theme + */ + theme: "system" | "light" | "dark"; + + /** + * Ask for confirmation when reposting notes + */ + confirmReposts: boolean; + + /** + * Automatically show the latests notes + */ + autoShowLatest: boolean; + + /** + * Show debugging menus to help diagnose issues + */ + showDebugMenus: boolean; + + /** + * File uploading service to upload attachments to + */ + fileUploader: "void.cat" | "nostr.build" | "nostrimg.com"; + + /** + * Use imgproxy to optimize images + */ + imgProxyConfig: ImgProxySettings | null; + + /** + * Default page to select on load + */ + defaultRootTab: "posts" | "conversations" | "global"; + + /** + * Default zap amount + */ + defaultZapAmount: number; + + /** + * For each fast zap an additional X% will be sent to Snort donate address + */ + fastZapDonate: number; + + /** + * Auto-zap every post + */ + autoZap: boolean; +} + +export const DefaultPreferences = { + enableReactions: true, + reactionEmoji: "+", + autoLoadMedia: "follows-only", + theme: "system", + confirmReposts: false, + showDebugMenus: false, + autoShowLatest: false, + fileUploader: "void.cat", + imgProxyConfig: DefaultImgProxy, + defaultRootTab: "posts", + defaultZapAmount: 50, + fastZapDonate: 0.0, + autoZap: false, +} as UserPreferences; diff --git a/packages/app/src/Login/index.ts b/packages/app/src/Login/index.ts new file mode 100644 index 00000000..d1ca2401 --- /dev/null +++ b/packages/app/src/Login/index.ts @@ -0,0 +1,6 @@ +import { MultiAccountStore } from "./MultiAccountStore"; +export const LoginStore = new MultiAccountStore(); + +export * from "./Preferences"; +export * from "./LoginSession"; +export * from "./Functions"; diff --git a/packages/app/src/Notifications.ts b/packages/app/src/Notifications.ts index 46442051..5dc28164 100644 --- a/packages/app/src/Notifications.ts +++ b/packages/app/src/Notifications.ts @@ -2,12 +2,19 @@ import Nostrich from "nostrich.webp"; import { TaggedRawEvent } from "@snort/nostr"; import { EventKind } from "@snort/nostr"; -import type { NotificationRequest } from "State/Login"; import { MetadataCache } from "Cache"; import { getDisplayName } from "Element/ProfileImage"; import { MentionRegex } from "Const"; import { tagFilterOfTextRepost, unwrap } from "Util"; import { UserCache } from "Cache/UserCache"; +import { LoginSession } from "Login"; + +export interface NotificationRequest { + title: string; + body: string; + icon: string; + timestamp: number; +} export async function makeNotification(ev: TaggedRawEvent): Promise { switch (ev.kind) { @@ -52,3 +59,20 @@ function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) { }) .join(); } + +export async function sendNotification(state: LoginSession, req: NotificationRequest) { + const hasPermission = "Notification" in window && Notification.permission === "granted"; + const shouldShowNotification = hasPermission && req.timestamp > state.readNotifications; + if (shouldShowNotification) { + try { + const worker = await navigator.serviceWorker.ready; + worker.showNotification(req.title, { + tag: "notification", + vibrate: [500], + ...req, + }); + } catch (error) { + console.warn(error); + } + } +} diff --git a/packages/app/src/Pages/ChatPage.tsx b/packages/app/src/Pages/ChatPage.tsx index 29fc6fd9..f70ad8e1 100644 --- a/packages/app/src/Pages/ChatPage.tsx +++ b/packages/app/src/Pages/ChatPage.tsx @@ -1,19 +1,17 @@ import "./ChatPage.css"; import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; -import { useSelector } from "react-redux"; import { useParams } from "react-router-dom"; +import { FormattedMessage } from "react-intl"; +import { RawEvent, TaggedRawEvent } from "@snort/nostr"; import ProfileImage from "Element/ProfileImage"; import { bech32ToHex } from "Util"; import useEventPublisher from "Feed/EventPublisher"; - import DM from "Element/DM"; -import { RawEvent, TaggedRawEvent } from "@snort/nostr"; import { dmsInChat, isToSelf } from "Pages/MessagesPage"; import NoteToSelf from "Element/NoteToSelf"; -import { RootState } from "State/Store"; -import { FormattedMessage } from "react-intl"; import { useDmCache } from "Hooks/useDmsCache"; +import useLogin from "Hooks/useLogin"; type RouterParams = { id: string; @@ -23,7 +21,7 @@ export default function ChatPage() { const params = useParams(); const publisher = useEventPublisher(); const id = bech32ToHex(params.id ?? ""); - const pubKey = useSelector((s: RootState) => s.login.publicKey); + const pubKey = useLogin().publicKey; const [content, setContent] = useState(); const dmListRef = useRef(null); const dms = filterDms(useDmCache()); diff --git a/packages/app/src/Pages/HashTagsPage.tsx b/packages/app/src/Pages/HashTagsPage.tsx index 01b5a358..2d9e39da 100644 --- a/packages/app/src/Pages/HashTagsPage.tsx +++ b/packages/app/src/Pages/HashTagsPage.tsx @@ -1,30 +1,27 @@ import { useMemo } from "react"; import { useParams } from "react-router-dom"; import { FormattedMessage } from "react-intl"; -import { useSelector, useDispatch } from "react-redux"; + import Timeline from "Element/Timeline"; import useEventPublisher from "Feed/EventPublisher"; -import { setTags } from "State/Login"; -import type { RootState } from "State/Store"; +import useLogin from "Hooks/useLogin"; +import { setTags } from "Login"; const HashTagsPage = () => { const params = useParams(); const tag = (params.tag ?? "").toLowerCase(); - const dispatch = useDispatch(); - const { tags } = useSelector((s: RootState) => s.login); + const login = useLogin(); const isFollowing = useMemo(() => { - return tags.includes(tag); - }, [tags, tag]); + return login.tags.item.includes(tag); + }, [login, tag]); const publisher = useEventPublisher(); - function followTags(ts: string[]) { - dispatch( - setTags({ - tags: ts, - createdAt: new Date().getTime(), - }) - ); - publisher.tags(ts).then(ev => publisher.broadcast(ev)); + async function followTags(ts: string[]) { + const ev = await publisher.tags(ts); + if (ev) { + publisher.broadcast(ev); + setTags(login, ts, ev.created_at * 1000); + } } return ( @@ -33,11 +30,14 @@ const HashTagsPage = () => {

#{tag}

{isFollowing ? ( - ) : ( - )} diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index 30086263..456c1eca 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -4,13 +4,10 @@ import { useDispatch, useSelector } from "react-redux"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { FormattedMessage } from "react-intl"; -import { RelaySettings } from "@snort/nostr"; import messages from "./messages"; -import { bech32ToHex, randomSample, unixNowMs, unwrap } from "Util"; import Icon from "Icons/Icon"; import { RootState } from "State/Store"; -import { init, setRelays } from "State/Login"; import { setShow, reset } from "State/NoteCreator"; import { System } from "System"; import ProfileImage from "Element/ProfileImage"; @@ -20,11 +17,11 @@ import useModeration from "Hooks/useModeration"; import { NoteCreator } from "Element/NoteCreator"; import { db } from "Db"; import useEventPublisher from "Feed/EventPublisher"; -import { DefaultRelays, SnortPubKey } from "Const"; import SubDebug from "Element/SubDebug"; import { preload } from "Cache"; import { useDmCache } from "Hooks/useDmsCache"; import { mapPlanName } from "./subscribe"; +import useLogin from "Hooks/useLogin"; export default function Layout() { const location = useLocation(); @@ -33,9 +30,7 @@ export default function Layout() { const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing; const dispatch = useDispatch(); const navigate = useNavigate(); - const { loggedOut, publicKey, relays, preferences, newUserKey, subscription } = useSelector( - (s: RootState) => s.login - ); + const { publicKey, relays, preferences, currentSubscription } = useLogin(); const [pageClass, setPageClass] = useState("page"); const pub = useEventPublisher(); useLoginFeed(); @@ -72,11 +67,11 @@ export default function Layout() { useEffect(() => { if (relays) { (async () => { - for (const [k, v] of Object.entries(relays)) { + for (const [k, v] of Object.entries(relays.item)) { await System.ConnectToRelay(k, v); } for (const [k, c] of System.Sockets) { - if (!relays[k] && !c.Ephemeral) { + if (!relays.item[k] && !c.Ephemeral) { System.DisconnectRelay(k); } } @@ -117,7 +112,6 @@ export default function Layout() { await preload(); } console.debug(`Using db: ${a ? "IndexedDB" : "In-Memory"}`); - dispatch(init()); try { if ("registerProtocolHandler" in window.navigator) { @@ -133,53 +127,16 @@ export default function Layout() { }); }, []); - async function handleNewUser() { - let newRelays: Record = {}; - - try { - const rsp = await fetch("https://api.nostr.watch/v1/online"); - if (rsp.ok) { - const online: string[] = await rsp.json(); - const pickRandom = randomSample(online, 4); - const relayObjects = pickRandom.map(a => [a, { read: true, write: true }]); - newRelays = { - ...Object.fromEntries(relayObjects), - ...Object.fromEntries(DefaultRelays.entries()), - }; - dispatch( - setRelays({ - relays: newRelays, - createdAt: unixNowMs(), - }) - ); - } - } catch (e) { - console.warn(e); - } - - const ev = await pub.addFollow([bech32ToHex(SnortPubKey), unwrap(publicKey)], newRelays); - pub.broadcast(ev); - } - - useEffect(() => { - if (newUserKey === true) { - handleNewUser().catch(console.warn); - } - }, [newUserKey]); - - if (typeof loggedOut !== "boolean") { - return null; - } return (
{!shouldHideHeader && (
navigate("/")}>

Snort

- {subscription && ( + {currentSubscription && ( - {mapPlanName(subscription.type)} + {mapPlanName(currentSubscription.type)} )}
@@ -214,7 +171,7 @@ const AccountHeader = () => { const navigate = useNavigate(); const { isMuted } = useModeration(); - const { publicKey, latestNotification, readNotifications } = useSelector((s: RootState) => s.login); + const { publicKey, latestNotification, readNotifications } = useLogin(); const dms = useDmCache(); const hasNotifications = useMemo( diff --git a/packages/app/src/Pages/Login.tsx b/packages/app/src/Pages/Login.tsx index f842c840..204c49c8 100644 --- a/packages/app/src/Pages/Login.tsx +++ b/packages/app/src/Pages/Login.tsx @@ -1,22 +1,23 @@ import "./Login.css"; import { CSSProperties, useEffect, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; import * as secp from "@noble/secp256k1"; import { useIntl, FormattedMessage } from "react-intl"; import { HexKey } from "@snort/nostr"; -import { RootState } from "State/Store"; -import { setPrivateKey, setPublicKey, setRelays, setGeneratedPrivateKey } from "State/Login"; -import { DefaultRelays, EmailRegex, MnemonicRegex } from "Const"; +import { EmailRegex, MnemonicRegex } from "Const"; import { bech32ToHex, unwrap } from "Util"; import { generateBip39Entropy, entropyToDerivedKey } from "nip6"; import ZapButton from "Element/ZapButton"; import useImgProxy from "Hooks/useImgProxy"; +import Icon from "Icons/Icon"; +import useLogin from "Hooks/useLogin"; +import { generateNewLogin, LoginStore } from "Login"; +import useEventPublisher from "Feed/EventPublisher"; +import AsyncButton from "Element/AsyncButton"; import messages from "./messages"; -import Icon from "Icons/Icon"; interface ArtworkEntry { name: string; @@ -24,26 +25,28 @@ interface ArtworkEntry { link: string; } +const KarnageKey = bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"); + // todo: fill more const Artwork: Array = [ { name: "", - pubkey: bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"), + pubkey: KarnageKey, link: "https://void.cat/d/VKhPayp9ekeXYZGzAL9CxP", }, { name: "", - pubkey: bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"), + pubkey: KarnageKey, link: "https://void.cat/d/3H2h8xxc3aEN6EVeobd8tw", }, { name: "", - pubkey: bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"), + pubkey: KarnageKey, link: "https://void.cat/d/7i9W9PXn3TV86C4RUefNC9", }, { name: "", - pubkey: bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"), + pubkey: KarnageKey, link: "https://void.cat/d/KtoX4ei6RYHY7HESg3Ve3k", }, ]; @@ -64,9 +67,9 @@ export async function getNip05PubKey(addr: string): Promise { } export default function LoginPage() { - const dispatch = useDispatch(); const navigate = useNavigate(); - const publicKey = useSelector(s => s.login.publicKey); + const publisher = useEventPublisher(); + const login = useLogin(); const [key, setKey] = useState(""); const [error, setError] = useState(""); const [art, setArt] = useState(); @@ -77,10 +80,10 @@ export default function LoginPage() { const hasSubtleCrypto = window.crypto.subtle !== undefined; useEffect(() => { - if (publicKey) { + if (login.publicKey) { navigate("/"); } - }, [publicKey, navigate]); + }, [login, navigate]); useEffect(() => { const ret = unwrap(Artwork.at(Artwork.length * Math.random())); @@ -99,28 +102,28 @@ export default function LoginPage() { } const hexKey = bech32ToHex(key); if (secp.utils.isValidPrivateKey(hexKey)) { - dispatch(setPrivateKey(hexKey)); + LoginStore.loginWithPrivateKey(hexKey); } else { throw new Error("INVALID PRIVATE KEY"); } } else if (key.startsWith("npub")) { const hexKey = bech32ToHex(key); - dispatch(setPublicKey(hexKey)); + LoginStore.loginWithPubkey(hexKey); } else if (key.match(EmailRegex)) { const hexKey = await getNip05PubKey(key); - dispatch(setPublicKey(hexKey)); + LoginStore.loginWithPubkey(hexKey); } else if (key.match(MnemonicRegex)) { if (!hasSubtleCrypto) { throw new Error(insecureMsg); } const ent = generateBip39Entropy(key); const keyHex = entropyToDerivedKey(ent); - dispatch(setPrivateKey(keyHex)); + LoginStore.loginWithPrivateKey(keyHex); } else if (secp.utils.isValidPrivateKey(key)) { if (!hasSubtleCrypto) { throw new Error(insecureMsg); } - dispatch(setPrivateKey(key)); + LoginStore.loginWithPrivateKey(key); } else { throw new Error("INVALID PRIVATE KEY"); } @@ -139,29 +142,14 @@ export default function LoginPage() { } async function makeRandomKey() { - const ent = generateBip39Entropy(); - const entHex = secp.utils.bytesToHex(ent); - const newKeyHex = entropyToDerivedKey(ent); - dispatch(setGeneratedPrivateKey({ key: newKeyHex, entropy: entHex })); + await generateNewLogin(publisher); navigate("/new"); } async function doNip07Login() { + const relays = "getRelays" in window.nostr ? await window.nostr.getRelays() : undefined; const pubKey = await window.nostr.getPublicKey(); - dispatch(setPublicKey(pubKey)); - - if ("getRelays" in window.nostr) { - const relays = await window.nostr.getRelays(); - dispatch( - setRelays({ - relays: { - ...relays, - ...Object.fromEntries(DefaultRelays.entries()), - }, - createdAt: 1, - }) - ); - } + LoginStore.loginWithPubkey(pubKey, relays); } function altLogins() { @@ -198,9 +186,9 @@ export default function LoginPage() { />

- +
); diff --git a/packages/app/src/Pages/MessagesPage.tsx b/packages/app/src/Pages/MessagesPage.tsx index e31d83cc..27f86264 100644 --- a/packages/app/src/Pages/MessagesPage.tsx +++ b/packages/app/src/Pages/MessagesPage.tsx @@ -1,18 +1,16 @@ import { useMemo } from "react"; import { FormattedMessage } from "react-intl"; -import { useDispatch, useSelector } from "react-redux"; - import { HexKey, RawEvent } from "@snort/nostr"; + import UnreadCount from "Element/UnreadCount"; import ProfileImage from "Element/ProfileImage"; -import { hexToBech32 } from "../Util"; -import { incDmInteraction } from "State/Login"; -import { RootState } from "State/Store"; +import { hexToBech32 } from "Util"; import NoteToSelf from "Element/NoteToSelf"; import useModeration from "Hooks/useModeration"; +import { useDmCache } from "Hooks/useDmsCache"; +import useLogin from "Hooks/useLogin"; import messages from "./messages"; -import { useDmCache } from "Hooks/useDmsCache"; type DmChat = { pubkey: HexKey; @@ -21,18 +19,19 @@ type DmChat = { }; export default function MessagesPage() { - const dispatch = useDispatch(); - const myPubKey = useSelector(s => s.login.publicKey); - const dmInteraction = useSelector(s => s.login.dmInteraction); + const login = useLogin(); const { isMuted } = useModeration(); const dms = useDmCache(); const chats = useMemo(() => { - return extractChats( - dms.filter(a => !isMuted(a.pubkey)), - myPubKey ?? "" - ); - }, [dms, myPubKey, dmInteraction]); + if (login.publicKey) { + return extractChats( + dms.filter(a => !isMuted(a.pubkey)), + login.publicKey + ); + } + return []; + }, [dms, login]); const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unreadMessages, 0), [chats]); @@ -50,7 +49,7 @@ export default function MessagesPage() { } function person(chat: DmChat) { - if (chat.pubkey === myPubKey) return noteToSelf(chat); + if (chat.pubkey === login.publicKey) return noteToSelf(chat); return (
@@ -63,7 +62,6 @@ export default function MessagesPage() { for (const c of chats) { setLastReadDm(c.pubkey); } - dispatch(incDmInteraction()); } return ( @@ -78,7 +76,11 @@ export default function MessagesPage() {
{chats .sort((a, b) => { - return a.pubkey === myPubKey ? -1 : b.pubkey === myPubKey ? 1 : b.newestMessage - a.newestMessage; + return a.pubkey === login.publicKey + ? -1 + : b.pubkey === login.publicKey + ? 1 + : b.newestMessage - a.newestMessage; }) .map(person)}
diff --git a/packages/app/src/Pages/NostrLinkHandler.tsx b/packages/app/src/Pages/NostrLinkHandler.tsx index 079ac13e..8cb2223f 100644 --- a/packages/app/src/Pages/NostrLinkHandler.tsx +++ b/packages/app/src/Pages/NostrLinkHandler.tsx @@ -1,32 +1,25 @@ import { NostrPrefix } from "@snort/nostr"; import { useEffect, useState } from "react"; import { FormattedMessage } from "react-intl"; -import { useDispatch } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; import Spinner from "Icons/Spinner"; -import { setRelays } from "State/Login"; -import { parseNostrLink, profileLink, unixNowMs, unwrap } from "Util"; +import { parseNostrLink, profileLink } from "Util"; import { getNip05PubKey } from "Pages/Login"; +import { System } from "System"; export default function NostrLinkHandler() { const params = useParams(); - const [loading, setLoading] = useState(true); - const dispatch = useDispatch(); const navigate = useNavigate(); + + const [loading, setLoading] = useState(true); const link = decodeURIComponent(params["*"] ?? "").toLowerCase(); async function handleLink(link: string) { const nav = parseNostrLink(link); if (nav) { if ((nav.relays?.length ?? 0) > 0) { - // todo: add as ephemerial connection - dispatch( - setRelays({ - relays: Object.fromEntries(unwrap(nav.relays).map(a => [a, { read: true, write: false }])), - createdAt: unixNowMs(), - }) - ); + nav.relays?.map(a => System.ConnectEphemeralRelay(a)); } if (nav.type === NostrPrefix.Event || nav.type === NostrPrefix.Note || nav.type === NostrPrefix.Address) { navigate(`/e/${nav.encode()}`); diff --git a/packages/app/src/Pages/Notifications.tsx b/packages/app/src/Pages/Notifications.tsx index 1af84d5c..6e9b9032 100644 --- a/packages/app/src/Pages/Notifications.tsx +++ b/packages/app/src/Pages/Notifications.tsx @@ -1,17 +1,15 @@ import { useEffect } from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { HexKey } from "@snort/nostr"; -import { markNotificationsRead } from "State/Login"; -import { RootState } from "State/Store"; + import Timeline from "Element/Timeline"; import { TaskList } from "Tasks/TaskList"; +import useLogin from "Hooks/useLogin"; +import { markNotificationsRead } from "Login"; export default function NotificationsPage() { - const dispatch = useDispatch(); - const pubkey = useSelector(s => s.login.publicKey); + const login = useLogin(); useEffect(() => { - dispatch(markNotificationsRead()); + markNotificationsRead(login); }, []); return ( @@ -19,12 +17,12 @@ export default function NotificationsPage() {
- {pubkey && ( + {login.publicKey && ( (); const user = useUserProfile(id); - const loginPubKey = useSelector((s: RootState) => s.login.publicKey); + const loginPubKey = useLogin().publicKey; const isMe = loginPubKey === id; const [showLnQr, setShowLnQr] = useState(false); const [showProfileQr, setShowProfileQr] = useState(false); diff --git a/packages/app/src/Pages/Root.tsx b/packages/app/src/Pages/Root.tsx index eef64963..ea9c2343 100644 --- a/packages/app/src/Pages/Root.tsx +++ b/packages/app/src/Pages/Root.tsx @@ -12,6 +12,7 @@ import { TimelineSubject } from "Feed/TimelineFeed"; import { debounce, getRelayName, sha256, unixNow, unwrap } from "Util"; import messages from "./messages"; +import useLogin from "Hooks/useLogin"; interface RelayOption { url: string; @@ -22,7 +23,7 @@ export default function RootPage() { const { formatMessage } = useIntl(); const navigate = useNavigate(); const location = useLocation(); - const { publicKey: pubKey, tags, preferences } = useSelector((s: RootState) => s.login); + const { publicKey: pubKey, tags, preferences } = useLogin(); const RootTab: Record = { Posts: { @@ -65,7 +66,7 @@ export default function RootPage() { } }, [location]); - const tagTabs = tags.map((t, idx) => { + const tagTabs = tags.item.map((t, idx) => { return { text: `#${t}`, value: idx + 3, data: `/tag/${t}` }; }); const tabs = [RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global, ...tagTabs]; @@ -81,8 +82,8 @@ export default function RootPage() { } const FollowsHint = () => { - const { publicKey: pubKey, follows } = useSelector((s: RootState) => s.login); - if (follows?.length === 0 && pubKey) { + const { publicKey: pubKey, follows } = useLogin(); + if (follows.item?.length === 0 && pubKey) { return ( { }; const GlobalTab = () => { - const { relays } = useSelector((s: RootState) => s.login); + const { relays } = useLogin(); const [relay, setRelay] = useState(); const [allRelays, setAllRelays] = useState(); const [now] = useState(unixNow()); @@ -177,8 +178,8 @@ const GlobalTab = () => { }; const PostsTab = () => { - const follows = useSelector((s: RootState) => s.login.follows); - const subject: TimelineSubject = { type: "pubkey", items: follows, discriminator: "follows" }; + const { follows } = useLogin(); + const subject: TimelineSubject = { type: "pubkey", items: follows.item, discriminator: "follows" }; return ( <> @@ -189,8 +190,8 @@ const PostsTab = () => { }; const ConversationsTab = () => { - const { follows } = useSelector((s: RootState) => s.login); - const subject: TimelineSubject = { type: "pubkey", items: follows, discriminator: "follows" }; + const { follows } = useLogin(); + const subject: TimelineSubject = { type: "pubkey", items: follows.item, discriminator: "follows" }; return ; }; diff --git a/packages/app/src/Pages/new/DiscoverFollows.tsx b/packages/app/src/Pages/new/DiscoverFollows.tsx index 7fbf8e45..adef55c0 100644 --- a/packages/app/src/Pages/new/DiscoverFollows.tsx +++ b/packages/app/src/Pages/new/DiscoverFollows.tsx @@ -1,24 +1,25 @@ +import { useMemo } from "react"; import { useIntl, FormattedMessage } from "react-intl"; -import { useDispatch } from "react-redux"; import { useNavigate, Link } from "react-router-dom"; + import { RecommendedFollows } from "Const"; import Logo from "Element/Logo"; import FollowListBase from "Element/FollowListBase"; -import { useMemo } from "react"; -import { clearEntropy } from "State/Login"; +import { clearEntropy } from "Login"; +import useLogin from "Hooks/useLogin"; import messages from "./messages"; export default function DiscoverFollows() { const { formatMessage } = useIntl(); - const dispatch = useDispatch(); + const login = useLogin(); const navigate = useNavigate(); const sortedReccomends = useMemo(() => { return RecommendedFollows.sort(() => (Math.random() >= 0.5 ? -1 : 1)).map(a => a.toLowerCase()); }, []); async function clearEntropyAndGo() { - dispatch(clearEntropy()); + clearEntropy(login); navigate("/"); } diff --git a/packages/app/src/Pages/new/GetVerified.tsx b/packages/app/src/Pages/new/GetVerified.tsx index 79265e38..d3d7436f 100644 --- a/packages/app/src/Pages/new/GetVerified.tsx +++ b/packages/app/src/Pages/new/GetVerified.tsx @@ -1,20 +1,19 @@ import { useState } from "react"; import { FormattedMessage } from "react-intl"; import { useNavigate } from "react-router-dom"; -import { useSelector } from "react-redux"; import Logo from "Element/Logo"; import { services } from "Pages/Verification"; import Nip5Service from "Element/Nip5Service"; import ProfileImage from "Element/ProfileImage"; -import type { RootState } from "State/Store"; import { useUserProfile } from "Hooks/useUserProfile"; +import useLogin from "Hooks/useLogin"; import messages from "./messages"; export default function GetVerified() { const navigate = useNavigate(); - const { publicKey } = useSelector((s: RootState) => s.login); + const { publicKey } = useLogin(); const user = useUserProfile(publicKey); const [isVerified, setIsVerified] = useState(false); const name = user?.name || "nostrich"; diff --git a/packages/app/src/Pages/new/ImportFollows.tsx b/packages/app/src/Pages/new/ImportFollows.tsx index 1ac5e458..dccb7709 100644 --- a/packages/app/src/Pages/new/ImportFollows.tsx +++ b/packages/app/src/Pages/new/ImportFollows.tsx @@ -1,5 +1,4 @@ import { useMemo, useState } from "react"; -import { useSelector } from "react-redux"; import { useIntl, FormattedMessage } from "react-intl"; import { useNavigate } from "react-router-dom"; @@ -7,15 +6,15 @@ import { ApiHost } from "Const"; import Logo from "Element/Logo"; import AsyncButton from "Element/AsyncButton"; import FollowListBase from "Element/FollowListBase"; -import { RootState } from "State/Store"; import { bech32ToHex } from "Util"; import SnortApi from "SnortApi"; +import useLogin from "Hooks/useLogin"; import messages from "./messages"; export default function ImportFollows() { const navigate = useNavigate(); - const currentFollows = useSelector((s: RootState) => s.login.follows); + const currentFollows = useLogin().follows; const { formatMessage } = useIntl(); const [twitterUsername, setTwitterUsername] = useState(""); const [follows, setFollows] = useState([]); @@ -23,7 +22,7 @@ export default function ImportFollows() { const api = new SnortApi(ApiHost); 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.item.includes(a) ? 1 : -1)); }, [follows, currentFollows]); async function loadFollows() { diff --git a/packages/app/src/Pages/new/NewUserFlow.tsx b/packages/app/src/Pages/new/NewUserFlow.tsx index 891ea943..ae1ac6db 100644 --- a/packages/app/src/Pages/new/NewUserFlow.tsx +++ b/packages/app/src/Pages/new/NewUserFlow.tsx @@ -1,13 +1,12 @@ -import { useSelector } from "react-redux"; import { FormattedMessage } from "react-intl"; import { useNavigate } from "react-router-dom"; import Logo from "Element/Logo"; import { CollapsedSection } from "Element/Collapsed"; import Copy from "Element/Copy"; -import { RootState } from "State/Store"; import { hexToBech32 } from "Util"; import { hexToMnemonic } from "nip6"; +import useLogin from "Hooks/useLogin"; import messages from "./messages"; @@ -69,7 +68,7 @@ const Extensions = () => { }; export default function NewUserFlow() { - const { publicKey, privateKey, generatedEntropy } = useSelector((s: RootState) => s.login); + const { publicKey, privateKey, generatedEntropy } = useLogin(); const navigate = useNavigate(); return ( diff --git a/packages/app/src/Pages/settings/Index.tsx b/packages/app/src/Pages/settings/Index.tsx index d04332d1..71622667 100644 --- a/packages/app/src/Pages/settings/Index.tsx +++ b/packages/app/src/Pages/settings/Index.tsx @@ -1,22 +1,20 @@ import "./Index.css"; import { FormattedMessage } from "react-intl"; -import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; import Icon from "Icons/Icon"; -import { logout } from "State/Login"; +import { logout } from "Login"; +import useLogin from "Hooks/useLogin"; +import { unwrap } from "Util"; import messages from "./messages"; const SettingsIndex = () => { - const dispatch = useDispatch(); + const login = useLogin(); const navigate = useNavigate(); function handleLogout() { - dispatch( - logout(() => { - navigate("/"); - }) - ); + logout(unwrap(login.publicKey)); + navigate("/"); } return ( diff --git a/packages/app/src/Pages/settings/Preferences.tsx b/packages/app/src/Pages/settings/Preferences.tsx index 894c3801..e477d7d4 100644 --- a/packages/app/src/Pages/settings/Preferences.tsx +++ b/packages/app/src/Pages/settings/Preferences.tsx @@ -1,20 +1,20 @@ import "./Preferences.css"; -import { useDispatch, useSelector } from "react-redux"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; import emoji from "@jukben/emoji-search"; -import { DefaultImgProxy, setPreferences, UserPreferences } from "State/Login"; -import { RootState } from "State/Store"; +import useLogin from "Hooks/useLogin"; +import { updatePreferences, UserPreferences } from "Login"; +import { DefaultImgProxy } from "Const"; import { unwrap } from "Util"; import messages from "./messages"; const PreferencesPage = () => { - const dispatch = useDispatch(); const { formatMessage } = useIntl(); - const perf = useSelector(s => s.login.preferences); + const login = useLogin(); + const perf = login.preferences; return (
@@ -32,12 +32,10 @@ const PreferencesPage = () => { - dispatch( - setPreferences({ - ...perf, - theme: e.target.value, - } as UserPreferences) - ) + updatePreferences(login, { + ...perf, + theme: e.target.value, + } as UserPreferences) }>
@@ -189,7 +181,7 @@ const PreferencesPage = () => { defaultValue={perf.fastZapDonate * 100} min={0} max={100} - onChange={e => dispatch(setPreferences({ ...perf, fastZapDonate: parseInt(e.target.value || "0") / 100 }))} + onChange={e => updatePreferences(login, { ...perf, fastZapDonate: parseInt(e.target.value || "0") / 100 })} />
@@ -206,7 +198,7 @@ const PreferencesPage = () => { dispatch(setPreferences({ ...perf, autoZap: e.target.checked }))} + onChange={e => updatePreferences(login, { ...perf, autoZap: e.target.checked })} /> @@ -225,12 +217,10 @@ const PreferencesPage = () => { type="checkbox" checked={perf.imgProxyConfig !== null} onChange={e => - dispatch( - setPreferences({ - ...perf, - imgProxyConfig: e.target.checked ? DefaultImgProxy : null, - }) - ) + updatePreferences(login, { + ...perf, + imgProxyConfig: e.target.checked ? DefaultImgProxy : null, + }) } /> @@ -250,15 +240,13 @@ const PreferencesPage = () => { description: "Placeholder text for imgproxy url textbox", })} onChange={e => - dispatch( - setPreferences({ - ...perf, - imgProxyConfig: { - ...unwrap(perf.imgProxyConfig), - url: e.target.value, - }, - }) - ) + updatePreferences(login, { + ...perf, + imgProxyConfig: { + ...unwrap(perf.imgProxyConfig), + url: e.target.value, + }, + }) } /> @@ -276,15 +264,13 @@ const PreferencesPage = () => { description: "Hexidecimal 'key' input for improxy", })} onChange={e => - dispatch( - setPreferences({ - ...perf, - imgProxyConfig: { - ...unwrap(perf.imgProxyConfig), - key: e.target.value, - }, - }) - ) + updatePreferences(login, { + ...perf, + imgProxyConfig: { + ...unwrap(perf.imgProxyConfig), + key: e.target.value, + }, + }) } /> @@ -302,15 +288,13 @@ const PreferencesPage = () => { description: "Hexidecimal 'salt' input for imgproxy", })} onChange={e => - dispatch( - setPreferences({ - ...perf, - imgProxyConfig: { - ...unwrap(perf.imgProxyConfig), - salt: e.target.value, - }, - }) - ) + updatePreferences(login, { + ...perf, + imgProxyConfig: { + ...unwrap(perf.imgProxyConfig), + salt: e.target.value, + }, + }) } /> @@ -331,7 +315,7 @@ const PreferencesPage = () => { dispatch(setPreferences({ ...perf, enableReactions: e.target.checked }))} + onChange={e => updatePreferences(login, { ...perf, enableReactions: e.target.checked })} /> @@ -348,12 +332,10 @@ const PreferencesPage = () => { className="emoji-selector" value={perf.reactionEmoji} onChange={e => - dispatch( - setPreferences({ - ...perf, - reactionEmoji: e.target.value, - } as UserPreferences) - ) + updatePreferences(login, { + ...perf, + reactionEmoji: e.target.value, + }) }>