diff --git a/packages/app/public/icons.svg b/packages/app/public/icons.svg index e6e5d47f..68067aff 100644 --- a/packages/app/public/icons.svg +++ b/packages/app/public/icons.svg @@ -1,407 +1,143 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - + + + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + + + + \ No newline at end of file diff --git a/packages/app/src/Cache/FeedCache.ts b/packages/app/src/Cache/FeedCache.ts index 387093ae..0c53ca1e 100644 --- a/packages/app/src/Cache/FeedCache.ts +++ b/packages/app/src/Cache/FeedCache.ts @@ -154,7 +154,7 @@ export default abstract class FeedCache { protected notifyChange(keys: Array) { this.#changed = true; - this.#hooks.filter(a => keys.includes(a.key)).forEach(h => h.fn()); + this.#hooks.filter(a => keys.includes(a.key) || a.key === "*").forEach(h => h.fn()); } abstract key(of: TCached): string; diff --git a/packages/app/src/Element/NoteCreator.tsx b/packages/app/src/Element/NoteCreator.tsx index a89c4f47..b6c9ad6f 100644 --- a/packages/app/src/Element/NoteCreator.tsx +++ b/packages/app/src/Element/NoteCreator.tsx @@ -31,6 +31,9 @@ import messages from "./messages"; import { ClipboardEventHandler, useState } from "react"; import Spinner from "Icons/Spinner"; import { EventBuilder } from "System"; +import { Menu, MenuItem } from "@szhsin/react-menu"; +import { LoginStore } from "Login"; +import { getCurrentSubscription } from "Subscription"; interface NotePreviewProps { note: TaggedRawEvent; @@ -56,6 +59,7 @@ export function NoteCreator() { useSelector((s: RootState) => s.noteCreator); const [uploadInProgress, setUploadInProgress] = useState(false); const dispatch = useDispatch(); + const sub = getCurrentSubscription(LoginStore.allSubscriptions()); async function sendNote() { if (note && publisher) { @@ -229,6 +233,18 @@ export function NoteCreator() { } } + function listAccounts() { + return LoginStore.getSessions().map(a => ( + { + ev.stopPropagation = true; + LoginStore.switchAccount(a); + }}> + + + )); + } + const handlePaste: ClipboardEventHandler = evt => { if (evt.clipboardData) { const clipboardItems = evt.clipboardData.items; @@ -273,12 +289,23 @@ export function NoteCreator() { /> {renderPollOptions()}
+ {sub && ( + + + + } + menuClassName="ctx-menu"> + {listAccounts()} + + )} {pollOptions === undefined && !replyTo && ( - )} -
@@ -288,19 +315,19 @@ export function NoteCreator() { )}
{uploadInProgress && } - - -
{showAdvanced && (
-

diff --git a/packages/app/src/Element/ProfileImage.tsx b/packages/app/src/Element/ProfileImage.tsx index 8697ed53..89ca564e 100644 --- a/packages/app/src/Element/ProfileImage.tsx +++ b/packages/app/src/Element/ProfileImage.tsx @@ -1,12 +1,13 @@ import "./ProfileImage.css"; import { useMemo } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { HexKey, NostrPrefix } from "@snort/nostr"; + import { useUserProfile } from "Hooks/useUserProfile"; import { hexToBech32, profileLink } from "Util"; import Avatar from "Element/Avatar"; import Nip05 from "Element/Nip05"; -import { HexKey, NostrPrefix } from "@snort/nostr"; import { MetadataCache } from "Cache"; import usePageWidth from "Hooks/usePageWidth"; @@ -55,17 +56,17 @@ export default function ProfileImage({ }; return ( -
+
- +
{showUsername && (
- +
{name.trim()}
{nip05 && } - +
{subHeader} diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index 02062c7f..b5683e45 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -26,7 +26,7 @@ export default function useLoginFeed() { const subLogin = useMemo(() => { if (!pubKey) return null; - const b = new RequestBuilder("login"); + const b = new RequestBuilder(`login:${pubKey.slice(0, 12)}`); b.withOptions({ leaveOpen: true, }); @@ -46,7 +46,7 @@ export default function useLoginFeed() { const subLists = useMemo(() => { if (!pubKey) return null; - const b = new RequestBuilder("login:lists"); + const b = new RequestBuilder(`login:${pubKey.slice(0, 12)}:lists`); b.withOptions({ leaveOpen: true, }); diff --git a/packages/app/src/Hooks/useDmsCache.tsx b/packages/app/src/Hooks/useDmsCache.tsx index ae7f9f9a..51f09f04 100644 --- a/packages/app/src/Hooks/useDmsCache.tsx +++ b/packages/app/src/Hooks/useDmsCache.tsx @@ -3,7 +3,7 @@ import { useSyncExternalStore } from "react"; export function useDmCache() { return useSyncExternalStore( - c => DmCache.hook(c, undefined), + c => DmCache.hook(c, "*"), () => DmCache.snapshot() ); } diff --git a/packages/app/src/Hooks/useLoginHandler.tsx b/packages/app/src/Hooks/useLoginHandler.tsx new file mode 100644 index 00000000..fc14ad7e --- /dev/null +++ b/packages/app/src/Hooks/useLoginHandler.tsx @@ -0,0 +1,55 @@ +import { useIntl } from "react-intl"; +import * as secp from "@noble/secp256k1"; + +import { EmailRegex, MnemonicRegex } from "Const"; +import { LoginStore } from "Login"; +import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; +import { getNip05PubKey } from "Pages/Login"; +import { bech32ToHex } from "Util"; + +export default function useLoginHandler() { + const { formatMessage } = useIntl(); + const hasSubtleCrypto = window.crypto.subtle !== undefined; + + async function doLogin(key: string) { + const insecureMsg = formatMessage({ + defaultMessage: + "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead", + }); + if (key.startsWith("nsec")) { + if (!hasSubtleCrypto) { + throw new Error(insecureMsg); + } + const hexKey = bech32ToHex(key); + if (secp.utils.isValidPrivateKey(hexKey)) { + LoginStore.loginWithPrivateKey(hexKey); + } else { + throw new Error("INVALID PRIVATE KEY"); + } + } else if (key.startsWith("npub")) { + const hexKey = bech32ToHex(key); + LoginStore.loginWithPubkey(hexKey); + } else if (key.match(EmailRegex)) { + const hexKey = await getNip05PubKey(key); + LoginStore.loginWithPubkey(hexKey); + } else if (key.match(MnemonicRegex)) { + if (!hasSubtleCrypto) { + throw new Error(insecureMsg); + } + const ent = generateBip39Entropy(key); + const keyHex = entropyToPrivateKey(ent); + LoginStore.loginWithPrivateKey(keyHex); + } else if (secp.utils.isValidPrivateKey(key)) { + if (!hasSubtleCrypto) { + throw new Error(insecureMsg); + } + LoginStore.loginWithPrivateKey(key); + } else { + throw new Error("INVALID PRIVATE KEY"); + } + } + + return { + doLogin, + }; +} diff --git a/packages/app/src/Login/Functions.ts b/packages/app/src/Login/Functions.ts index dd68d1b3..567a4cfa 100644 --- a/packages/app/src/Login/Functions.ts +++ b/packages/app/src/Login/Functions.ts @@ -5,7 +5,7 @@ import { DefaultRelays, SnortPubKey } from "Const"; import { LoginStore, UserPreferences, LoginSession } from "Login"; import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; import { bech32ToHex, dedupeById, randomSample, sanitizeRelayUrl, unixNowMs, unwrap } from "Util"; -import { getCurrentSubscription, SubscriptionEvent } from "Subscription"; +import { SubscriptionEvent } from "Subscription"; import { EventPublisher } from "System/EventPublisher"; export function setRelays(state: LoginSession, relays: Record, createdAt: number) { @@ -150,7 +150,7 @@ export function setBookmarked(state: LoginSession, bookmarked: Array, ts export function addSubscription(state: LoginSession, ...subs: SubscriptionEvent[]) { const newSubs = dedupeById([...(state.subscriptions || []), ...subs]); if (newSubs.length !== state.subscriptions.length) { - state.currentSubscription = getCurrentSubscription(state.subscriptions); + state.subscriptions = newSubs; LoginStore.updateSession(state); } } diff --git a/packages/app/src/Login/LoginSession.ts b/packages/app/src/Login/LoginSession.ts index dd4776b6..ab7dd421 100644 --- a/packages/app/src/Login/LoginSession.ts +++ b/packages/app/src/Login/LoginSession.ts @@ -80,9 +80,4 @@ export interface LoginSession { * 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 index 31850558..552c9fc2 100644 --- a/packages/app/src/Login/MultiAccountStore.ts +++ b/packages/app/src/Login/MultiAccountStore.ts @@ -73,6 +73,17 @@ export class MultiAccountStore extends ExternalStore { return [...this.#accounts.keys()]; } + allSubscriptions() { + return [...this.#accounts.values()].map(a => a.subscriptions).flat(); + } + + switchAccount(pk: string) { + if (this.#accounts.has(pk)) { + this.#activeAccount = pk; + this.#save(); + } + } + loginWithPubkey(key: HexKey, relays?: Record) { if (this.#accounts.has(key)) { throw new Error("Already logged in with this pubkey"); @@ -127,6 +138,9 @@ export class MultiAccountStore extends ExternalStore { removeSession(k: string) { if (this.#accounts.delete(k)) { + if (this.#activeAccount === k) { + this.#activeAccount = undefined; + } this.#save(); } } diff --git a/packages/app/src/Pages/ChatPage.tsx b/packages/app/src/Pages/ChatPage.tsx index 3603c148..976e2bab 100644 --- a/packages/app/src/Pages/ChatPage.tsx +++ b/packages/app/src/Pages/ChatPage.tsx @@ -2,13 +2,13 @@ import "./ChatPage.css"; import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { FormattedMessage } from "react-intl"; -import { RawEvent, TaggedRawEvent } from "@snort/nostr"; +import { TaggedRawEvent } from "@snort/nostr"; import ProfileImage from "Element/ProfileImage"; import { bech32ToHex } from "Util"; import useEventPublisher from "Feed/EventPublisher"; import DM from "Element/DM"; -import { dmsInChat, isToSelf } from "Pages/MessagesPage"; +import { dmsForLogin, dmsInChat, isToSelf } from "Pages/MessagesPage"; import NoteToSelf from "Element/NoteToSelf"; import { useDmCache } from "Hooks/useDmsCache"; import useLogin from "Hooks/useLogin"; @@ -24,15 +24,17 @@ export default function ChatPage() { const pubKey = useLogin().publicKey; const [content, setContent] = useState(); const dmListRef = useRef(null); - const dms = filterDms(useDmCache()); - - function filterDms(dms: readonly RawEvent[]) { - return dmsInChat(id === pubKey ? dms.filter(d => isToSelf(d, pubKey)) : dms, id); - } + const dms = useDmCache(); const sortedDms = useMemo(() => { - return [...dms].sort((a, b) => a.created_at - b.created_at); - }, [dms]); + if (pubKey) { + const myDms = dmsForLogin(dms, pubKey); + // filter dms in this chat, or dms to self + const thisDms = id === pubKey ? myDms.filter(d => isToSelf(d, pubKey)) : myDms; + return [...dmsInChat(thisDms, id)].sort((a, b) => a.created_at - b.created_at); + } + return []; + }, [dms, pubKey]); useEffect(() => { if (dmListRef.current) { diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index c5d31ab0..3828af89 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -24,6 +24,7 @@ import useLogin from "Hooks/useLogin"; import Avatar from "Element/Avatar"; import { useUserProfile } from "Hooks/useUserProfile"; import { profileLink } from "Util"; +import { getCurrentSubscription } from "Subscription"; export default function Layout() { const location = useLocation(); @@ -32,7 +33,8 @@ export default function Layout() { const isReplyNoteCreatorShowing = replyTo && isNoteCreatorShowing; const dispatch = useDispatch(); const navigate = useNavigate(); - const { publicKey, relays, preferences, currentSubscription } = useLogin(); + const { publicKey, relays, preferences, subscriptions } = useLogin(); + const currentSubscription = getCurrentSubscription(subscriptions); const [pageClass, setPageClass] = useState("page"); const pub = useEventPublisher(); useLoginFeed(); diff --git a/packages/app/src/Pages/Login.tsx b/packages/app/src/Pages/Login.tsx index 2e45cd96..35125498 100644 --- a/packages/app/src/Pages/Login.tsx +++ b/packages/app/src/Pages/Login.tsx @@ -2,21 +2,17 @@ import "./Login.css"; import { CSSProperties, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import * as secp from "@noble/secp256k1"; import { useIntl, FormattedMessage } from "react-intl"; import { HexKey } from "@snort/nostr"; -import { EmailRegex, MnemonicRegex } from "Const"; import { bech32ToHex, unwrap } from "Util"; -import { generateBip39Entropy, entropyToPrivateKey } 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 AsyncButton from "Element/AsyncButton"; - -import messages from "./messages"; +import useLoginHandler from "Hooks/useLoginHandler"; interface ArtworkEntry { name: string; @@ -74,6 +70,7 @@ export default function LoginPage() { const [isMasking, setMasking] = useState(true); const { formatMessage } = useIntl(); const { proxy } = useImgProxy(); + const loginHandler = useLoginHandler(); const hasNip7 = "nostr" in window; const hasSubtleCrypto = window.crypto.subtle !== undefined; @@ -90,42 +87,8 @@ export default function LoginPage() { }, []); async function doLogin() { - const insecureMsg = formatMessage({ - defaultMessage: - "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead", - }); try { - if (key.startsWith("nsec")) { - if (!hasSubtleCrypto) { - throw new Error(insecureMsg); - } - const hexKey = bech32ToHex(key); - if (secp.utils.isValidPrivateKey(hexKey)) { - LoginStore.loginWithPrivateKey(hexKey); - } else { - throw new Error("INVALID PRIVATE KEY"); - } - } else if (key.startsWith("npub")) { - const hexKey = bech32ToHex(key); - LoginStore.loginWithPubkey(hexKey); - } else if (key.match(EmailRegex)) { - const hexKey = await getNip05PubKey(key); - LoginStore.loginWithPubkey(hexKey); - } else if (key.match(MnemonicRegex)) { - if (!hasSubtleCrypto) { - throw new Error(insecureMsg); - } - const ent = generateBip39Entropy(key); - const keyHex = entropyToPrivateKey(ent); - LoginStore.loginWithPrivateKey(keyHex); - } else if (secp.utils.isValidPrivateKey(key)) { - if (!hasSubtleCrypto) { - throw new Error(insecureMsg); - } - LoginStore.loginWithPrivateKey(key); - } else { - throw new Error("INVALID PRIVATE KEY"); - } + await loginHandler.doLogin(key); } catch (e) { if (e instanceof Error) { setError(e.message); @@ -242,7 +205,9 @@ export default function LoginPage() { setKey(e.target.value)} /> diff --git a/packages/app/src/Pages/MessagesPage.tsx b/packages/app/src/Pages/MessagesPage.tsx index 27f86264..c0c1c0d3 100644 --- a/packages/app/src/Pages/MessagesPage.tsx +++ b/packages/app/src/Pages/MessagesPage.tsx @@ -4,7 +4,7 @@ import { HexKey, RawEvent } from "@snort/nostr"; import UnreadCount from "Element/UnreadCount"; import ProfileImage from "Element/ProfileImage"; -import { hexToBech32 } from "Util"; +import { dedupe, hexToBech32, unwrap } from "Util"; import NoteToSelf from "Element/NoteToSelf"; import useModeration from "Hooks/useModeration"; import { useDmCache } from "Hooks/useDmsCache"; @@ -31,7 +31,7 @@ export default function MessagesPage() { ); } return []; - }, [dms, login]); + }, [dms, login.publicKey]); const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unreadMessages, 0), [chats]); @@ -105,7 +105,7 @@ export function setLastReadDm(pk: HexKey) { export function dmTo(e: RawEvent) { const firstP = e.tags.find(b => b[0] === "p"); - return firstP ? firstP[1] : ""; + return unwrap(firstP?.[1]); } export function isToSelf(e: Readonly, pk: HexKey) { @@ -137,14 +137,19 @@ function newestMessage(dms: RawEvent[], myPubKey: HexKey, pk: HexKey) { return dmsInChat(dms, pk).reduce((acc, v) => (acc = v.created_at > acc ? v.created_at : acc), 0); } +export function dmsForLogin(dms: readonly RawEvent[], myPubKey: HexKey) { + return dms.filter(a => a.pubkey === myPubKey || (a.pubkey !== myPubKey && dmTo(a) === myPubKey)); +} + export function extractChats(dms: RawEvent[], myPubKey: HexKey) { - const keys = dms.map(a => [a.pubkey, dmTo(a)]).flat(); - const filteredKeys = Array.from(new Set(keys)); + const myDms = dmsForLogin(dms, myPubKey); + const keys = myDms.map(a => [a.pubkey, dmTo(a)]).flat(); + const filteredKeys = dedupe(keys); return filteredKeys.map(a => { return { pubkey: a, - unreadMessages: unreadDms(dms, myPubKey, a), - newestMessage: newestMessage(dms, myPubKey, a), + unreadMessages: unreadDms(myDms, myPubKey, a), + newestMessage: newestMessage(myDms, myPubKey, a), } as DmChat; }); } diff --git a/packages/app/src/Pages/Root.tsx b/packages/app/src/Pages/Root.tsx index bd336f2b..106779c7 100644 --- a/packages/app/src/Pages/Root.tsx +++ b/packages/app/src/Pages/Root.tsx @@ -185,8 +185,12 @@ const GlobalTab = () => { }; const PostsTab = () => { - const { follows } = useLogin(); - const subject: TimelineSubject = { type: "pubkey", items: follows.item, discriminator: "follows" }; + const { follows, publicKey } = useLogin(); + const subject: TimelineSubject = { + type: "pubkey", + items: follows.item, + discriminator: `follows:${publicKey?.slice(0, 12)}`, + }; return ( <> @@ -197,8 +201,12 @@ const PostsTab = () => { }; const ConversationsTab = () => { - const { follows } = useLogin(); - const subject: TimelineSubject = { type: "pubkey", items: follows.item, discriminator: "follows" }; + const { follows, publicKey } = useLogin(); + const subject: TimelineSubject = { + type: "pubkey", + items: follows.item, + discriminator: `follows:${publicKey?.slice(0, 12)}`, + }; return ; }; diff --git a/packages/app/src/Pages/SettingsPage.tsx b/packages/app/src/Pages/SettingsPage.tsx index 9cc786e8..d282b0a0 100644 --- a/packages/app/src/Pages/SettingsPage.tsx +++ b/packages/app/src/Pages/SettingsPage.tsx @@ -5,6 +5,7 @@ import Profile from "Pages/settings/Profile"; import Relay from "Pages/settings/Relays"; import Preferences from "Pages/settings/Preferences"; import RelayInfo from "Pages/settings/RelayInfo"; +import AccountsPage from "Pages/settings/Accounts"; import { WalletSettingsRoutes } from "Pages/settings/WalletSettings"; import { ManageHandleRoutes } from "Pages/settings/handle"; @@ -44,6 +45,10 @@ export const SettingsRoutes: RouteObject[] = [ path: "preferences", element: , }, + { + path: "accounts", + element: , + }, ...ManageHandleRoutes, ...WalletSettingsRoutes, ]; diff --git a/packages/app/src/Pages/messages.ts b/packages/app/src/Pages/messages.ts index 60955b6b..b715720b 100644 --- a/packages/app/src/Pages/messages.ts +++ b/packages/app/src/Pages/messages.ts @@ -46,5 +46,4 @@ export default defineMessages({ }, Bookmarks: { defaultMessage: "Bookmarks" }, BookmarksCount: { defaultMessage: "{n} Bookmarks" }, - KeyPlaceholder: { defaultMessage: "nsec, npub, nip-05, hex, mnemonic" }, }); diff --git a/packages/app/src/Pages/settings/Accounts.tsx b/packages/app/src/Pages/settings/Accounts.tsx new file mode 100644 index 00000000..9ad66696 --- /dev/null +++ b/packages/app/src/Pages/settings/Accounts.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import ProfilePreview from "Element/ProfilePreview"; +import { LoginStore } from "Login"; +import useLoginHandler from "Hooks/useLoginHandler"; +import AsyncButton from "Element/AsyncButton"; +import { getActiveSubscriptions } from "Subscription"; + +export default function AccountsPage() { + const { formatMessage } = useIntl(); + const [key, setKey] = useState(""); + const [error, setError] = useState(""); + const loginHandler = useLoginHandler(); + const logins = LoginStore.getSessions(); + const sub = getActiveSubscriptions(LoginStore.allSubscriptions()); + + async function doLogin() { + try { + setError(""); + await loginHandler.doLogin(key); + setKey(""); + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } else { + setError( + formatMessage({ + defaultMessage: "Unknown login error", + }) + ); + } + console.error(e); + } + } + + return ( + <> +

+ +

+ {logins.map(a => ( +
+ + + +
+ } + /> +
+ ))} + + {sub && ( + <> +

+ +

+
+ setKey(e.target.value)} + /> + doLogin()}> + + +
+ + )} + {error && {error}} + + ); +} diff --git a/packages/app/src/Pages/settings/Index.tsx b/packages/app/src/Pages/settings/Index.tsx index 403ecf39..0d838ece 100644 --- a/packages/app/src/Pages/settings/Index.tsx +++ b/packages/app/src/Pages/settings/Index.tsx @@ -2,15 +2,17 @@ import "./Index.css"; import { FormattedMessage } from "react-intl"; import { useNavigate } from "react-router-dom"; import Icon from "Icons/Icon"; -import { logout } from "Login"; +import { LoginStore, logout } from "Login"; import useLogin from "Hooks/useLogin"; import { unwrap } from "Util"; +import { getCurrentSubscription } from "Subscription"; import messages from "./messages"; const SettingsIndex = () => { const login = useLogin(); const navigate = useNavigate(); + const sub = getCurrentSubscription(LoginStore.allSubscriptions()); function handleLogout() { logout(unwrap(login.publicKey)); @@ -55,6 +57,13 @@ const SettingsIndex = () => {
+ {sub && ( +
navigate("accounts")}> + + + +
+ )}