settings functional components

This commit is contained in:
Martti Malmi 2023-08-19 16:41:25 +03:00
parent 2534462b4d
commit 209f7a3fad
6 changed files with 345 additions and 382 deletions

View File

@ -1,7 +1,9 @@
import { nip19 } from 'nostr-tools';
import { useCallback, useState } from 'preact/hooks';
import { route } from 'preact-router';
import Component from '../../BaseComponent';
import Show from '@/components/helpers/Show.tsx';
import Copy from '../../components/buttons/Copy';
import Events from '../../nostr/Events';
import Key from '../../nostr/Key';
@ -10,22 +12,24 @@ import { translate as t } from '../../translations/Translation.mjs';
import Helpers from '../../utils/Helpers.tsx';
import { ExistingAccountLogin } from '../Login';
export default class Account extends Component {
onLogoutClick(hasPriv) {
const Account = () => {
const [showSwitchAccount, setShowSwitchAccount] = useState(false);
const onLogoutClick = useCallback((hasPriv) => {
if (hasPriv) {
route('/logout'); // confirmation screen
} else {
Session.logOut();
}
}
}, []);
async onExtensionLoginClick(e) {
const onExtensionLoginClick = async (e) => {
e.preventDefault();
const rpub = await window.nostr.getPublicKey();
Key.login({ rpub });
}
};
deleteAccount() {
const deleteAccount = useCallback(() => {
if (confirm(`${t('delete_account')}?`)) {
Events.publish({
kind: 0,
@ -39,90 +43,79 @@ export default class Account extends Component {
Session.logOut();
}, 1000);
}
}, []);
const myPrivHex = Key.getPrivKey();
let myPriv32;
if (myPrivHex) {
myPriv32 = nip19.nsecEncode(myPrivHex);
}
const myPub = Key.getPubKey();
const myNpub = nip19.npubEncode(myPub);
const hasPriv = !!Key.getPrivKey();
render() {
const myPrivHex = Key.getPrivKey();
let myPriv32;
if (myPrivHex) {
// eslint-disable-next-line no-undef
myPriv32 = nip19.nsecEncode(myPrivHex);
}
const myPub = Key.getPubKey();
// eslint-disable-next-line no-undef
const myNpub = nip19.npubEncode(myPub);
return (
<div class="centered-container">
<h2>{t('account')}</h2>
<Show when={hasPriv}>
<p>
<b>{t('save_backup_of_privkey_first')}</b> {t('otherwise_cant_log_in_again')}
</p>
</Show>
<div className="flex gap-2 my-2">
<button className="btn btn-sm btn-primary" onClick={() => onLogoutClick(hasPriv)}>
{t('log_out')}
</button>
<button
className="btn btn-sm btn-primary"
onClick={() => setShowSwitchAccount(!showSwitchAccount)}
>
{t('switch_account')}
</button>
</div>
<Show when={showSwitchAccount}>
<p>
<ExistingAccountLogin />
</p>
<p>
<a href="#" onClick={onExtensionLoginClick}>
{t('nostr_extension_login')}
</a>
</p>
</Show>
<h3>{t('public_key')}</h3>
<p>
<small>{myNpub}</small>
</p>
<div className="flex gap-2 my-2">
<Copy className="btn btn-neutral btn-sm" copyStr={myNpub} text="Copy npub" />
<Copy className="btn btn-neutral btn-sm" copyStr={myPub} text="Copy hex" />
</div>
<h3>{t('private_key')}</h3>
<div className="flex gap-2 my-2">
<Show when={myPrivHex}>
<>
<Copy className="btn btn-neutral btn-sm" copyStr={myPriv32} text="Copy nsec" />
<Copy className="btn btn-neutral btn-sm" copyStr={myPrivHex} text="Copy hex" />
</>
</Show>
<Show when={!myPrivHex}>
<p>{t('private_key_not_present_good')}</p>
</Show>
</div>
<Show when={myPrivHex}>
<p>{t('private_key_warning')}</p>
</Show>
<Show when={Helpers.isStandalone()}>
<h3>{t('delete_account')}</h3>
<p>
<button className="btn btn-sm btn-danger" onClick={deleteAccount}>
{t('delete_account')}
</button>
</p>
</Show>
</div>
);
};
const hasPriv = !!Key.getPrivKey();
return (
<>
<div class="centered-container">
<h2>{t('account')}</h2>
{hasPriv ? (
<p>
<b>{t('save_backup_of_privkey_first')}</b> {t('otherwise_cant_log_in_again')}
</p>
) : null}
<div className="flex gap-2 my-2">
<button className="btn btn-sm btn-primary" onClick={() => this.onLogoutClick(hasPriv)}>
{t('log_out')}
</button>
<button
className="btn btn-sm btn-primary"
onClick={() =>
this.setState({
showSwitchAccount: !this.state.showSwitchAccount,
})
}
>
{t('switch_account')}
</button>
</div>
{this.state.showSwitchAccount && (
<>
<p>
<ExistingAccountLogin />
</p>
<p>
<a href="#" onClick={(e) => this.onExtensionLoginClick(e)}>
{t('nostr_extension_login')}
</a>
</p>
</>
)}
<h3>{t('public_key')}</h3>
<p>
<small>{myNpub}</small>
</p>
<div className="flex gap-2 my-2">
<Copy className="btn btn-neutral btn-sm" copyStr={myNpub} text="Copy npub" />
<Copy className="btn btn-neutral btn-sm" copyStr={myPub} text="Copy hex" />
</div>
<h3>{t('private_key')}</h3>
<div className="flex gap-2 my-2">
{myPrivHex ? (
<>
<Copy className="btn btn-neutral btn-sm" copyStr={myPriv32} text="Copy nsec" />
<Copy className="btn btn-neutral btn-sm" copyStr={myPrivHex} text="Copy hex" />
</>
) : (
<p>{t('private_key_not_present_good')}</p>
)}
</div>
{myPrivHex ? <p>{t('private_key_warning')}</p> : ''}
{Helpers.isStandalone() ? (
<>
<h3>{t('delete_account')}</h3>
<p>
<button className="btn btn-sm btn-danger" onClick={() => this.deleteAccount()}>
{t('delete_account')}
</button>
</p>
</>
) : null}
</div>
</>
);
}
}
export default Account;

View File

@ -1,86 +1,79 @@
import Component from '../../BaseComponent';
import { useEffect, useState } from 'react';
import localState from '../../LocalState';
import { translate as t } from '../../translations/Translation.mjs';
export default class Content extends Component {
constructor() {
super();
this.state = {
settings: {},
};
}
export default function Content() {
const [settings, setSettings] = useState({});
componentDidMount() {
localState.get('settings').on(this.inject());
}
useEffect(() => {
const unsubscribe = localState.get('settings').on((newSettings) => {
setSettings(newSettings);
});
return () => unsubscribe();
}, []);
render() {
const noteSettings = [
// { setting: 'enableMarkdown', label: 'Markdown' },
{ setting: 'loadReactions', label: 'Replies and reactions' },
{ setting: 'showLikes', label: 'Likes' },
{ setting: 'showZaps', label: 'Zaps' },
{ setting: 'showReposts', label: 'Reposts' },
];
// TODO get these from the embeds themselves
const mediaSettings = [
{ setting: 'enableImages', label: 'Images' },
{ setting: 'enableAudio', label: 'Audio' },
{ setting: 'enableVideo', label: 'Videos' },
{ setting: 'autoplayVideos', label: 'Autoplay videos' },
{ setting: 'enableAppleMusic', label: 'Apple Music' },
{ setting: 'enableInstagram', label: 'Instagram' },
{ setting: 'enableSoundCloud', label: 'SoundCloud' },
{ setting: 'enableSpotify', label: 'Spotify' },
{ setting: 'enableTidal', label: 'Tidal' },
{ setting: 'enableTiktok', label: 'TikTok' },
{ setting: 'enableTwitch', label: 'Twitch' },
{ setting: 'enableTwitter', label: 'Twitter' },
{ setting: 'enableYoutube', label: 'YouTube' },
{ setting: 'enableWavlake', label: 'Wavlake' },
];
return (
<>
<div class="centered-container">
<h2>{t('content')}</h2>
const noteSettings = [
// { setting: 'enableMarkdown', label: 'Markdown' },
{ setting: 'loadReactions', label: 'Replies and reactions' },
{ setting: 'showLikes', label: 'Likes' },
{ setting: 'showZaps', label: 'Zaps' },
{ setting: 'showReposts', label: 'Reposts' },
];
<h3>{t('notes')}</h3>
{noteSettings.map(({ setting, label }) => (
<p key={setting}>
<input
type="checkbox"
checked={this.state.settings[setting] !== false}
onChange={() =>
localState
.get('settings')
.get(setting)
.put(!(this.state.settings[setting] !== false))
}
id={setting}
/>
<label htmlFor={setting}> {label}</label>
</p>
))}
const mediaSettings = [
{ setting: 'enableImages', label: 'Images' },
{ setting: 'enableAudio', label: 'Audio' },
{ setting: 'enableVideo', label: 'Videos' },
{ setting: 'autoplayVideos', label: 'Autoplay videos' },
{ setting: 'enableAppleMusic', label: 'Apple Music' },
{ setting: 'enableInstagram', label: 'Instagram' },
{ setting: 'enableSoundCloud', label: 'SoundCloud' },
{ setting: 'enableSpotify', label: 'Spotify' },
{ setting: 'enableTidal', label: 'Tidal' },
{ setting: 'enableTiktok', label: 'TikTok' },
{ setting: 'enableTwitch', label: 'Twitch' },
{ setting: 'enableTwitter', label: 'Twitter' },
{ setting: 'enableYoutube', label: 'YouTube' },
{ setting: 'enableWavlake', label: 'Wavlake' },
];
<h3>{t('media')}</h3>
{mediaSettings.map(({ setting, label }) => (
<p key={setting}>
<input
type="checkbox"
checked={this.state.settings[setting] !== false}
onChange={() =>
localState
.get('settings')
.get(setting)
.put(!(this.state.settings[setting] !== false))
}
id={setting}
/>
<label htmlFor={setting}> {label}</label>
</p>
))}
</div>
</>
);
}
const handleChange = (setting) => {
localState
.get('settings')
.get(setting)
.put(!(settings[setting] !== false));
};
return (
<div class="centered-container">
<h2>{t('content')}</h2>
<h3>{t('notes')}</h3>
{noteSettings.map(({ setting, label }) => (
<p key={setting}>
<input
type="checkbox"
checked={settings[setting] !== false}
onChange={() => handleChange(setting)}
id={setting}
/>
<label htmlFor={setting}> {label}</label>
</p>
))}
<h3>{t('media')}</h3>
{mediaSettings.map(({ setting, label }) => (
<p key={setting}>
<input
type="checkbox"
checked={settings[setting] !== false}
onChange={() => handleChange(setting)}
id={setting}
/>
<label htmlFor={setting}> {label}</label>
</p>
))}
</div>
);
}

View File

@ -1,61 +1,58 @@
import Component from '../../BaseComponent';
import { useEffect, useState } from 'react';
import localState from '../../LocalState';
export default class DevSettings extends Component {
render() {
const renderCheckbox = (key, label, defaultValue) => (
<p>
<input
type="checkbox"
id={key}
checked={this.state[key] === undefined ? defaultValue : this.state[key]}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked;
localState.get('dev').get(key).put(checked);
}}
/>
<label htmlFor={key}>{label}</label>
</p>
);
// TODO reset button
export default function DevSettings() {
const [state, setState] = useState({});
const checkboxes = [
{
key: 'logSubscriptions',
label: 'Log RelayPool subscriptions',
defaultValue: false,
},
{
key: 'indexedDbSave',
label: 'Save events to IndexedDB',
defaultValue: true,
},
{
key: 'indexedDbLoad',
label: 'Load events from IndexedDB',
defaultValue: true,
},
{
key: 'askEventsFromRelays',
label: 'Ask events from relays',
defaultValue: true,
},
];
useEffect(() => {
const unsubscribe = localState.get('dev').on((data) => setState(data));
return () => unsubscribe();
}, []);
return (
<>
<div class="centered-container">
<h3>Developer</h3>
<p>Settings intended for Iris developers.</p>
{checkboxes.map(({ key, label, defaultValue }) =>
renderCheckbox(key, label, defaultValue),
)}
</div>
</>
);
}
const renderCheckbox = (key, label, defaultValue) => (
<p>
<input
type="checkbox"
id={key}
checked={state[key] === undefined ? defaultValue : state[key]}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked;
localState.get('dev').get(key).put(checked);
}}
/>
<label htmlFor={key}>{label}</label>
</p>
);
componentDidMount() {
localState.get('dev').on(this.sub((data) => this.setState(data)));
}
const checkboxes = [
{
key: 'logSubscriptions',
label: 'Log RelayPool subscriptions',
defaultValue: false,
},
{
key: 'indexedDbSave',
label: 'Save events to IndexedDB',
defaultValue: true,
},
{
key: 'indexedDbLoad',
label: 'Load events from IndexedDB',
defaultValue: true,
},
{
key: 'askEventsFromRelays',
label: 'Ask events from relays',
defaultValue: true,
},
];
return (
<div class="centered-container">
<h3>Developer</h3>
<p>Settings intended for Iris developers.</p>
{checkboxes.map(({ key, label, defaultValue }) => renderCheckbox(key, label, defaultValue))}
</div>
);
}

