Add Account settings

This commit is contained in:
Bojan Mojsilovic 2023-10-12 14:26:31 +02:00
parent feff1269c4
commit f05149cf53
14 changed files with 371 additions and 14 deletions

View File

@ -32,6 +32,7 @@ const Mutelist = lazy(() => import('./pages/Mutelist'));
const CreateAccount = lazy(() => import('./pages/CreateAccount')); const CreateAccount = lazy(() => import('./pages/CreateAccount'));
const NotifSettings = lazy(() => import('./pages/Settings/Notifications')); const NotifSettings = lazy(() => import('./pages/Settings/Notifications'));
const Account = lazy(() => import('./pages/Settings/Account'));
const Appearance = lazy(() => import('./pages/Settings/Appearance')); const Appearance = lazy(() => import('./pages/Settings/Appearance'));
const HomeFeeds = lazy(() => import('./pages/Settings/HomeFeeds')); const HomeFeeds = lazy(() => import('./pages/Settings/HomeFeeds'));
const ZapSettings = lazy(() => import('./pages/Settings/Zaps')); const ZapSettings = lazy(() => import('./pages/Settings/Zaps'));
@ -104,6 +105,7 @@ const Router: Component = () => {
<Route path="/download" element={<Navigate href='/downloads' />} />; <Route path="/download" element={<Navigate href='/downloads' />} />;
<Route path="/settings" component={Settings}> <Route path="/settings" component={Settings}>
<Route path="/" component={Menu} /> <Route path="/" component={Menu} />
<Route path="/account" component={Account} />
<Route path="/appearance" component={Appearance} /> <Route path="/appearance" component={Appearance} />
<Route path="/feeds" component={HomeFeeds} /> <Route path="/feeds" component={HomeFeeds} />
<Route path="/notifications" component={NotifSettings} /> <Route path="/notifications" component={NotifSettings} />

View File

@ -2,7 +2,8 @@
border: none; border: none;
border-radius: 6px; border-radius: 6px;
margin: 0px 8px; margin: 0px 8px;
padding: 0px; padding-inline: 10px;
padding-block: 6px;
font-size: 14px; font-size: 14px;
line-height: 20px; line-height: 20px;
font-weight: 700; font-weight: 700;

View File

@ -53,6 +53,7 @@ const NavMenu: Component< { id?: string } > = (props) => {
label: intl.formatMessage(t.settings), label: intl.formatMessage(t.settings),
icon: 'settingsIcon', icon: 'settingsIcon',
hiddenOnSmallScreens: true, hiddenOnSmallScreens: true,
bubble: () => account?.sec ? 1 : 0,
}, },
{ {
to: '/help', to: '/help',

View File

@ -25,7 +25,7 @@ import { sendContacts, sendLike, sendMuteList, triggerImportEvents } from "../li
import { generatePrivateKey, Relay, getPublicKey as nostrGetPubkey, nip19 } from "nostr-tools"; import { generatePrivateKey, Relay, getPublicKey as nostrGetPubkey, nip19 } from "nostr-tools";
import { APP_ID } from "../App"; import { APP_ID } from "../App";
import { getLikes, getFilterlists, getProfileContactList, getProfileMuteList, getUserProfiles, sendFilterlists, getAllowlist, sendAllowList } from "../lib/profile"; import { getLikes, getFilterlists, getProfileContactList, getProfileMuteList, getUserProfiles, sendFilterlists, getAllowlist, sendAllowList } from "../lib/profile";
import { getStorage, readSecFromStorage, saveFollowing, saveLikes, saveMuted, saveMuteList, saveRelaySettings, storeSec } from "../lib/localStore"; import { clearSec, getStorage, readSecFromStorage, saveFollowing, saveLikes, saveMuted, saveMuteList, saveRelaySettings, storeSec } from "../lib/localStore";
import { connectRelays, connectToRelay, getDefaultRelays, getPreConfiguredRelays } from "../lib/relays"; import { connectRelays, connectToRelay, getDefaultRelays, getPreConfiguredRelays } from "../lib/relays";
import { getPublicKey } from "../lib/nostrAPI"; import { getPublicKey } from "../lib/nostrAPI";
import { generateKeys } from "../lib/PrimalNostr"; import { generateKeys } from "../lib/PrimalNostr";
@ -79,6 +79,7 @@ export type AccountContextStore = {
addToAllowlist: (pubkey: string | undefined, then?: () => void) => void, addToAllowlist: (pubkey: string | undefined, then?: () => void) => void,
removeFromAllowlist: (pubkey: string | undefined) => void, removeFromAllowlist: (pubkey: string | undefined) => void,
setSec: (sec: string | undefined) => void, setSec: (sec: string | undefined) => void,
logout: () => void,
}, },
} }
@ -120,6 +121,12 @@ export function AccountProvider(props: { children: JSXElement }) {
let connectedRelaysCopy: Relay[] = []; let connectedRelaysCopy: Relay[] = [];
const logout = () => {
updateStore('sec', () => undefined);
updateStore('publicKey', () => undefined);
clearSec();
};
const setSec = (sec: string | undefined) => { const setSec = (sec: string | undefined) => {
const decoded = nip19.decode(sec); const decoded = nip19.decode(sec);
@ -1215,6 +1222,7 @@ const [store, updateStore] = createStore<AccountContextStore>({
addToAllowlist, addToAllowlist,
removeFromAllowlist, removeFromAllowlist,
setSec, setSec,
logout,
}, },
}); });

