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

@ -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} />