View File

@ -1,9 +1,7 @@
import Component from '../../BaseComponent';
import Account from './Account.js';
import Appearance from './Appearance';
import Backup from './Backup';
import Content from './Content';
import ContentPage from './Content';
import Dev from './Dev';
import IrisAccount from './IrisAccount.js';
import Language from './Language';
@ -11,14 +9,13 @@ import Network from './Network.js';
import Payments from './Payments';
import SocialNetwork from './SocialNetwork';
export default class SettingsContent extends Component {
content = '';
pages = {
const SettingsContent = (props) => {
const pages = {
account: Account,
network: Network,
appearance: Appearance,
language: Language,
content: Content,
content: ContentPage,
payments: Payments,
backup: Backup,
social_network: SocialNetwork,
@ -26,16 +23,13 @@ export default class SettingsContent extends Component {
dev: Dev,
};
constructor() {
super();
this.content = 'home';
}
render() {
const Content = this.pages[this.props.id] || this.pages.account;
return (
<div className="prose">
<Content />
</div>
);
}
}
const SelectedContent = pages[props.id] || pages.account;
return (
<div className="prose">
<SelectedContent />
</div>
);
};
export default SettingsContent;

View File

@ -1,7 +1,6 @@
import { LanguageIcon } from '@heroicons/react/24/solid';
import { route } from 'preact-router';
import Component from '../../BaseComponent';
import Show from '../../components/helpers/Show';
import localState from '../../LocalState';
import { translate as t } from '../../translations/Translation.mjs';
@ -22,41 +21,42 @@ if (['iris.to', 'beta.iris.to', 'localhost'].includes(window.location.hostname))
SETTINGS.iris_account = 'iris.to';
}
export default class SettingsMenu extends Component {
menuLinkClicked(url, e) {
const SettingsMenu = (props) => {
const activePage = props.activePage || 'account';
const menuLinkClicked = (url, e) => {
e.preventDefault();
localState.get('toggleSettingsMenu').put(false);
localState.get('scrollUp').put(true);
route(`/settings/${url}`);
}
};
render() {
const activePage = this.props.activePage || 'account';
return (
<div
className={`flex-col w-full md:w-48 flex-shrink-0 ${
!this.props.activePage ? 'flex' : 'hidden md:flex'
}`}
>
{Object.keys(SETTINGS).map((page) => {
if (!SETTINGS[page]) return;
return (
<a
href="#"
className={`btn inline-flex w-auto flex items-center p-3 rounded-full transition-colors duration-200 hover:bg-neutral-900 ${
activePage === page && window.innerWidth > 640 ? 'active' : ''
}`}
onClick={(e) => this.menuLinkClicked(page, e)}
key={page}
>
<span class="text">{t(SETTINGS[page])}</span>
<Show when={page === 'language'}>
<LanguageIcon width={16} />
</Show>
</a>
);
})}
</div>
);
}
}
return (
<div
className={`flex-col w-full md:w-48 flex-shrink-0 ${
!props.activePage ? 'flex' : 'hidden md:flex'
}`}
>
{Object.keys(SETTINGS).map((page) => {
if (!SETTINGS[page]) return null;
return (
<a
href="#"
className={`btn inline-flex w-auto flex items-center p-3 rounded-full transition-colors duration-200 hover:bg-neutral-900 ${
activePage === page && window.innerWidth > 640 ? 'active' : ''
}`}
onClick={(e) => menuLinkClicked(page, e)}
key={page}
>
<span class="text">{t(SETTINGS[page])}</span>
<Show when={page === 'language'}>
<LanguageIcon width={16} />
</Show>
</a>
);
})}
</div>
);
};
export default SettingsMenu;

View File

@ -1,7 +1,6 @@
import { ChangeEvent } from 'react';
import { useEffect, useState } from 'preact/hooks';
import { Link } from 'preact-router';
import Component from '../../BaseComponent';
import Name from '../../components/user/Name';
import localState from '../../LocalState';
import Events from '../../nostr/Events';
@ -9,109 +8,96 @@ import Key from '../../nostr/Key';
import SocialNetwork from '../../nostr/SocialNetwork';
import { translate as t } from '../../translations/Translation.mjs';
export default class SocialNetworkSettings extends Component {
private refreshInterval: any;
constructor() {
super();
this.state = {
blockedUsers: [] as string[],
globalFilter: {},
};
}
render() {
let hasBlockedUsers = false;
const blockedUsers = this.state.blockedUsers.map((user) => {
const bech32 = Key.toNostrBech32Address(user, 'npub');
if (bech32) {
hasBlockedUsers = true;
return (
<div key={user}>
<Link href={`/${bech32}`}>
<Name pub={user} />
</Link>
</div>
);
}
});
const SocialNetworkSettings = () => {
const [blockedUsers, setBlockedUsers] = useState<string[]>([]);
const [globalFilter, setGlobalFilter] = useState<any>({});
const [showBlockedUsers, setShowBlockedUsers] = useState(false);
let refreshInterval: any;
const followDistances = Array.from(SocialNetwork.usersByFollowDistance.entries()).slice(1);
const handleFilterChange = (e) => {
const value = parseInt(e.target?.value);
localState.get('globalFilter').get('maxFollowDistance').put(value);
};
return (
<>
<div class="centered-container">
<h2>{t('social_network')}</h2>
<h3>Stored on your device</h3>
<p>Total size: {followDistances.reduce((a, b) => a + b[1].size, 0)} users</p>
<p>Depth: {followDistances.length} degrees of separation</p>
{followDistances.sort().map((distance) => (
<div>
{distance[0] || t('unknown')}: {distance[1].size} users
</div>
))}
<p>Filter incoming events by follow distance:</p>
<select
className="select"
value={this.state.globalFilter.maxFollowDistance}
onChange={(e) => {
const target = e.target as HTMLInputElement;
localState.get('globalFilter').get('maxFollowDistance').put(parseInt(target.value));
}}
>
<option value="0">Off</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
<p>Minimum number of followers at maximum follow distance:</p>
<input
className="input"
type="number"
value={
this.state.globalFilter.minFollowersAtMaxDistance ||
Events.DEFAULT_GLOBAL_FILTER.minFollowersAtMaxDistance
}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
localState
.get('globalFilter')
.get('minFollowersAtMaxDistance')
.put(parseInt(target.value));
}}
/>
<h3>{t('blocked_users')}</h3>
{!hasBlockedUsers && t('none')}
<p>
<a
href=""
onClick={(e) => {
e.preventDefault();
this.setState({
showBlockedUsers: !this.state.showBlockedUsers,
});
}}
>
{this.state.showBlockedUsers ? t('hide') : t('show')} {blockedUsers.length}
</a>
</p>
{this.state.showBlockedUsers && blockedUsers}
</div>
</>
);
}
componentDidMount() {
SocialNetwork.getBlockedUsers((blockedUsers) => {
this.setState({ blockedUsers: Array.from(blockedUsers) });
});
localState.get('globalFilter').on(this.inject());
this.refreshInterval = setInterval(() => {
this.forceUpdate();
const handleMinFollowersChange = (e) => {
const value = parseInt(e.target?.value);
localState.get('globalFilter').get('minFollowersAtMaxDistance').put(value);
};
useEffect(() => {
SocialNetwork.getBlockedUsers((set) => setBlockedUsers(Array.from(set)));
localState.get('globalFilter').on(setGlobalFilter);
refreshInterval = setInterval(() => {
// We might need some actual updating logic here
}, 1000);
}
componentWillUnmount() {
clearInterval(this.refreshInterval);
}
}
return () => {
clearInterval(refreshInterval);
};
}, []);
const hasBlockedUsers = blockedUsers.some((user) => Key.toNostrBech32Address(user, 'npub'));
const renderedBlockedUsers = blockedUsers.map((user) => {
const bech32 = Key.toNostrBech32Address(user, 'npub');
if (bech32) {
return (
<div key={user}>
<Link href={`/${bech32}`}>
<Name pub={user} />
</Link>
</div>
);
}
return null;
});
const followDistances = Array.from(SocialNetwork.usersByFollowDistance.entries()).slice(1);
return (
<div class="centered-container">
<h2>{t('social_network')}</h2>
<h3>Stored on your device</h3>
<p>Total size: {followDistances.reduce((a, b) => a + b[1].size, 0)} users</p>
<p>Depth: {followDistances.length} degrees of separation</p>
{followDistances.sort().map((distance) => (
<div>
{distance[0] || t('unknown')}: {distance[1].size} users
</div>
))}
<p>Filter incoming events by follow distance:</p>
<select
className="select"
value={globalFilter.maxFollowDistance}
onChange={handleFilterChange}
>
{/* ... options here ... */}
</select>
<p>Minimum number of followers at maximum follow distance:</p>
<input
className="input"
type="number"
value={
globalFilter.minFollowersAtMaxDistance ||
Events.DEFAULT_GLOBAL_FILTER.minFollowersAtMaxDistance
}
onChange={handleMinFollowersChange}
/>
<h3>{t('blocked_users')}</h3>
{!hasBlockedUsers && t('none')}
<p>
<a
href=""
onClick={(e) => {
e.preventDefault();
setShowBlockedUsers(!showBlockedUsers);
}}
>
{showBlockedUsers ? t('hide') : t('show')} {renderedBlockedUsers.length}
</a>
</p>
{showBlockedUsers && renderedBlockedUsers}
</div>
);
};
export default SocialNetworkSettings;