View File

@ -1,4 +1,4 @@
import { createStore } from "solid-js/store"; import { createStore, reconcile } from "solid-js/store";
import { Kind } from "../constants"; import { Kind } from "../constants";
import { import {
createContext, createContext,
@ -31,7 +31,7 @@ import {
UserRelation, UserRelation,
} from "../types/primal"; } from "../types/primal";
import { APP_ID } from "../App"; import { APP_ID } from "../App";
import { getMessageCounts, getNewMessages, getOldMessages, markAllAsRead, resetMessageCount, subscribeToMessagesStats } from "../lib/messages"; import { getMessageCounts, getNewMessages, getOldMessages, markAllAsRead, resetMessageCount, subscribeToMessagesStats, unsubscribeToMessagesStats } from "../lib/messages";
import { useAccountContext } from "./AccountContext"; import { useAccountContext } from "./AccountContext";
import { convertToUser } from "../stores/profile"; import { convertToUser } from "../stores/profile";
import { getUserProfiles } from "../lib/profile"; import { getUserProfiles } from "../lib/profile";
@ -742,6 +742,14 @@ export const MessagesProvider = (props: { children: ContextChildren }) => {
} }
}); });
createEffect(() => {
if (!account?.sec) {
unsubscribeToMessagesStats(subidMsgCount);
updateStore('messageCount', () => 0);
updateStore('messageCountPerSender', reconcile({}));
}
});
onCleanup(() => { onCleanup(() => {
removeSocketListeners( removeSocketListeners(
socket(), socket(),

View File

@ -18,7 +18,7 @@ import {
NostrEvent, NostrEvent,
} from "../types/primal"; } from "../types/primal";
import { APP_ID } from "../App"; import { APP_ID } from "../App";
import { subscribeToNotificationStats } from "../lib/notifications"; import { subscribeToNotificationStats, unsubscribeToNotificationStats } from "../lib/notifications";
import { useAccountContext } from "./AccountContext"; import { useAccountContext } from "./AccountContext";
export type NotificationsContextStore = { export type NotificationsContextStore = {
@ -124,7 +124,12 @@ export const NotificationsProvider = (props: { children: ContextChildren }) => {
} }
}); });
createEffect(() => {}); createEffect(() => {
if (!account?.sec) {
unsubscribeToNotificationStats(subid);
updateStore('notificationCount', () => 0);
}
});
onCleanup(() => { onCleanup(() => {
removeSocketListeners( removeSocketListeners(

View File

@ -155,3 +155,7 @@ export const storeSec = (sec: string | undefined) => {
localStorage.setItem('primalSec', sec); localStorage.setItem('primalSec', sec);
}; };
export const clearSec = () => {
localStorage.removeItem('primalSec');
};

View File

@ -12,6 +12,14 @@ export const subscribeToMessagesStats = (pubkey: string, subid: string) => {
])); ]));
} }
export const unsubscribeToMessagesStats = (subid: string) => {
sendMessage(JSON.stringify([
"CLOSE",
subid,
{cache: ["directmsg_count"]},
]));
}
export const resetMessageCount = async (sender: string, subid: string) => { export const resetMessageCount = async (sender: string, subid: string) => {
const event = { const event = {
content: `{ "description": "reset messages from '${sender}'"}`, content: `{ "description": "reset messages from '${sender}'"}`,

View File

@ -101,6 +101,14 @@ export const subscribeToNotificationStats = (pubkey: string, subid: string) => {
])); ]));
} }
export const unsubscribeToNotificationStats = (subid: string) => {
sendMessage(JSON.stringify([
"CLOSE",
subid,
{cache: ["notification_counts", { subid }]},
]));
}
export const truncateNumber = (amount: number, from?: 1 | 2 | 3 | 4) => { export const truncateNumber = (amount: number, from?: 1 | 2 | 3 | 4) => {
const t = 1_000; const t = 1_000;
const s = from || 1; const s = from || 1;

View File

@ -0,0 +1,110 @@
import { Component, createSignal, Show } from 'solid-js';
import styles from './Settings.module.scss';
import ThemeChooser from '../../components/ThemeChooser/ThemeChooser';
import { useIntl } from '@cookbook/solid-intl';
import { settings as t, actions as tActions } from '../../translations';
import PageCaption from '../../components/PageCaption/PageCaption';
import { Link } from '@solidjs/router';
import PageTitle from '../../components/PageTitle/PageTitle';
import { useAccountContext } from '../../contexts/AccountContext';
import Avatar from '../../components/Avatar/Avatar';
const Account: Component = () => {
const intl = useIntl();
const account = useAccountContext();
const [isCoppied, setIsCoppied] = createSignal('');
const onCopy = (text: string) => {
navigator.clipboard.writeText(text);
setIsCoppied(text);
setTimeout(() => setIsCoppied(''), 2_000);
};
return (
<div>
<PageTitle title={`${intl.formatMessage(t.account.title)} ${intl.formatMessage(t.title)}`} />
<PageCaption>
<Link href='/settings' >{intl.formatMessage(t.index.title)}</Link>:&nbsp;
<div>{intl.formatMessage(t.account.title)}</div>
</PageCaption>
<div class={styles.securityWarning}>
<div class={styles.securityIcon}></div>
<div class={styles.securityMessage}>
{intl.formatMessage(t.account.description, {
link: <a href="https://getalby.com" target='_blank'>Alby</a>,
})}
</div>
</div>
<div class={styles.settingsAccountCaption}>
<div class={styles.caption}>
{intl.formatMessage(t.account.pubkey)}
</div>
<button
class={styles.copy}
onClick={() => onCopy(account?.activeUser?.npub || '')}
>
<Show when={isCoppied() === account?.activeUser?.npub}>
<div class={styles.checkIcon}></div>
</Show>
{intl.formatMessage(tActions.copyPubkey)}
</button>
</div>
<div class={styles.settingsKeyArea}>
<Avatar
src={account?.activeUser?.picture}
size="vs"
/>
<div class={styles.key}>
{account?.activeUser?.npub}
</div>
</div>
<div class={styles.settingsDescription}>
{intl.formatMessage(t.account.pubkeyDesc)}
</div>
<div class={styles.settingsAccountCaption}>
<div class={styles.caption}>
{intl.formatMessage(t.account.privkey)}
</div>
<button
class={styles.copy}
onClick={() => onCopy(account?.sec || '')}
>
<Show when={isCoppied() === account?.sec}>
<div class={styles.checkIcon}></div>
</Show>
{intl.formatMessage(tActions.copyPrivkey)}
</button>
</div>
<div class={styles.settingsKeyArea}>
<div class={styles.icon}>
<div class={styles.keyIcon}></div>
</div>
<input
class={styles.key}
value={account?.sec || ''}
readOnly={true}
type="password"
/>
</div>
<div class={styles.settingsDescription}>
{intl.formatMessage(t.account.privkeyDesc)}
</div>
</div>
)
}
export default Account;

View File

@ -2,10 +2,11 @@ import { Component, Show } from 'solid-js';
import styles from './Settings.module.scss'; import styles from './Settings.module.scss';
import { useIntl } from '@cookbook/solid-intl'; import { useIntl } from '@cookbook/solid-intl';
import { settings as t } from '../../translations'; import { settings as t, actions as tActions } from '../../translations';
import PageCaption from '../../components/PageCaption/PageCaption'; import PageCaption from '../../components/PageCaption/PageCaption';
import { Link } from '@solidjs/router'; import { Link } from '@solidjs/router';
import { useAccountContext } from '../../contexts/AccountContext'; import { useAccountContext } from '../../contexts/AccountContext';
import ButtonPrimary from '../../components/Buttons/ButtonPrimary';
const Menu: Component = () => { const Menu: Component = () => {
@ -19,6 +20,18 @@ const Menu: Component = () => {
<PageCaption title={intl.formatMessage(t.title)} /> <PageCaption title={intl.formatMessage(t.title)} />
<div class={styles.subpageLinks}> <div class={styles.subpageLinks}>
<Show when={account?.sec != undefined}>
<Link href="/settings/account">
<div class={styles.caption}>
{intl.formatMessage(t.account.title)}
<div class={styles.bubble}>
<div>{1}</div>
</div>
</div>
<div class={styles.chevron}></div>
</Link>
</Show>
<Link href="/settings/appearance"> <Link href="/settings/appearance">
{intl.formatMessage(t.appearance.title)} {intl.formatMessage(t.appearance.title)}
<div class={styles.chevron}></div> <div class={styles.chevron}></div>
@ -57,6 +70,14 @@ const Menu: Component = () => {
</Show> </Show>
</div> </div>
<Show when={account?.sec}>
<div class={styles.webVersion}>
<ButtonPrimary onClick={account?.actions.logout}>
{intl.formatMessage(tActions.logout)}
</ButtonPrimary>
</div>
</Show>
<div class={styles.webVersion}> <div class={styles.webVersion}>
<div class={styles.title}>version</div> <div class={styles.title}>version</div>
<div class={styles.value}>{version}</div> <div class={styles.value}>{version}</div>

View File

@ -50,6 +50,95 @@
align-items: center; align-items: center;
} }
.settingsDescription {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 400;
line-height: 17px;
margin-top: 8px;
}
.settingsAccountCaption {
display: flex;
justify-content: space-between;
width: 100%;
margin-top: 46px;
.caption {
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
line-height: 20px;
text-transform: uppercase;
}
.copy {
color: var(--accent-1);
background: none;
font-size: 14px;
font-weight: 500;
line-height: 20px;
padding: 0;
border: none;
width: fit-content;
display: flex;
justify-content: flex-end;
align-items: center;
&:focus {
box-shadow: none;
}
.checkIcon {
width: 16px;
height: 16px;
display: inline-block;
margin-inline: 2px;
background-color: var(--success-color);
-webkit-mask: url(../../assets/icons/check.svg) no-repeat 0 / 80%;
mask: url(../../assets/icons/check.svg) no-repeat 0 / 80%;
}
}
}
.settingsKeyArea {
background-color: var(--background-input);
padding-inline: 12px;
padding-block: 10px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: space-between;
.key {
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
line-height: 20px;
width: 100%;
margin: 0;
padding: 0;
margin-left: 12px;
border: none;
height: 20px;
}
.icon {
margin: 7px;
}
.keyIcon {
min-width: 28px;
height: 28px;
display: block;
margin: 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/key.svg) no-repeat 0 0 / 28px 28px;
mask: url(../../assets/icons/key.svg) no-repeat 0 0 / 28px 28px;
}
}
.secondCaption { .secondCaption {
margin-top: 24px; margin-top: 24px;
} }
@ -108,7 +197,31 @@
line-height: 28px; line-height: 28px;
text-decoration: none; text-decoration: none;
>div { .caption {
display: flex;
justify-content: flex-start;
.bubble {
text-align: center;
padding-top: 2px;
padding-inline: 4px;
margin-inline: 8px;
width: 18px;
height: 18px;
border-radius: 8px;
font-weight: 500;
font-size: 12px;
line-height: 12px;
background: var(--brand-gradient);
border: 1px solid var(--background-site);
color: white;
text-shadow: 0.5px 0.5px 0px black;
}
}
.chevron {
width: 12px; width: 12px;
height: 20px; height: 20px;
background-color: var(--text-tertiary); background-color: var(--text-tertiary);
@ -117,7 +230,7 @@
} }
&:hover { &:hover {
>div { .chevron {
background-color: var(--text-primary); background-color: var(--text-primary);
} }
} }
@ -864,3 +977,28 @@
} }
} }
} }
.securityWarning {
display: flex;
align-items: center;
width: 100%;
.securityMessage {
display: inline-block;
color: var(--text-secondary);
font-size: 14px;
font-weight: 400;
line-height: 17px;
margin-left: 22px;
}
.securityIcon {
min-width: 54px;
height: 54px;
display: inline-block;
margin: 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/report.svg) no-repeat 0 0 / 54px 54px;
mask: url(../../assets/icons/report.svg) no-repeat 0 0 / 54px 54px;
}
}

