feat: account switching

This commit is contained in:
2023-04-19 13:10:41 +01:00
parent 9dacad430a
commit e5b215abb5
19 changed files with 310 additions and 400 deletions

View File

@ -154,7 +154,7 @@ export default abstract class FeedCache<TCached> {
protected notifyChange(keys: Array<string>) {
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;

View File

@ -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 => (
<MenuItem
onClick={ev => {
ev.stopPropagation = true;
LoginStore.switchAccount(a);
}}>
<ProfileImage pubkey={a} linkToProfile={false} />
</MenuItem>
));
}
const handlePaste: ClipboardEventHandler<HTMLDivElement> = evt => {
if (evt.clipboardData) {
const clipboardItems = evt.clipboardData.items;
@ -273,12 +289,23 @@ export function NoteCreator() {
/>
{renderPollOptions()}
<div className="insert">
{sub && (
<Menu
menuButton={
<button>
<Icon name="code-circle" />
</button>
}
menuClassName="ctx-menu">
{listAccounts()}
</Menu>
)}
{pollOptions === undefined && !replyTo && (
<button type="button" onClick={() => dispatch(setPollOptions(["A", "B"]))}>
<button onClick={() => dispatch(setPollOptions(["A", "B"]))}>
<Icon name="pie-chart" />
</button>
)}
<button type="button" onClick={attachFile}>
<button onClick={attachFile}>
<Icon name="attachment" />
</button>
</div>
@ -288,19 +315,19 @@ export function NoteCreator() {
)}
<div className="note-creator-actions">
{uploadInProgress && <Spinner />}
<button className="secondary" type="button" onClick={() => dispatch(setShowAdvanced(!showAdvanced))}>
<button className="secondary" onClick={() => dispatch(setShowAdvanced(!showAdvanced))}>
<FormattedMessage defaultMessage="Advanced" />
</button>
<button className="secondary" type="button" onClick={cancel}>
<button className="secondary" onClick={cancel}>
<FormattedMessage {...messages.Cancel} />
</button>
<button type="button" onClick={onSubmit}>
<button onClick={onSubmit}>
{replyTo ? <FormattedMessage {...messages.Reply} /> : <FormattedMessage {...messages.Send} />}
</button>
</div>
{showAdvanced && (
<div>
<button className="secondary" type="button" onClick={loadPreview}>
<button className="secondary" onClick={loadPreview}>
<FormattedMessage defaultMessage="Toggle Preview" />
</button>
<h4>

View File

@ -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 (
<div className={`pfp f-ellipsis${className ? ` ${className}` : ""}`}>
<div className={`pfp f-ellipsis${className ? ` ${className}` : ""}`} onClick={onAvatarClick}>
<div className="avatar-wrapper">
<Avatar user={user} onClick={onAvatarClick} />
<Avatar user={user} />
</div>
{showUsername && (
<div className="profile-name">
<div className="username">
<Link className="display-name" key={pubkey} to={link ?? profileLink(pubkey)}>
<div className="display-name">
<div>{name.trim()}</div>
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} verifyNip={verifyNip} />}
</Link>
</div>
</div>
<div className="subheader" style={{ width: autoWidth ? width - 190 : "" }}>
{subHeader}

View File

@ -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,
});

View File

@ -3,7 +3,7 @@ import { useSyncExternalStore } from "react";
export function useDmCache() {
return useSyncExternalStore(
c => DmCache.hook(c, undefined),
c => DmCache.hook(c, "*"),
() => DmCache.snapshot()
);
}

View File

@ -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,
};
}

View File

@ -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<string, RelaySettings>, createdAt: number) {
@ -150,7 +150,7 @@ export function setBookmarked(state: LoginSession, bookmarked: Array<string>, 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);
}
}

View File

@ -80,9 +80,4 @@ export interface LoginSession {
* Snort subscriptions licences
*/
subscriptions: Array<SubscriptionEvent>;
/**
* Current active subscription
*/
currentSubscription?: SubscriptionEvent;
}

View File

@ -73,6 +73,17 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
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<string, RelaySettings>) {
if (this.#accounts.has(key)) {
throw new Error("Already logged in with this pubkey");
@ -127,6 +138,9 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
removeSession(k: string) {
if (this.#accounts.delete(k)) {
if (this.#activeAccount === k) {
this.#activeAccount = undefined;
}
this.#save();
}
}

View File

@ -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<string>();
const dmListRef = useRef<HTMLDivElement>(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) {

View File

@ -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();

View File

@ -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() {
<input
dir="auto"
type={isMasking ? "password" : "text"}
placeholder={formatMessage(messages.KeyPlaceholder)}
placeholder={formatMessage({
defaultMessage: "nsec, npub, nip-05, hex, mnemonic",
})}
className="f-grow"
onChange={e => setKey(e.target.value)}
/>

View File

@ -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<RawEvent>, 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<string>(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;
});
}

View File

@ -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 <Timeline subject={subject} postsOnly={false} method={"TIME_RANGE"} window={undefined} relay={undefined} />;
};

View File

@ -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: <Preferences />,
},
{
path: "accounts",
element: <AccountsPage />,
},
...ManageHandleRoutes,
...WalletSettingsRoutes,
];

View File

@ -46,5 +46,4 @@ export default defineMessages({
},
Bookmarks: { defaultMessage: "Bookmarks" },
BookmarksCount: { defaultMessage: "{n} Bookmarks" },
KeyPlaceholder: { defaultMessage: "nsec, npub, nip-05, hex, mnemonic" },
});

View File

@ -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 (
<>
<h3>
<FormattedMessage defaultMessage="Logins" />
</h3>
{logins.map(a => (
<div className="card flex" key={a}>
<ProfilePreview
pubkey={a}
options={{
about: false,
}}
actions={
<div className="f-1">
<button className="mb10" onClick={() => LoginStore.switchAccount(a)}>
<FormattedMessage defaultMessage="Switch" />
</button>
<button onClick={() => LoginStore.removeSession(a)}>
<FormattedMessage defaultMessage="Logout" />
</button>
</div>
}
/>
</div>
))}
{sub && (
<>
<h3>
<FormattedMessage defaultMessage="Add Account" />
</h3>
<div className="flex">
<input
dir="auto"
type="text"
placeholder={formatMessage({
defaultMessage: "nsec, npub, nip-05, hex, mnemonic",
})}
className="f-grow mr10"
onChange={e => setKey(e.target.value)}
/>
<AsyncButton onClick={() => doLogin()}>
<FormattedMessage defaultMessage="Login" />
</AsyncButton>
</div>
</>
)}
{error && <b className="error">{error}</b>}
</>
);
}

View File

@ -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 = () => {
<FormattedMessage defaultMessage="Snort Subscription" />
<Icon name="arrowFront" />
</div>
{sub && (
<div className="settings-row" onClick={() => navigate("accounts")}>
<Icon name="code-circle" />
<FormattedMessage defaultMessage="Accounts" />
<Icon name="arrowFront" />
</div>
)}
<div className="settings-row" onClick={handleLogout}>
<Icon name="logout" />
<FormattedMessage {...messages.LogOut} />