import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
+import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import colorMXID from '../../../util/colorMXID';
+import Text from '../../atoms/text/Text';
+import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import ImageUpload from '../../molecules/image-upload/ImageUpload';
import Input from '../../atoms/input/Input';
+import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
+
import './ProfileEditor.scss';
// TODO Fix bug that prevents 'Save' button from enabling up until second changed.
-function ProfileEditor({
- userId,
-}) {
+function ProfileEditor({ userId }) {
+ const [isEditing, setIsEditing] = useState(false);
const mx = initMatrix.matrixClient;
+ const user = mx.getUser(mx.getUserId());
+
const displayNameRef = useRef(null);
- const bgColor = colorMXID(userId);
- const [avatarSrc, setAvatarSrc] = useState(null);
+ const [avatarSrc, setAvatarSrc] = useState(user.avatarUrl ? mx.mxcUrlToHttp(user.avatarUrl, 80, 80, 'crop') : null);
+ const [username, setUsername] = useState(user.displayName);
const [disabled, setDisabled] = useState(true);
- let username = mx.getUser(mx.getUserId()).displayName;
-
useEffect(() => {
+ let isMounted = true;
mx.getProfileInfo(mx.getUserId()).then((info) => {
+ if (!isMounted) return;
setAvatarSrc(info.avatar_url ? mx.mxcUrlToHttp(info.avatar_url, 80, 80, 'crop') : null);
+ setUsername(info.displayname);
});
+ return () => {
+ isMounted = false;
+ };
}, [userId]);
- // Sets avatar URL and updates the avatar component in profile editor to reflect new upload
- function handleAvatarUpload(url) {
+ const handleAvatarUpload = (url) => {
if (url === null) {
if (confirm('Are you sure you want to remove avatar?')) {
mx.setAvatarUrl('');
}
mx.setAvatarUrl(url);
setAvatarSrc(mx.mxcUrlToHttp(url, 80, 80, 'crop'));
- }
+ };
- function saveDisplayName() {
+ const saveDisplayName = () => {
const newDisplayName = displayNameRef.current.value;
if (newDisplayName !== null && newDisplayName !== username) {
mx.setDisplayName(newDisplayName);
- username = newDisplayName;
+ setUsername(newDisplayName);
setDisabled(true);
+ setIsEditing(false);
}
- }
+ };
- function onDisplayNameInputChange() {
+ const onDisplayNameInputChange = () => {
setDisabled(username === displayNameRef.current.value || displayNameRef.current.value == null);
- }
- function cancelDisplayNameChanges() {
+ };
+ const cancelDisplayNameChanges = () => {
displayNameRef.current.value = username;
onDisplayNameInputChange();
- }
+ setIsEditing(false);
+ };
- return (
+ const renderForm = () => (
<form
- className="profile-editor"
+ className="profile-editor__form"
+ style={{ marginBottom: avatarSrc ? '24px' : '0' }}
onSubmit={(e) => { e.preventDefault(); saveDisplayName(); }}
>
+ <Input
+ label={`Display name of ${mx.getUserId()}`}
+ onChange={onDisplayNameInputChange}
+ value={mx.getUser(mx.getUserId()).displayName}
+ forwardRef={displayNameRef}
+ />
+ <Button variant="primary" type="submit" disabled={disabled}>Save</Button>
+ <Button onClick={cancelDisplayNameChanges}>Cancel</Button>
+ </form>
+ );
+
+ const renderInfo = () => (
+ <div className="profile-editor__info" style={{ marginBottom: avatarSrc ? '24px' : '0' }}>
+ <div>
+ <Text variant="h2" primary weight="medium">{twemojify(username)}</Text>
+ <IconButton
+ src={PencilIC}
+ size="extra-small"
+ tooltip="Edit"
+ onClick={() => setIsEditing(true)}
+ />
+ </div>
+ <Text variant="b2">{mx.getUserId()}</Text>
+ </div>
+ );
+
+ return (
+ <div className="profile-editor">
<ImageUpload
text={username}
- bgColor={bgColor}
+ bgColor={colorMXID(userId)}
imageSrc={avatarSrc}
onUpload={handleAvatarUpload}
onRequestRemove={() => handleAvatarUpload(null)}
/>
- <div className="profile-editor__input-wrapper">
- <Input
- label={`Display name of ${mx.getUserId()}`}
- onChange={onDisplayNameInputChange}
- value={mx.getUser(mx.getUserId()).displayName}
- forwardRef={displayNameRef}
- />
- <Button variant="primary" type="submit" disabled={disabled}>Save</Button>
- <Button onClick={cancelDisplayNameChanges}>Cancel</Button>
- </div>
- </form>
+ {
+ isEditing ? renderForm() : renderInfo()
+ }
+ </div>
);
}
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
+import React, { useState, useEffect } from 'react';
import './Settings.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import settings from '../../../client/state/settings';
+import navigation from '../../../client/state/navigation';
import {
toggleSystemTheme, toggleMarkdown, toggleMembershipEvents, toggleNickAvatarEvents,
toggleNotifications, toggleNotificationSounds,
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import Toggle from '../../atoms/button/Toggle';
+import Tabs from '../../atoms/tabs/Tabs';
+import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import SegmentedControls from '../../atoms/segmented-controls/SegmentedControls';
-import PopupWindow, { PWContentSelector } from '../../molecules/popup-window/PopupWindow';
+import PopupWindow from '../../molecules/popup-window/PopupWindow';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
import ProfileEditor from '../profile-editor/ProfileEditor';
-import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
import BellIC from '../../../../public/res/ic/outlined/bell.svg';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
-function GeneralSection() {
- return (
- <div className="settings-content">
- <SettingTile
- title=""
- content={(
- <ProfileEditor userId={initMatrix.matrixClient.getUserId()} />
- )}
- />
- </div>
- );
-}
-
function AppearanceSection() {
const [, updateState] = useState({});
return (
- <div className="settings-content">
- <SettingTile
- title="Follow system theme"
- options={(
- <Toggle
- isActive={settings.useSystemTheme}
- onToggle={() => { toggleSystemTheme(); updateState({}); }}
- />
- )}
- content={<Text variant="b3">Use light or dark mode based on the system's settings.</Text>}
- />
- {(() => {
- if (!settings.useSystemTheme) {
- return (
- <SettingTile
- title="Theme"
- content={(
- <SegmentedControls
- selected={settings.getThemeIndex()}
- segments={[
- { text: 'Light' },
- { text: 'Silver' },
- { text: 'Dark' },
- { text: 'Butter' },
- ]}
- onSelect={(index) => settings.setTheme(index)}
- />
- )}
+ <div className="settings-appearance">
+ <div className="settings-appearance__card">
+ <MenuHeader>Theme</MenuHeader>
+ <SettingTile
+ title="Follow system theme"
+ options={(
+ <Toggle
+ isActive={settings.useSystemTheme}
+ onToggle={() => { toggleSystemTheme(); updateState({}); }}
/>
- );
- }
- })()}
- <SettingTile
- title="Markdown formatting"
- options={(
- <Toggle
- isActive={settings.isMarkdown}
- onToggle={() => { toggleMarkdown(); updateState({}); }}
- />
- )}
- content={<Text variant="b3">Format messages with markdown syntax before sending.</Text>}
- />
- <SettingTile
- title="Hide membership events"
- options={(
- <Toggle
- isActive={settings.hideMembershipEvents}
- onToggle={() => { toggleMembershipEvents(); updateState({}); }}
- />
- )}
- content={<Text variant="b3">Hide membership change messages from room timeline. (Join, Leave, Invite, Kick and Ban)</Text>}
- />
- <SettingTile
- title="Hide nick/avatar events"
- options={(
- <Toggle
- isActive={settings.hideNickAvatarEvents}
- onToggle={() => { toggleNickAvatarEvents(); updateState({}); }}
+ )}
+ content={<Text variant="b3">Use light or dark mode based on the system settings.</Text>}
+ />
+ {!settings.useSystemTheme && (
+ <SettingTile
+ title="Theme"
+ content={(
+ <SegmentedControls
+ selected={settings.getThemeIndex()}
+ segments={[
+ { text: 'Light' },
+ { text: 'Silver' },
+ { text: 'Dark' },
+ { text: 'Butter' },
+ ]}
+ onSelect={(index) => settings.setTheme(index)}
+ />
+ )}
/>
)}
- content={<Text variant="b3">Hide nick and avatar change messages from room timeline.</Text>}
- />
+ </div>
+ <div className="settings-appearance__card">
+ <MenuHeader>Room messages</MenuHeader>
+ <SettingTile
+ title="Markdown formatting"
+ options={(
+ <Toggle
+ isActive={settings.isMarkdown}
+ onToggle={() => { toggleMarkdown(); updateState({}); }}
+ />
+ )}
+ content={<Text variant="b3">Format messages with markdown syntax before sending.</Text>}
+ />
+ <SettingTile
+ title="Hide membership events"
+ options={(
+ <Toggle
+ isActive={settings.hideMembershipEvents}
+ onToggle={() => { toggleMembershipEvents(); updateState({}); }}
+ />
+ )}
+ content={<Text variant="b3">Hide membership change messages from room timeline. (Join, Leave, Invite, Kick and Ban)</Text>}
+ />
+ <SettingTile
+ title="Hide nick/avatar events"
+ options={(
+ <Toggle
+ isActive={settings.hideNickAvatarEvents}
+ onToggle={() => { toggleNickAvatarEvents(); updateState({}); }}
+ />
+ )}
+ content={<Text variant="b3">Hide nick and avatar change messages from room timeline.</Text>}
+ />
+ </div>
</div>
);
}
const renderOptions = () => {
if (window.Notification === undefined) {
- return <Text className="set-notifications__not-supported">Not supported in this browser.</Text>;
+ return <Text className="settings-notifications__not-supported">Not supported in this browser.</Text>;
}
if (permission === 'granted') {
};
return (
- <div className="set-notifications settings-content">
+ <div className="settings-notifications">
+ <MenuHeader>Notification & Sound</MenuHeader>
<SettingTile
- title="Show desktop notifications"
+ title="Desktop notification"
options={renderOptions()}
- content={<Text variant="b3">Show notifications when new messages arrive.</Text>}
+ content={<Text variant="b3">Show desktop notification when new messages arrive.</Text>}
/>
<SettingTile
- title="Play notification sounds"
+ title="Notification Sound"
options={(
<Toggle
isActive={settings.isNotificationSounds}
onToggle={() => { toggleNotificationSounds(); updateState({}); }}
/>
)}
- content={<Text variant="b3">Play a sound when new messages arrive.</Text>}
+ content={<Text variant="b3">Play sound when new messages arrive.</Text>}
/>
</div>
);
function SecuritySection() {
return (
- <div className="set-security settings-content">
- <SettingTile
- title={`Device ID: ${initMatrix.matrixClient.getDeviceId()}`}
- />
- <SettingTile
- title={`Device key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
- content={<Text variant="b3">Use this device ID-key combo to verify or manage this session from Element client.</Text>}
- />
- <SettingTile
- title="Export E2E room keys"
- content={(
- <>
- <Text variant="b3">Export end-to-end encryption room keys to decrypt old messages in other session. In order to encrypt keys you need to set a password, which will be used while importing.</Text>
- <ExportE2ERoomKeys />
- </>
- )}
- />
- <SettingTile
- title="Import E2E room keys"
- content={(
- <>
- <Text variant="b3">{'To decrypt older messages, Export E2EE room keys from Element (Settings > Security & Privacy > Encryption > Cryptography) and import them here. Imported keys are encrypted so you\'ll have to enter the password you set in order to decrypt it.'}</Text>
- <ImportE2ERoomKeys />
- </>
- )}
- />
+ <div className="settings-security">
+ <div className="settings-security__card">
+ <MenuHeader>Device Info</MenuHeader>
+ <SettingTile
+ title={`Device ID: ${initMatrix.matrixClient.getDeviceId()}`}
+ />
+ <SettingTile
+ title={`Device key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
+ content={<Text variant="b3">Use this device ID-key combo to verify or manage this session from Element client.</Text>}
+ />
+ </div>
+ <div className="settings-security__card">
+ <MenuHeader>Encryption</MenuHeader>
+ <SettingTile
+ title="Export E2E room keys"
+ content={(
+ <>
+ <Text variant="b3">Export end-to-end encryption room keys to decrypt old messages in other session. In order to encrypt keys you need to set a password, which will be used while importing.</Text>
+ <ExportE2ERoomKeys />
+ </>
+ )}
+ />
+ <SettingTile
+ title="Import E2E room keys"
+ content={(
+ <>
+ <Text variant="b3">{'To decrypt older messages, Export E2EE room keys from Element (Settings > Security & Privacy > Encryption > Cryptography) and import them here. Imported keys are encrypted so you\'ll have to enter the password you set in order to decrypt it.'}</Text>
+ <ImportE2ERoomKeys />
+ </>
+ )}
+ />
+ </div>
</div>
);
}
function AboutSection() {
return (
- <div className="settings-content set__about">
- <div className="set-about__branding">
- <img width="60" height="60" src={CinnySVG} alt="Cinny logo" />
- <div>
- <Text variant="h2" weight="medium">
- Cinny
- <span className="text text-b3" style={{ margin: '0 var(--sp-extra-tight)' }}>{`v${cons.version}`}</span>
- </Text>
- <Text>Yet another matrix client</Text>
+ <div className="settings-about">
+ <div className="settings-about__card">
+ <MenuHeader>Application</MenuHeader>
+ <div className="settings-about__branding">
+ <img width="60" height="60" src={CinnySVG} alt="Cinny logo" />
+ <div>
+ <Text variant="h2" weight="medium">
+ Cinny
+ <span className="text text-b3" style={{ margin: '0 var(--sp-extra-tight)' }}>{`v${cons.version}`}</span>
+ </Text>
+ <Text>Yet another matrix client</Text>
- <div className="set-about__btns">
- <Button onClick={() => window.open('https://github.com/ajbura/cinny')}>Source code</Button>
- <Button onClick={() => window.open('https://cinny.in/#sponsor')}>Support</Button>
+ <div className="settings-about__btns">
+ <Button onClick={() => window.open('https://github.com/ajbura/cinny')}>Source code</Button>
+ <Button onClick={() => window.open('https://cinny.in/#sponsor')}>Support</Button>
+ </div>
</div>
</div>
</div>
- <div className="set-about__credits">
- <Text variant="s1" weight="medium">Credits</Text>
- <ul>
- <li>
- {/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
- <Text>The <a href="https://github.com/matrix-org/matrix-js-sdk" rel="noreferrer noopener" target="_blank">matrix-js-sdk</a> is © <a href="https://matrix.org/foundation" rel="noreferrer noopener" target="_blank">The Matrix.org Foundation C.I.C</a> used under the terms of <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener" target="_blank">Apache 2.0</a>.</Text>
- </li>
- <li>
- {/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
- <Text>The <a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">Twemoji</a> emoji art is © <a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">Twitter, Inc and other contributors</a> used under the terms of <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noreferrer noopener">CC-BY 4.0</a>.</Text>
- </li>
- <li>
- {/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
- <Text>The <a href="https://material.io/design/sound/sound-resources.html" target="_blank" rel="noreferrer noopener">Material sound resources</a> are © <a href="https://google.com" target="_blank" rel="noreferrer noopener">Google</a> used under the terms of <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noreferrer noopener">CC-BY 4.0</a>.</Text>
- </li>
- </ul>
+ <div className="settings-about__card">
+ <MenuHeader>Credits</MenuHeader>
+ <div className="settings-about__credits">
+ <ul>
+ <li>
+ {/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
+ <Text>The <a href="https://github.com/matrix-org/matrix-js-sdk" rel="noreferrer noopener" target="_blank">matrix-js-sdk</a> is © <a href="https://matrix.org/foundation" rel="noreferrer noopener" target="_blank">The Matrix.org Foundation C.I.C</a> used under the terms of <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener" target="_blank">Apache 2.0</a>.</Text>
+ </li>
+ <li>
+ {/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
+ <Text>The <a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">Twemoji</a> emoji art is © <a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">Twitter, Inc and other contributors</a> used under the terms of <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noreferrer noopener">CC-BY 4.0</a>.</Text>
+ </li>
+ <li>
+ {/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
+ <Text>The <a href="https://material.io/design/sound/sound-resources.html" target="_blank" rel="noreferrer noopener">Material sound resources</a> are © <a href="https://google.com" target="_blank" rel="noreferrer noopener">Google</a> used under the terms of <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noreferrer noopener">CC-BY 4.0</a>.</Text>
+ </li>
+ </ul>
+ </div>
</div>
</div>
);
}
-function Settings({ isOpen, onRequestClose }) {
- const settingSections = [{
- name: 'General',
- iconSrc: SettingsIC,
- render() {
- return <GeneralSection />;
- },
- }, {
- name: 'Appearance',
- iconSrc: SunIC,
- render() {
- return <AppearanceSection />;
- },
- }, {
- name: 'Notifications',
- iconSrc: BellIC,
- render() {
- return <NotificationsSection />;
- },
- }, {
- name: 'Security & Privacy',
- iconSrc: LockIC,
- render() {
- return <SecuritySection />;
- },
- }, {
- name: 'Help & About',
- iconSrc: InfoIC,
- render() {
- return <AboutSection />;
- },
- }];
- const [selectedSection, setSelectedSection] = useState(settingSections[0]);
+const tabText = {
+ APPEARANCE: 'Appearance',
+ NOTIFICATIONS: 'Notifications',
+ SECURITY: 'Security',
+ ABOUT: 'About',
+};
+const tabItems = [{
+ text: tabText.APPEARANCE,
+ iconSrc: SunIC,
+ disabled: false,
+ render: () => <AppearanceSection />,
+}, {
+ text: tabText.NOTIFICATIONS,
+ iconSrc: BellIC,
+ disabled: false,
+ render: () => <NotificationsSection />,
+}, {
+ text: tabText.SECURITY,
+ iconSrc: LockIC,
+ disabled: false,
+ render: () => <SecuritySection />,
+}, {
+ text: tabText.ABOUT,
+ iconSrc: InfoIC,
+ disabled: false,
+ render: () => <AboutSection />,
+}];
+
+function useWindowToggle(setSelectedTab) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ const openSettings = (tab) => {
+ const tabItem = tabItems.find((item) => item.text === tab);
+ if (tabItem) setSelectedTab(tabItem);
+ setIsOpen(true);
+ };
+ navigation.on(cons.events.navigation.SETTINGS_OPENED, openSettings);
+ return () => {
+ navigation.removeListener(cons.events.navigation.SETTINGS_OPENED, openSettings);
+ };
+ }, []);
+
+ const requestClose = () => setIsOpen(false);
+
+ return [isOpen, requestClose];
+}
+
+function Settings() {
+ const [selectedTab, setSelectedTab] = useState(tabItems[0]);
+ const [isOpen, requestClose] = useWindowToggle(setSelectedTab);
+ const handleTabChange = (tabItem) => setSelectedTab(tabItem);
const handleLogout = () => {
if (confirm('Confirm logout')) logout();
};
return (
<PopupWindow
- className="settings-window"
isOpen={isOpen}
- onRequestClose={onRequestClose}
- title="Settings"
- contentTitle={selectedSection.name}
- drawer={(
+ className="settings-window"
+ title={<Text variant="s1" weight="medium" primary>Settings</Text>}
+ contentOptions={(
<>
- {
- settingSections.map((section) => (
- <PWContentSelector
- key={section.name}
- selected={selectedSection.name === section.name}
- onClick={() => setSelectedSection(section)}
- iconSrc={section.iconSrc}
- >
- {section.name}
- </PWContentSelector>
- ))
- }
- <PWContentSelector
- variant="danger"
- onClick={handleLogout}
- iconSrc={PowerIC}
- >
+ <Button variant="danger" iconSrc={PowerIC} onClick={handleLogout}>
Logout
- </PWContentSelector>
+ </Button>
+ <IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />
</>
)}
- contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+ onRequestClose={requestClose}
>
- {selectedSection.render()}
+ {isOpen && (
+ <div className="settings-window__content">
+ <ProfileEditor userId={initMatrix.matrixClient.getUserId()} />
+ <Tabs
+ items={tabItems}
+ defaultSelected={tabItems.findIndex((tab) => tab.text === selectedTab.text)}
+ onSelect={handleTabChange}
+ />
+ <div className="settings-window__cards-wrapper">
+ { selectedTab.render() }
+ </div>
+ </div>
+ )}
</PopupWindow>
);
}
-Settings.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- onRequestClose: PropTypes.func.isRequired,
-};
-
export default Settings;