View File

@ -1,15 +1,11 @@
import { Component } from 'solid-js'; import { Component } from 'solid-js';
import Branding from '../../components/Branding/Branding';
import styles from './Settings.module.scss'; import styles from './Settings.module.scss';
import Wormhole from '../../components/Wormhole/Wormhole'; import Wormhole from '../../components/Wormhole/Wormhole';
import { useIntl } from '@cookbook/solid-intl'; import { useIntl } from '@cookbook/solid-intl';
import Search from '../../components/Search/Search'; import Search from '../../components/Search/Search';
import { settings as t } from '../../translations'; import { settings as t } from '../../translations';
import { useSettingsContext } from '../../contexts/SettingsContext'; import { Outlet } from '@solidjs/router';
import PageCaption from '../../components/PageCaption/PageCaption';
import { Link, Outlet } from '@solidjs/router';
import HomeSidebar from '../../components/HomeSidebar/HomeSidebar';
import StickySidebar from '../../components/StickySidebar/StickySidebar'; import StickySidebar from '../../components/StickySidebar/StickySidebar';
import SettingsSidebar from '../../components/SettingsSidebar/SettingsSidebar'; import SettingsSidebar from '../../components/SettingsSidebar/SettingsSidebar';
import PageTitle from '../../components/PageTitle/PageTitle'; import PageTitle from '../../components/PageTitle/PageTitle';

View File

@ -108,6 +108,11 @@ export const actions = {
defaultMessage: 'Login now', defaultMessage: 'Login now',
description: 'Login Now action, button label', description: 'Login Now action, button label',
}, },
logout: {
id: 'actions.logout',
defaultMessage: 'Logout',
description: 'Logout action, button label',
},
getStarted: { getStarted: {
id: 'actions.getStarted', id: 'actions.getStarted',
defaultMessage: 'Get Started', defaultMessage: 'Get Started',
@ -123,6 +128,16 @@ export const actions = {
defaultMessage: 'copy', defaultMessage: 'copy',
description: 'Copy action, button label', description: 'Copy action, button label',
}, },
copyPubkey: {
id: 'actions.copyPubkey',
defaultMessage: 'copy public key',
description: 'Copy pubkey action, button label',
},
copyPrivkey: {
id: 'actions.copyPrivkey',
defaultMessage: 'copy private key',
description: 'Copy private key action, button label',
},
addFeedToHome: { addFeedToHome: {
id: 'actions.addFeedToHome', id: 'actions.addFeedToHome',
defaultMessage: 'add this feed to my home page', defaultMessage: 'add this feed to my home page',
@ -1046,6 +1061,38 @@ export const settings = {
description: 'Title of the settings page', description: 'Title of the settings page',
}, },
}, },
account: {
title: {
id: 'settings.account.title',
defaultMessage: 'Account',
description: 'Title of the account settings sub-page',
},
description: {
id: 'settings.account.description',
defaultMessage: "You can improve your account security by installing a Nostr browser extension, like {link}. By storing your Nostr private key within a browser extension, you will be able to securely sign into any Nostr web app, including Primal.",
description: 'Warning about account security',
},
pubkey: {
id: 'settings.account.pubkey',
defaultMessage: 'Your Public Key',
description: 'Your public key section caption',
},
pubkeyDesc: {
id: 'settings.account.pubkeyDesc',
defaultMessage: 'Anyone on Nostr can find you via your public key. Feel free to share anywhere.',
description: 'Label describing the public key',
},
privkey: {
id: 'settings.account.privkey',
defaultMessage: 'Your Private Key',
description: 'Your private key section caption',
},
privkeyDesc: {
id: 'settings.account.privkeyDesc',
defaultMessage: 'This key fully controls your Nostr account. Dont share it with anyone. Only copy this key to store it securely or to login to another Nostr app.',
description: 'Label describing the private key',
},
},
appearance: { appearance: {
title: { title: {
id: 'settings.appearance.title', id: 'settings.appearance.title',