mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-17 20:50:29 +03:00
redesigned app settings and switch to rust crypto (#1988)
* rework general settings * account settings - WIP * add missing key prop * add object url hook * extract wide modal styles * profile settings and image editor - WIP * add outline style to upload card * remove file param from bind upload atom hook * add compact variant to upload card * add compact upload card renderer * add option to update profile avatar * add option to change profile displayname * allow displayname change based on capabilities check * rearrange settings components into folders * add system notification settings * add initial page param in settings * convert account data hook to typescript * add push rule hook * add notification mode hook * add notification mode switcher component * add all messages notification settings options * add special messages notification settings * add keyword notifications * add ignored users section * improve ignore user list strings * add about settings * add access token option in about settings * add developer tools settings * add expand button to account data dev tool option * update folds * fix editable active element textarea check * do not close dialog when editable element in focus * add text area plugins * add text area intent handler hook * add newline intent mod in text area * add next line hotkey in text area intent hook * add syntax error position dom utility function * add account data editor * add button to send new account data in dev tools * improve custom emoji plugin * add more custom emojis hooks * add text util css * add word break in setting tile title and description * emojis and sticker user settings - WIP * view image packs from settings * emoji pack editing - WIP * add option to edit pack meta * change saved changes message * add image edit and delete controls * add option to upload pack images and apply changes * fix state event type when updating image pack * lazy load pack image tile img * hide upload image button when user can not edit pack * add option to add or remove global image packs * upgrade to rust crypto (#2168) * update matrix js sdk * remove dead code * use rust crypto * update setPowerLevel usage * fix types * fix deprecated isRoomEncrypted method uses * fix deprecated room.currentState uses * fix deprecated import/export room keys func * fix merge issues in image pack file * fix remaining issues in image pack file * start indexedDBStore * update package lock and vite-plugin-top-level-await * user session settings - WIP * add useAsync hook * add password stage uia * add uia flow matrix error hook * add UIA action component * add options to delete sessions * add sso uia stage * fix SSO stage complete error * encryption - WIP * update user settings encryption terminology * add default variant to password input * use password input in uia password stage * add options for local backup in user settings * remove typo in import local backup password input label * online backup - WIP * fix uia sso action * move access token settings from about to developer tools * merge encryption tab into sessions and rename it to devices * add device placeholder tile * add logout dialog * add logout button for current device * move other devices in component * render unverified device verification tile * add learn more section for current device verification * add device verification status badge * add info card component * add index file for password input component * add types for secret storage * add component to access secret storage key * manual verification - WIP * update matrix-js-sdk to v35 * add manual verification * use react query for device list * show unverified tab on sidebar * fix device list updates * add session key details to current device * render restore encryption backup * fix loading state of restore backup * fix unverified tab settings closes after verification * key backup tile - WIP * fix unverified tab badge * rename session key to device key in device tile * improve backup restore functionality * fix restore button enabled after layout reload during restoring backup * update backup info on status change * add backup disconnection failures * add device verification using sas * restore backup after verification * show option to logout on startup error screen * fix key backup hook update on decryption key cached * add option to enable device verification * add device verification reset dialog * add logout button in settings drawer * add encrypted message lost on logout * fix backup restore never finish with 0 keys * fix setup dialog hides when enabling device verification * show backup details in menu * update setup device verification body copy * replace deprecated method * fix displayname appear as mxid in settings * remove old refactored codes * fix types
This commit is contained in:
parent
f5d68fcc22
commit
56b754153a
196 changed files with 14171 additions and 8403 deletions
|
|
@ -1,204 +0,0 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './EmojiVerification.scss';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { hasPrivateKey } from '../../../client/state/secretStorageKeys';
|
||||
import { getDefaultSSKey, isCrossVerified } from '../../../util/matrixUtil';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import Dialog from '../../molecules/dialog/Dialog';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { accessSecretStorage } from '../settings/SecretStorageAccess';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
function EmojiVerificationContent({ data, requestClose }) {
|
||||
const [sas, setSas] = useState(null);
|
||||
const [process, setProcess] = useState(false);
|
||||
const { request, targetDevice } = data;
|
||||
const mx = useMatrixClient();
|
||||
const mountStore = useStore();
|
||||
const beginStore = useStore();
|
||||
|
||||
const beginVerification = async () => {
|
||||
if (
|
||||
isCrossVerified(mx, mx.deviceId) &&
|
||||
(mx.getCrossSigningId() === null ||
|
||||
(await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing')) === false)
|
||||
) {
|
||||
if (!hasPrivateKey(getDefaultSSKey(mx))) {
|
||||
const keyData = await accessSecretStorage(mx, 'Emoji verification');
|
||||
if (!keyData) {
|
||||
request.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await mx.checkOwnCrossSigningTrust();
|
||||
}
|
||||
setProcess(true);
|
||||
await request.accept();
|
||||
|
||||
const verifier = request.beginKeyVerification('m.sas.v1', targetDevice);
|
||||
|
||||
const handleVerifier = (sasData) => {
|
||||
verifier.off('show_sas', handleVerifier);
|
||||
if (!mountStore.getItem()) return;
|
||||
setSas(sasData);
|
||||
setProcess(false);
|
||||
};
|
||||
verifier.on('show_sas', handleVerifier);
|
||||
await verifier.verify();
|
||||
};
|
||||
|
||||
const sasMismatch = () => {
|
||||
sas.mismatch();
|
||||
setProcess(true);
|
||||
};
|
||||
|
||||
const sasConfirm = () => {
|
||||
sas.confirm();
|
||||
setProcess(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountStore.setItem(true);
|
||||
const handleChange = () => {
|
||||
if (request.done || request.cancelled) {
|
||||
requestClose();
|
||||
return;
|
||||
}
|
||||
if (targetDevice && !beginStore.getItem()) {
|
||||
beginStore.setItem(true);
|
||||
beginVerification();
|
||||
}
|
||||
};
|
||||
|
||||
if (request === null) return undefined;
|
||||
const req = request;
|
||||
req.on('change', handleChange);
|
||||
return () => {
|
||||
req.off('change', handleChange);
|
||||
if (req.cancelled === false && req.done === false) {
|
||||
req.cancel();
|
||||
}
|
||||
};
|
||||
}, [request]);
|
||||
|
||||
const renderWait = () => (
|
||||
<>
|
||||
<Spinner size="small" />
|
||||
<Text>Waiting for response from other device...</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
if (sas !== null) {
|
||||
return (
|
||||
<div className="emoji-verification__content">
|
||||
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
|
||||
<div className="emoji-verification__emojis">
|
||||
{sas.sas.emoji.map((emoji, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className="emoji-verification__emoji-block" key={`${emoji[1]}-${i}`}>
|
||||
<Text variant="h1">{emoji[0]}</Text>
|
||||
<Text>{emoji[1]}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="emoji-verification__buttons">
|
||||
{process ? (
|
||||
renderWait()
|
||||
) : (
|
||||
<>
|
||||
<Button variant="primary" onClick={sasConfirm}>
|
||||
They match
|
||||
</Button>
|
||||
<Button onClick={sasMismatch}>No match</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (targetDevice) {
|
||||
return (
|
||||
<div className="emoji-verification__content">
|
||||
<Text>Please accept the request from other device.</Text>
|
||||
<div className="emoji-verification__buttons">{renderWait()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="emoji-verification__content">
|
||||
<Text>Click accept to start the verification process.</Text>
|
||||
<div className="emoji-verification__buttons">
|
||||
{process ? (
|
||||
renderWait()
|
||||
) : (
|
||||
<Button variant="primary" onClick={beginVerification}>
|
||||
Accept
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
EmojiVerificationContent.propTypes = {
|
||||
data: PropTypes.shape({}).isRequired,
|
||||
requestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function useVisibilityToggle() {
|
||||
const [data, setData] = useState(null);
|
||||
const mx = useMatrixClient();
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (request, targetDevice) => {
|
||||
setData({ request, targetDevice });
|
||||
};
|
||||
navigation.on(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
|
||||
mx.on('crypto.verification.request', handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
|
||||
mx.removeListener('crypto.verification.request', handleOpen);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
const requestClose = () => setData(null);
|
||||
|
||||
return [data, requestClose];
|
||||
}
|
||||
|
||||
function EmojiVerification() {
|
||||
const [data, requestClose] = useVisibilityToggle();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={data !== null}
|
||||
className="emoji-verification"
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
Emoji verification
|
||||
</Text>
|
||||
}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{data !== null ? (
|
||||
<EmojiVerificationContent data={data} requestClose={requestClose} />
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmojiVerification;
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.emoji-verification {
|
||||
&__content {
|
||||
padding: var(--sp-normal);
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
|
||||
&__emojis {
|
||||
margin: var(--sp-loose) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: var(--sp-extra-tight);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__emoji-block {
|
||||
@extend .cp-fx__column;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--sp-extra-tight);
|
||||
white-space: nowrap;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
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 { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
|
||||
import './ProfileEditor.scss';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
function ProfileEditor({ userId }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const mx = useMatrixClient();
|
||||
const user = mx.getUser(mx.getUserId());
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const displayNameRef = useRef(null);
|
||||
const [avatarSrc, setAvatarSrc] = useState(
|
||||
user.avatarUrl
|
||||
? mx.mxcUrlToHttp(user.avatarUrl, 80, 80, 'crop', undefined, undefined, useAuthentication)
|
||||
: null
|
||||
);
|
||||
const [username, setUsername] = useState(user.displayName);
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
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',
|
||||
undefined,
|
||||
undefined,
|
||||
useAuthentication
|
||||
)
|
||||
: null
|
||||
);
|
||||
setUsername(info.displayname);
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [mx, userId, useAuthentication]);
|
||||
|
||||
const handleAvatarUpload = async (url) => {
|
||||
if (url === null) {
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Remove avatar',
|
||||
'Are you sure that you want to remove avatar?',
|
||||
'Remove',
|
||||
'caution'
|
||||
);
|
||||
if (isConfirmed) {
|
||||
mx.setAvatarUrl('');
|
||||
setAvatarSrc(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
mx.setAvatarUrl(url);
|
||||
setAvatarSrc(mx.mxcUrlToHttp(url, 80, 80, 'crop', undefined, undefined, useAuthentication));
|
||||
};
|
||||
|
||||
const saveDisplayName = () => {
|
||||
const newDisplayName = displayNameRef.current.value;
|
||||
if (newDisplayName !== null && newDisplayName !== username) {
|
||||
mx.setDisplayName(newDisplayName);
|
||||
setUsername(newDisplayName);
|
||||
setDisabled(true);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDisplayNameInputChange = () => {
|
||||
setDisabled(username === displayNameRef.current.value || displayNameRef.current.value == null);
|
||||
};
|
||||
const cancelDisplayNameChanges = () => {
|
||||
displayNameRef.current.value = username;
|
||||
onDisplayNameInputChange();
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const renderForm = () => (
|
||||
<form
|
||||
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">
|
||||
{username ?? userId}
|
||||
</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 ?? userId}
|
||||
bgColor={colorMXID(userId)}
|
||||
imageSrc={avatarSrc}
|
||||
onUpload={handleAvatarUpload}
|
||||
onRequestRemove={() => handleAvatarUpload(null)}
|
||||
/>
|
||||
{isEditing ? renderForm() : renderInfo()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProfileEditor.defaultProps = {
|
||||
userId: null,
|
||||
};
|
||||
|
||||
ProfileEditor.propTypes = {
|
||||
userId: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ProfileEditor;
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
@use '../../partials/dir';
|
||||
@use '../../partials/flex';
|
||||
|
||||
.profile-editor {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.profile-editor__info,
|
||||
.profile-editor__form {
|
||||
@extend .cp-fx__item-one;
|
||||
@include dir.side(margin, var(--sp-loose), 0);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.profile-editor__info {
|
||||
flex-direction: column;
|
||||
& > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.ic-btn {
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-editor__form {
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
|
||||
& > .input-container {
|
||||
@extend .cp-fx__item-one;
|
||||
}
|
||||
& > button {
|
||||
height: 46px;
|
||||
margin-top: var(--sp-normal);
|
||||
@include dir.side(margin, var(--sp-normal), 0);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ProfileViewer.scss';
|
||||
import { EventTimeline } from 'matrix-js-sdk';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
|
|
@ -45,13 +46,14 @@ function ModerationTools({ roomId, userId }) {
|
|||
|
||||
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||
const powerLevel = roomMember?.powerLevel || 0;
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
const canIKick =
|
||||
roomMember?.membership === 'join' &&
|
||||
room.currentState.hasSufficientPowerLevelFor('kick', myPowerLevel) &&
|
||||
roomState?.hasSufficientPowerLevelFor('kick', myPowerLevel) &&
|
||||
powerLevel < myPowerLevel;
|
||||
const canIBan =
|
||||
['join', 'leave'].includes(roomMember?.membership) &&
|
||||
room.currentState.hasSufficientPowerLevelFor('ban', myPowerLevel) &&
|
||||
roomState?.hasSufficientPowerLevelFor('ban', myPowerLevel) &&
|
||||
powerLevel < myPowerLevel;
|
||||
|
||||
const handleKick = (e) => {
|
||||
|
|
@ -98,8 +100,9 @@ function SessionInfo({ userId }) {
|
|||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
await mx.downloadKeys([userId], true);
|
||||
const myDevices = mx.getStoredDevicesForUser(userId);
|
||||
const crypto = mx.getCrypto();
|
||||
const userToDevices = await crypto.getUserDeviceInfo([userId], true);
|
||||
const myDevices = Array.from(userToDevices.get(userId).values());
|
||||
|
||||
if (isUnmounted) return;
|
||||
setDevices(myDevices);
|
||||
|
|
@ -125,7 +128,7 @@ function SessionInfo({ userId }) {
|
|||
<Chip
|
||||
key={device.deviceId}
|
||||
iconSrc={ShieldEmptyIC}
|
||||
text={device.getDisplayName() || device.deviceId}
|
||||
text={device.displayName || device.deviceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -170,8 +173,10 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
|||
|
||||
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||
const userPL = room.getMember(userId)?.powerLevel || 0;
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
|
||||
const canIKick =
|
||||
room.currentState.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
|
||||
roomState?.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
|
||||
|
||||
const isBanned = member?.membership === 'ban';
|
||||
|
||||
|
|
@ -347,8 +352,9 @@ function ProfileViewer() {
|
|||
const powerLevel = roomMember?.powerLevel || 0;
|
||||
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||
|
||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
const canChangeRole =
|
||||
room.currentState.maySendEvent('m.room.power_levels', mx.getUserId()) &&
|
||||
roomState?.maySendEvent('m.room.power_levels', mx.getUserId()) &&
|
||||
(powerLevel < myPowerLevel || userId === mx.getUserId());
|
||||
|
||||
const handleChangePowerLevel = async (newPowerLevel) => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExistin
|
|||
import Search from '../search/Search';
|
||||
import CreateRoom from '../create-room/CreateRoom';
|
||||
import JoinAlias from '../join-alias/JoinAlias';
|
||||
import EmojiVerification from '../emoji-verification/EmojiVerification';
|
||||
|
||||
import ReusableDialog from '../../molecules/dialog/ReusableDialog';
|
||||
|
||||
|
|
@ -17,7 +16,6 @@ function Dialogs() {
|
|||
<JoinAlias />
|
||||
<SpaceAddExisting />
|
||||
<Search />
|
||||
<EmojiVerification />
|
||||
|
||||
<ReusableDialog />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import cons from '../../../client/state/cons';
|
|||
import navigation from '../../../client/state/navigation';
|
||||
|
||||
import InviteUser from '../invite-user/InviteUser';
|
||||
import Settings from '../settings/Settings';
|
||||
import SpaceSettings from '../space-settings/SpaceSettings';
|
||||
import RoomSettings from '../room/RoomSettings';
|
||||
|
||||
|
|
@ -38,7 +37,6 @@ function Windows() {
|
|||
searchTerm={inviteUser.searchTerm}
|
||||
onRequestClose={() => changeInviteUser({ isOpen: false, roomId: undefined })}
|
||||
/>
|
||||
<Settings />
|
||||
<SpaceSettings />
|
||||
<RoomSettings />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './AuthRequest.scss';
|
||||
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { getSecret } from '../../../client/state/auth';
|
||||
|
||||
let lastUsedPassword;
|
||||
const getAuthId = (password) => ({
|
||||
type: 'm.login.password',
|
||||
password,
|
||||
identifier: {
|
||||
type: 'm.id.user',
|
||||
user: getSecret().userId,
|
||||
},
|
||||
});
|
||||
|
||||
function AuthRequest({ onComplete, makeRequest }) {
|
||||
const [status, setStatus] = useState(false);
|
||||
const mountStore = useStore();
|
||||
|
||||
const handleForm = async (e) => {
|
||||
mountStore.setItem(true);
|
||||
e.preventDefault();
|
||||
const password = e.target.password.value;
|
||||
if (password.trim() === '') return;
|
||||
try {
|
||||
setStatus({ ongoing: true });
|
||||
await makeRequest(getAuthId(password));
|
||||
lastUsedPassword = password;
|
||||
if (!mountStore.getItem()) return;
|
||||
onComplete(true);
|
||||
} catch (err) {
|
||||
lastUsedPassword = undefined;
|
||||
if (!mountStore.getItem()) return;
|
||||
if (err.errcode === 'M_FORBIDDEN') {
|
||||
setStatus({ error: 'Wrong password. Please enter correct password.' });
|
||||
return;
|
||||
}
|
||||
setStatus({ error: 'Request failed!' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
setStatus(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-request">
|
||||
<form onSubmit={handleForm}>
|
||||
<Input
|
||||
name="password"
|
||||
label="Account password"
|
||||
type="password"
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
{status.ongoing && <Spinner size="small" />}
|
||||
{status.error && <Text variant="b3">{status.error}</Text>}
|
||||
{(status === false || status.error) && <Button variant="primary" type="submit" disabled={!!status.error}>Continue</Button>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
AuthRequest.propTypes = {
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
makeRequest: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} title Title of dialog
|
||||
* @param {(auth) => void} makeRequest request to make
|
||||
* @returns {Promise<boolean>} whether the request succeed or not.
|
||||
*/
|
||||
export const authRequest = async (title, makeRequest) => {
|
||||
try {
|
||||
const auth = lastUsedPassword ? getAuthId(lastUsedPassword) : undefined;
|
||||
await makeRequest(auth);
|
||||
return true;
|
||||
} catch (e) {
|
||||
lastUsedPassword = undefined;
|
||||
if (e.httpStatus !== 401 || e.data?.flows === undefined) return false;
|
||||
|
||||
const { flows } = e.data;
|
||||
const canUsePassword = flows.find((f) => f.stages.includes('m.login.password'));
|
||||
if (!canUsePassword) return false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let isCompleted = false;
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">{title}</Text>,
|
||||
(requestClose) => (
|
||||
<AuthRequest
|
||||
onComplete={(done) => {
|
||||
isCompleted = true;
|
||||
resolve(done);
|
||||
requestClose();
|
||||
}}
|
||||
makeRequest={makeRequest}
|
||||
/>
|
||||
),
|
||||
() => {
|
||||
if (!isCompleted) resolve(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default AuthRequest;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
.auth-request {
|
||||
padding: var(--sp-normal);
|
||||
|
||||
& form > *:not(:first-child) {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
|
||||
& .text-b3 {
|
||||
color: var(--tc-danger-high);
|
||||
margin-top: var(--sp-ultra-tight) !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
/* eslint-disable react/jsx-one-expression-per-line */
|
||||
import React, { useState } from 'react';
|
||||
import './CrossSigning.scss';
|
||||
import FileSaver from 'file-saver';
|
||||
import { Formik } from 'formik';
|
||||
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
import { copyToClipboard } from '../../../util/common';
|
||||
import { clearSecretStorageKeys } from '../../../client/state/secretStorageKeys';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
|
||||
import { authRequest } from './AuthRequest';
|
||||
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
const failedDialog = () => {
|
||||
const renderFailure = (requestClose) => (
|
||||
<div className="cross-signing__failure">
|
||||
<Text variant="h1">❌</Text>
|
||||
<Text weight="medium">Failed to setup cross signing. Please try again.</Text>
|
||||
<Button onClick={requestClose}>Close</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">
|
||||
Setup cross signing
|
||||
</Text>,
|
||||
renderFailure
|
||||
);
|
||||
};
|
||||
|
||||
const securityKeyDialog = (key) => {
|
||||
const downloadKey = () => {
|
||||
const blob = new Blob([key.encodedPrivateKey], {
|
||||
type: 'text/plain;charset=us-ascii',
|
||||
});
|
||||
FileSaver.saveAs(blob, 'security-key.txt');
|
||||
};
|
||||
const copyKey = () => {
|
||||
copyToClipboard(key.encodedPrivateKey);
|
||||
};
|
||||
|
||||
const renderSecurityKey = () => (
|
||||
<div className="cross-signing__key">
|
||||
<Text weight="medium">Please save this security key somewhere safe.</Text>
|
||||
<Text className="cross-signing__key-text">{key.encodedPrivateKey}</Text>
|
||||
<div className="cross-signing__key-btn">
|
||||
<Button variant="primary" onClick={() => copyKey(key)}>
|
||||
Copy
|
||||
</Button>
|
||||
<Button onClick={() => downloadKey(key)}>Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Download automatically.
|
||||
downloadKey();
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">
|
||||
Security Key
|
||||
</Text>,
|
||||
() => renderSecurityKey()
|
||||
);
|
||||
};
|
||||
|
||||
function CrossSigningSetup() {
|
||||
const initialValues = { phrase: '', confirmPhrase: '' };
|
||||
const [genWithPhrase, setGenWithPhrase] = useState(undefined);
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const setup = async (securityPhrase = undefined) => {
|
||||
setGenWithPhrase(typeof securityPhrase === 'string');
|
||||
const recoveryKey = await mx.createRecoveryKeyFromPassphrase(securityPhrase);
|
||||
clearSecretStorageKeys();
|
||||
|
||||
await mx.bootstrapSecretStorage({
|
||||
createSecretStorageKey: async () => recoveryKey,
|
||||
setupNewKeyBackup: true,
|
||||
setupNewSecretStorage: true,
|
||||
});
|
||||
|
||||
const authUploadDeviceSigningKeys = async (makeRequest) => {
|
||||
const isDone = await authRequest('Setup cross signing', async (auth) => {
|
||||
await makeRequest(auth);
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (isDone) securityKeyDialog(recoveryKey);
|
||||
else failedDialog();
|
||||
});
|
||||
};
|
||||
|
||||
await mx.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys,
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
};
|
||||
|
||||
const validator = (values) => {
|
||||
const errors = {};
|
||||
if (values.phrase === '12345678') {
|
||||
errors.phrase = 'How about 87654321 ?';
|
||||
}
|
||||
if (values.phrase === '87654321') {
|
||||
errors.phrase = 'Your are playing with 🔥';
|
||||
}
|
||||
const PHRASE_REGEX = /^([^\s]){8,127}$/;
|
||||
if (values.phrase.length > 0 && !PHRASE_REGEX.test(values.phrase)) {
|
||||
errors.phrase = 'Phrase must contain 8-127 characters with no space.';
|
||||
}
|
||||
if (values.confirmPhrase.length > 0 && values.confirmPhrase !== values.phrase) {
|
||||
errors.confirmPhrase = "Phrase don't match.";
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cross-signing__setup">
|
||||
<div className="cross-signing__setup-entry">
|
||||
<Text>
|
||||
We will generate a <b>Security Key</b>, which you can use to manage messages backup and
|
||||
session verification.
|
||||
</Text>
|
||||
{genWithPhrase !== false && (
|
||||
<Button variant="primary" onClick={() => setup()} disabled={genWithPhrase !== undefined}>
|
||||
Generate Key
|
||||
</Button>
|
||||
)}
|
||||
{genWithPhrase === false && <Spinner size="small" />}
|
||||
</div>
|
||||
<Text className="cross-signing__setup-divider">OR</Text>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => setup(values.phrase)}
|
||||
validate={validator}
|
||||
>
|
||||
{({ values, errors, handleChange, handleSubmit }) => (
|
||||
<form
|
||||
className="cross-signing__setup-entry"
|
||||
onSubmit={handleSubmit}
|
||||
disabled={genWithPhrase !== undefined}
|
||||
>
|
||||
<Text>
|
||||
Alternatively you can also set a <b>Security Phrase </b>
|
||||
so you don't have to remember long Security Key, and optionally save the Key as
|
||||
backup.
|
||||
</Text>
|
||||
<Input
|
||||
name="phrase"
|
||||
value={values.phrase}
|
||||
onChange={handleChange}
|
||||
label="Security Phrase"
|
||||
type="password"
|
||||
required
|
||||
disabled={genWithPhrase !== undefined}
|
||||
/>
|
||||
{errors.phrase && (
|
||||
<Text variant="b3" className="cross-signing__error">
|
||||
{errors.phrase}
|
||||
</Text>
|
||||
)}
|
||||
<Input
|
||||
name="confirmPhrase"
|
||||
value={values.confirmPhrase}
|
||||
onChange={handleChange}
|
||||
label="Confirm Security Phrase"
|
||||
type="password"
|
||||
required
|
||||
disabled={genWithPhrase !== undefined}
|
||||
/>
|
||||
{errors.confirmPhrase && (
|
||||
<Text variant="b3" className="cross-signing__error">
|
||||
{errors.confirmPhrase}
|
||||
</Text>
|
||||
)}
|
||||
{genWithPhrase !== true && (
|
||||
<Button variant="primary" type="submit" disabled={genWithPhrase !== undefined}>
|
||||
Set Phrase & Generate Key
|
||||
</Button>
|
||||
)}
|
||||
{genWithPhrase === true && <Spinner size="small" />}
|
||||
</form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const setupDialog = () => {
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">
|
||||
Setup cross signing
|
||||
</Text>,
|
||||
() => <CrossSigningSetup />
|
||||
);
|
||||
};
|
||||
|
||||
function CrossSigningReset() {
|
||||
return (
|
||||
<div className="cross-signing__reset">
|
||||
<Text variant="h1">✋🧑🚒🤚</Text>
|
||||
<Text weight="medium">Resetting cross-signing keys is permanent.</Text>
|
||||
<Text>
|
||||
Anyone you have verified with will see security alerts and your message backup will be lost.
|
||||
You almost certainly do not want to do this, unless you have lost <b>Security Key</b> or{' '}
|
||||
<b>Phrase</b> and every session you can cross-sign from.
|
||||
</Text>
|
||||
<Button variant="danger" onClick={setupDialog}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resetDialog = () => {
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">
|
||||
Reset cross signing
|
||||
</Text>,
|
||||
() => <CrossSigningReset />
|
||||
);
|
||||
};
|
||||
|
||||
function CrossSignin() {
|
||||
const isCSEnabled = useCrossSigningStatus();
|
||||
return (
|
||||
<SettingTile
|
||||
title="Cross signing"
|
||||
content={
|
||||
<Text variant="b3">
|
||||
Setup to verify and keep track of all your sessions. Also required to backup encrypted
|
||||
message.
|
||||
</Text>
|
||||
}
|
||||
options={
|
||||
isCSEnabled ? (
|
||||
<Button variant="danger" onClick={resetDialog}>
|
||||
Reset
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={setupDialog}>
|
||||
Setup
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CrossSignin;
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
.cross-signing {
|
||||
&__setup {
|
||||
padding: var(--sp-normal);
|
||||
}
|
||||
&__setup-entry {
|
||||
& > *:not(:first-child) {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: var(--tc-danger-high);
|
||||
margin-top: var(--sp-ultra-tight) !important;
|
||||
}
|
||||
|
||||
&__setup-divider {
|
||||
margin: var(--sp-tight) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
flex: 1;
|
||||
content: '';
|
||||
margin: var(--sp-tight) 0;
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cross-signing__key {
|
||||
padding: var(--sp-normal);
|
||||
|
||||
&-text {
|
||||
margin: var(--sp-normal) 0;
|
||||
padding: var(--sp-extra-tight);
|
||||
background-color: var(--bg-surface-low);
|
||||
border-radius: var(--bo-radius);
|
||||
}
|
||||
&-btn {
|
||||
display: flex;
|
||||
& > button:last-child {
|
||||
margin: 0 var(--sp-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cross-signing__failure,
|
||||
.cross-signing__reset {
|
||||
padding: var(--sp-normal);
|
||||
padding-top: var(--sp-extra-loose);
|
||||
& > .text {
|
||||
padding-bottom: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import './DeviceManage.scss';
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
import { isCrossVerified } from '../../../util/matrixUtil';
|
||||
import { openReusableDialog, openEmojiVerification } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||
import InfoCard from '../../atoms/card/InfoCard';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
|
||||
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
||||
|
||||
import { authRequest } from './AuthRequest';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||
import { accessSecretStorage } from './SecretStorageAccess';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
const promptDeviceName = async (deviceName) => new Promise((resolve) => {
|
||||
let isCompleted = false;
|
||||
|
||||
const renderContent = (onComplete) => {
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const name = e.target.session.value;
|
||||
if (typeof name !== 'string') onComplete(null);
|
||||
onComplete(name);
|
||||
};
|
||||
return (
|
||||
<form className="device-manage__rename" onSubmit={handleSubmit}>
|
||||
<Input value={deviceName} label="Session name" name="session" />
|
||||
<div className="device-manage__rename-btn">
|
||||
<Button variant="primary" type="submit">Save</Button>
|
||||
<Button onClick={() => onComplete(null)}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Edit session name</Text>,
|
||||
(requestClose) => renderContent((name) => {
|
||||
isCompleted = true;
|
||||
resolve(name);
|
||||
requestClose();
|
||||
}),
|
||||
() => {
|
||||
if (!isCompleted) resolve(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function DeviceManage() {
|
||||
const TRUNCATED_COUNT = 4;
|
||||
const mx = useMatrixClient();
|
||||
const isCSEnabled = useCrossSigningStatus();
|
||||
const deviceList = useDeviceList();
|
||||
const [processing, setProcessing] = useState([]);
|
||||
const [truncated, setTruncated] = useState(true);
|
||||
const mountStore = useStore();
|
||||
mountStore.setItem(true);
|
||||
const isMeVerified = isCrossVerified(mx, mx.deviceId);
|
||||
|
||||
useEffect(() => {
|
||||
setProcessing([]);
|
||||
}, [deviceList]);
|
||||
|
||||
const addToProcessing = (device) => {
|
||||
const old = [...processing];
|
||||
old.push(device.device_id);
|
||||
setProcessing(old);
|
||||
};
|
||||
|
||||
const removeFromProcessing = () => {
|
||||
setProcessing([]);
|
||||
};
|
||||
|
||||
if (deviceList === null) {
|
||||
return (
|
||||
<div className="device-manage">
|
||||
<div className="device-manage__loading">
|
||||
<Spinner size="small" />
|
||||
<Text>Loading sessions...</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRename = async (device) => {
|
||||
const newName = await promptDeviceName(device.display_name);
|
||||
if (newName === null || newName.trim() === '') return;
|
||||
if (newName.trim() === device.display_name) return;
|
||||
addToProcessing(device);
|
||||
try {
|
||||
await mx.setDeviceDetails(device.device_id, {
|
||||
display_name: newName,
|
||||
});
|
||||
} catch {
|
||||
if (!mountStore.getItem()) return;
|
||||
removeFromProcessing(device);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (device) => {
|
||||
const isConfirmed = await confirmDialog(
|
||||
`Logout ${device.display_name}`,
|
||||
`You are about to logout "${device.display_name}" session.`,
|
||||
'Logout',
|
||||
'danger',
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
addToProcessing(device);
|
||||
await authRequest(`Logout "${device.display_name}"`, async (auth) => {
|
||||
await mx.deleteDevice(device.device_id, auth);
|
||||
});
|
||||
|
||||
if (!mountStore.getItem()) return;
|
||||
removeFromProcessing(device);
|
||||
};
|
||||
|
||||
const verifyWithKey = async (device) => {
|
||||
const keyData = await accessSecretStorage(mx, 'Session verification');
|
||||
if (!keyData) return;
|
||||
addToProcessing(device);
|
||||
await mx.checkOwnCrossSigningTrust();
|
||||
};
|
||||
|
||||
const verifyWithEmojis = async (deviceId) => {
|
||||
const req = await mx.requestVerification(mx.getUserId(), [deviceId]);
|
||||
openEmojiVerification(req, { userId: mx.getUserId(), deviceId });
|
||||
};
|
||||
|
||||
const verify = (deviceId, isCurrentDevice) => {
|
||||
if (isCurrentDevice) {
|
||||
verifyWithKey(deviceId);
|
||||
return;
|
||||
}
|
||||
verifyWithEmojis(deviceId);
|
||||
};
|
||||
|
||||
const renderDevice = (device, isVerified) => {
|
||||
const deviceId = device.device_id;
|
||||
const displayName = device.display_name;
|
||||
const lastIP = device.last_seen_ip;
|
||||
const lastTS = device.last_seen_ts;
|
||||
const isCurrentDevice = mx.deviceId === deviceId;
|
||||
const canVerify = isVerified === false && (isMeVerified || isCurrentDevice);
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
key={deviceId}
|
||||
title={(
|
||||
<Text style={{ color: isVerified !== false ? '' : 'var(--tc-danger-high)' }}>
|
||||
{displayName}
|
||||
<Text variant="b3" span>{`${displayName ? ' — ' : ''}${deviceId}`}</Text>
|
||||
{isCurrentDevice && <Text span className="device-manage__current-label" variant="b3">Current</Text>}
|
||||
</Text>
|
||||
)}
|
||||
options={
|
||||
processing.includes(deviceId)
|
||||
? <Spinner size="small" />
|
||||
: (
|
||||
<>
|
||||
{(isCSEnabled && canVerify) && <Button onClick={() => verify(deviceId, isCurrentDevice)} variant="positive">Verify</Button>}
|
||||
<IconButton size="small" onClick={() => handleRename(device)} src={PencilIC} tooltip="Rename" />
|
||||
<IconButton size="small" onClick={() => handleRemove(device)} src={BinIC} tooltip="Remove session" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
content={(
|
||||
<>
|
||||
{lastTS && (
|
||||
<Text variant="b3">
|
||||
Last activity
|
||||
<span style={{ color: 'var(--tc-surface-normal)' }}>
|
||||
{dateFormat(new Date(lastTS), ' hh:MM TT, dd/mm/yyyy')}
|
||||
</span>
|
||||
{lastIP ? ` at ${lastIP}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
{isCurrentDevice && (
|
||||
<Text style={{ marginTop: 'var(--sp-ultra-tight)' }} variant="b3">
|
||||
{`Session Key: ${mx.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const unverified = [];
|
||||
const verified = [];
|
||||
const noEncryption = [];
|
||||
deviceList.sort((a, b) => b.last_seen_ts - a.last_seen_ts).forEach((device) => {
|
||||
const isVerified = isCrossVerified(mx, device.device_id);
|
||||
if (isVerified === true) {
|
||||
verified.push(device);
|
||||
} else if (isVerified === false) {
|
||||
unverified.push(device);
|
||||
} else {
|
||||
noEncryption.push(device);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<div className="device-manage">
|
||||
<div>
|
||||
<MenuHeader>Unverified sessions</MenuHeader>
|
||||
{!isMeVerified && isCSEnabled && (
|
||||
<div style={{ padding: 'var(--sp-extra-tight) var(--sp-normal)' }}>
|
||||
<InfoCard
|
||||
rounded
|
||||
variant="primary"
|
||||
iconSrc={InfoIC}
|
||||
title="Verify this session either with your Security Key/Phrase here or by initiating emoji verification from a verified session."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isMeVerified && unverified.length > 0 && (
|
||||
<div style={{ padding: 'var(--sp-extra-tight) var(--sp-normal)' }}>
|
||||
<InfoCard
|
||||
rounded
|
||||
variant="surface"
|
||||
iconSrc={InfoIC}
|
||||
title="Verify other sessions by emoji verification or remove unfamiliar ones."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isCSEnabled && (
|
||||
<div style={{ padding: 'var(--sp-extra-tight) var(--sp-normal)' }}>
|
||||
<InfoCard
|
||||
rounded
|
||||
variant="caution"
|
||||
iconSrc={InfoIC}
|
||||
title="Setup cross signing in case you lose all your sessions."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
unverified.length > 0
|
||||
? unverified.map((device) => renderDevice(device, false))
|
||||
: <Text className="device-manage__info">No unverified sessions</Text>
|
||||
}
|
||||
</div>
|
||||
{noEncryption.length > 0 && (
|
||||
<div>
|
||||
<MenuHeader>Sessions without encryption support</MenuHeader>
|
||||
{noEncryption.map((device) => renderDevice(device, null))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<MenuHeader>Verified sessions</MenuHeader>
|
||||
{
|
||||
verified.length > 0
|
||||
? verified.map((device, index) => {
|
||||
if (truncated && index >= TRUNCATED_COUNT) return null;
|
||||
return renderDevice(device, true);
|
||||
})
|
||||
: <Text className="device-manage__info">No verified sessions</Text>
|
||||
}
|
||||
{ verified.length > TRUNCATED_COUNT && (
|
||||
<Button className="device-manage__info" onClick={() => setTruncated(!truncated)}>
|
||||
{truncated ? `View ${verified.length - 4} more` : 'View less'}
|
||||
</Button>
|
||||
)}
|
||||
{ deviceList.length > 0 && (
|
||||
<Text className="device-manage__info" variant="b3">Session names are visible to everyone, so do not put any private info here.</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeviceManage;
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
|
||||
.device-manage {
|
||||
&__loading {
|
||||
@extend .cp-fx__row--c-c;
|
||||
padding: var(--sp-extra-loose) var(--sp-normal);
|
||||
|
||||
.text {
|
||||
margin: 0 var(--sp-normal);
|
||||
}
|
||||
}
|
||||
&__info {
|
||||
margin: var(--sp-normal);
|
||||
}
|
||||
& .setting-tile:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
& .setting-tile__options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-ultra-tight);
|
||||
& .btn-positive {
|
||||
padding: 6px var(--sp-tight);
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__current-label {
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
padding: 2px var(--sp-ultra-tight);
|
||||
color: var(--bg-surface);
|
||||
background-color: var(--tc-surface-low);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__rename {
|
||||
padding: var(--sp-normal);
|
||||
& > *:not(:last-child) {
|
||||
margin-bottom: var(--sp-normal);
|
||||
}
|
||||
&-btn {
|
||||
display: flex;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './KeyBackup.scss';
|
||||
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
import { deletePrivateKey } from '../../../client/state/secretStorageKeys';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import InfoCard from '../../atoms/card/InfoCard';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
|
||||
import { accessSecretStorage } from './SecretStorageAccess';
|
||||
|
||||
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||
import DownloadIC from '../../../../public/res/ic/outlined/download.svg';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
function CreateKeyBackupDialog({ keyData }) {
|
||||
const [done, setDone] = useState(false);
|
||||
const mx = useMatrixClient();
|
||||
const mountStore = useStore();
|
||||
|
||||
const doBackup = async () => {
|
||||
setDone(false);
|
||||
let info;
|
||||
|
||||
try {
|
||||
info = await mx.prepareKeyBackupVersion(null, { secureSecretStorage: true });
|
||||
info = await mx.createKeyBackupVersion(info);
|
||||
await mx.scheduleAllGroupSessionsForBackup();
|
||||
if (!mountStore.getItem()) return;
|
||||
setDone(true);
|
||||
} catch (e) {
|
||||
deletePrivateKey(keyData.keyId);
|
||||
await mx.deleteKeyBackupVersion(info.version);
|
||||
if (!mountStore.getItem()) return;
|
||||
setDone(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountStore.setItem(true);
|
||||
doBackup();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="key-backup__create">
|
||||
{done === false && (
|
||||
<div>
|
||||
<Spinner size="small" />
|
||||
<Text>Creating backup...</Text>
|
||||
</div>
|
||||
)}
|
||||
{done === true && (
|
||||
<>
|
||||
<Text variant="h1">✅</Text>
|
||||
<Text>Successfully created backup</Text>
|
||||
</>
|
||||
)}
|
||||
{done === null && (
|
||||
<>
|
||||
<Text>Failed to create backup</Text>
|
||||
<Button onClick={doBackup}>Retry</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
CreateKeyBackupDialog.propTypes = {
|
||||
keyData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
function RestoreKeyBackupDialog({ keyData }) {
|
||||
const [status, setStatus] = useState(false);
|
||||
const mx = useMatrixClient();
|
||||
const mountStore = useStore();
|
||||
|
||||
const restoreBackup = async () => {
|
||||
setStatus(false);
|
||||
|
||||
let meBreath = true;
|
||||
const progressCallback = (progress) => {
|
||||
if (!progress.successes) return;
|
||||
if (meBreath === false) return;
|
||||
meBreath = false;
|
||||
setTimeout(() => {
|
||||
meBreath = true;
|
||||
}, 200);
|
||||
|
||||
setStatus({ message: `Restoring backup keys... (${progress.successes}/${progress.total})` });
|
||||
};
|
||||
|
||||
try {
|
||||
const backupInfo = await mx.getKeyBackupVersion();
|
||||
const info = await mx.restoreKeyBackupWithSecretStorage(backupInfo, undefined, undefined, {
|
||||
progressCallback,
|
||||
});
|
||||
if (!mountStore.getItem()) return;
|
||||
setStatus({ done: `Successfully restored backup keys (${info.imported}/${info.total}).` });
|
||||
} catch (e) {
|
||||
if (!mountStore.getItem()) return;
|
||||
if (e.errcode === 'RESTORE_BACKUP_ERROR_BAD_KEY') {
|
||||
deletePrivateKey(keyData.keyId);
|
||||
setStatus({ error: 'Failed to restore backup. Key is invalid!', errorCode: 'BAD_KEY' });
|
||||
} else {
|
||||
setStatus({ error: 'Failed to restore backup.', errCode: 'UNKNOWN' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountStore.setItem(true);
|
||||
restoreBackup();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="key-backup__restore">
|
||||
{(status === false || status.message) && (
|
||||
<div>
|
||||
<Spinner size="small" />
|
||||
<Text>{status.message ?? 'Restoring backup keys...'}</Text>
|
||||
</div>
|
||||
)}
|
||||
{status.done && (
|
||||
<>
|
||||
<Text variant="h1">✅</Text>
|
||||
<Text>{status.done}</Text>
|
||||
</>
|
||||
)}
|
||||
{status.error && (
|
||||
<>
|
||||
<Text>{status.error}</Text>
|
||||
<Button onClick={restoreBackup}>Retry</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
RestoreKeyBackupDialog.propTypes = {
|
||||
keyData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
function DeleteKeyBackupDialog({ requestClose }) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const mx = useMatrixClient();
|
||||
const mountStore = useStore();
|
||||
|
||||
const deleteBackup = async () => {
|
||||
mountStore.setItem(true);
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const backupInfo = await mx.getKeyBackupVersion();
|
||||
if (backupInfo) await mx.deleteKeyBackupVersion(backupInfo.version);
|
||||
if (!mountStore.getItem()) return;
|
||||
requestClose(true);
|
||||
} catch {
|
||||
if (!mountStore.getItem()) return;
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="key-backup__delete">
|
||||
<Text variant="h1">🗑</Text>
|
||||
<Text weight="medium">Deleting key backup is permanent.</Text>
|
||||
<Text>All encrypted messages keys stored on server will be deleted.</Text>
|
||||
{isDeleting ? (
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
<Button variant="danger" onClick={deleteBackup}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
DeleteKeyBackupDialog.propTypes = {
|
||||
requestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function KeyBackup() {
|
||||
const mx = useMatrixClient();
|
||||
const isCSEnabled = useCrossSigningStatus();
|
||||
const [keyBackup, setKeyBackup] = useState(undefined);
|
||||
const mountStore = useStore();
|
||||
|
||||
const fetchKeyBackupVersion = async () => {
|
||||
const info = await mx.getKeyBackupVersion();
|
||||
if (!mountStore.getItem()) return;
|
||||
setKeyBackup(info);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountStore.setItem(true);
|
||||
fetchKeyBackupVersion();
|
||||
|
||||
const handleAccountData = (event) => {
|
||||
if (event.getType() === 'm.megolm_backup.v1') {
|
||||
fetchKeyBackupVersion();
|
||||
}
|
||||
};
|
||||
|
||||
mx.on('accountData', handleAccountData);
|
||||
return () => {
|
||||
mx.removeListener('accountData', handleAccountData);
|
||||
};
|
||||
}, [isCSEnabled]);
|
||||
|
||||
const openCreateKeyBackup = async () => {
|
||||
const keyData = await accessSecretStorage(mx, 'Create Key Backup');
|
||||
if (keyData === null) return;
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">
|
||||
Create Key Backup
|
||||
</Text>,
|
||||
() => <CreateKeyBackupDialog keyData={keyData} />,
|
||||
() => fetchKeyBackupVersion()
|
||||
);
|
||||
};
|
||||
|
||||
const openRestoreKeyBackup = async () => {
|
||||
const keyData = await accessSecretStorage(mx, 'Restore Key Backup');
|
||||
if (keyData === null) return;
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">
|
||||
Restore Key Backup
|
||||
</Text>,
|
||||
() => <RestoreKeyBackupDialog keyData={keyData} />
|
||||
);
|
||||
};
|
||||
|
||||
const openDeleteKeyBackup = () =>
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">
|
||||
Delete Key Backup
|
||||
</Text>,
|
||||
(requestClose) => (
|
||||
<DeleteKeyBackupDialog
|
||||
requestClose={(isDone) => {
|
||||
if (isDone) setKeyBackup(null);
|
||||
requestClose();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
const renderOptions = () => {
|
||||
if (keyBackup === undefined) return <Spinner size="small" />;
|
||||
if (keyBackup === null)
|
||||
return (
|
||||
<Button variant="primary" onClick={openCreateKeyBackup}>
|
||||
Create Backup
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
src={DownloadIC}
|
||||
variant="positive"
|
||||
onClick={openRestoreKeyBackup}
|
||||
tooltip="Restore backup"
|
||||
/>
|
||||
<IconButton src={BinIC} onClick={openDeleteKeyBackup} tooltip="Delete backup" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Encrypted messages backup"
|
||||
content={
|
||||
<>
|
||||
<Text variant="b3">
|
||||
Online backup your encrypted messages keys with your account data in case you lose
|
||||
access to your sessions. Your keys will be secured with a unique Security Key.
|
||||
</Text>
|
||||
{!isCSEnabled && (
|
||||
<InfoCard
|
||||
style={{ marginTop: 'var(--sp-ultra-tight)' }}
|
||||
rounded
|
||||
variant="caution"
|
||||
iconSrc={InfoIC}
|
||||
title="Setup cross signing to backup your encrypted messages."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
options={isCSEnabled ? renderOptions() : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyBackup;
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
.key-backup__create,
|
||||
.key-backup__restore {
|
||||
padding: var(--sp-normal);
|
||||
|
||||
& > div {
|
||||
padding: var(--sp-normal) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > .text {
|
||||
margin: 0 var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
||||
& > .text {
|
||||
margin-bottom: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.key-backup__delete {
|
||||
padding: var(--sp-normal);
|
||||
padding-top: var(--sp-extra-loose);
|
||||
|
||||
& > .text {
|
||||
padding-bottom: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './SecretStorageAccess.scss';
|
||||
import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
|
||||
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
import { getDefaultSSKey, getSSKeyInfo } from '../../../util/matrixUtil';
|
||||
import { storePrivateKey, hasPrivateKey, getPrivateKey } from '../../../client/state/secretStorageKeys';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
function SecretStorageAccess({ onComplete }) {
|
||||
const mx = useMatrixClient();
|
||||
const sSKeyId = getDefaultSSKey(mx);
|
||||
const sSKeyInfo = getSSKeyInfo(mx, sSKeyId);
|
||||
const isPassphrase = !!sSKeyInfo.passphrase;
|
||||
const [withPhrase, setWithPhrase] = useState(isPassphrase);
|
||||
const [process, setProcess] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const mountStore = useStore();
|
||||
|
||||
const toggleWithPhrase = () => setWithPhrase(!withPhrase);
|
||||
|
||||
const processInput = async ({ key, phrase }) => {
|
||||
mountStore.setItem(true);
|
||||
setProcess(true);
|
||||
try {
|
||||
const { salt, iterations } = sSKeyInfo.passphrase || {};
|
||||
const privateKey = key
|
||||
? mx.keyBackupKeyFromRecoveryKey(key)
|
||||
: await deriveKey(phrase, salt, iterations);
|
||||
const isCorrect = await mx.checkSecretStorageKey(privateKey, sSKeyInfo);
|
||||
|
||||
if (!mountStore.getItem()) return;
|
||||
if (!isCorrect) {
|
||||
setError(`Incorrect Security ${key ? 'Key' : 'Phrase'}`);
|
||||
setProcess(false);
|
||||
return;
|
||||
}
|
||||
onComplete({
|
||||
keyId: sSKeyId,
|
||||
key,
|
||||
phrase,
|
||||
privateKey,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mountStore.getItem()) return;
|
||||
setError(`Incorrect Security ${key ? 'Key' : 'Phrase'}`);
|
||||
setProcess(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForm = async (e) => {
|
||||
e.preventDefault();
|
||||
const password = e.target.password.value;
|
||||
if (password.trim() === '') return;
|
||||
const data = {};
|
||||
if (withPhrase) data.phrase = password;
|
||||
else data.key = password;
|
||||
processInput(data);
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
setError(null);
|
||||
setProcess(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="secret-storage-access">
|
||||
<form onSubmit={handleForm}>
|
||||
<Input
|
||||
name="password"
|
||||
label={`Security ${withPhrase ? 'Phrase' : 'Key'}`}
|
||||
type="password"
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
{error && <Text variant="b3">{error}</Text>}
|
||||
{!process && (
|
||||
<div className="secret-storage-access__btn">
|
||||
<Button variant="primary" type="submit">Continue</Button>
|
||||
{isPassphrase && <Button onClick={toggleWithPhrase}>{`Use Security ${withPhrase ? 'Key' : 'Phrase'}`}</Button>}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
{process && <Spinner size="small" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SecretStorageAccess.propTypes = {
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {MatrixClient} mx Matrix client
|
||||
* @param {string} title Title of secret storage access dialog
|
||||
* @returns {Promise<keyData | null>} resolve to keyData or null
|
||||
*/
|
||||
export const accessSecretStorage = (mx, title) => new Promise((resolve) => {
|
||||
let isCompleted = false;
|
||||
const defaultSSKey = getDefaultSSKey(mx);
|
||||
if (hasPrivateKey(defaultSSKey)) {
|
||||
resolve({ keyId: defaultSSKey, privateKey: getPrivateKey(defaultSSKey) });
|
||||
return;
|
||||
}
|
||||
const handleComplete = (keyData) => {
|
||||
isCompleted = true;
|
||||
storePrivateKey(keyData.keyId, keyData.privateKey);
|
||||
resolve(keyData);
|
||||
};
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">{title}</Text>,
|
||||
(requestClose) => (
|
||||
<SecretStorageAccess
|
||||
onComplete={(keyData) => {
|
||||
handleComplete(keyData);
|
||||
requestClose(requestClose);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => {
|
||||
if (!isCompleted) resolve(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
export default SecretStorageAccess;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
.secret-storage-access {
|
||||
padding: var(--sp-normal);
|
||||
|
||||
& form > *:not(:first-child) {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
|
||||
& .text-b3 {
|
||||
color: var(--tc-danger-high);
|
||||
margin-top: var(--sp-ultra-tight) !important;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
& .donut-spinner {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,660 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Input, toRem } from 'folds';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import './Settings.scss';
|
||||
|
||||
import { clearCacheAndReload, logoutClient } from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import settings from '../../../client/state/settings';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { toggleSystemTheme } from '../../../client/action/settings';
|
||||
import { usePermissionState } from '../../hooks/usePermission';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
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 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 { ImagePackUser, ImagePackGlobal } from '../../molecules/image-pack/ImagePack';
|
||||
import GlobalNotification from '../../molecules/global-notification/GlobalNotification';
|
||||
import KeywordNotification from '../../molecules/global-notification/KeywordNotification';
|
||||
import IgnoreUserList from '../../molecules/global-notification/IgnoreUserList';
|
||||
|
||||
import ProfileEditor from '../profile-editor/ProfileEditor';
|
||||
import CrossSigning from './CrossSigning';
|
||||
import KeyBackup from './KeyBackup';
|
||||
import DeviceManage from './DeviceManage';
|
||||
|
||||
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
|
||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
|
||||
import BellIC from '../../../../public/res/ic/outlined/bell.svg';
|
||||
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
||||
import PowerIC from '../../../../public/res/ic/outlined/power.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { isMacOS } from '../../utils/user-agent';
|
||||
import { KeySymbol } from '../../utils/key-symbol';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
function AppearanceSection() {
|
||||
const [, updateState] = useState({});
|
||||
|
||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
const [pageZoom, setPageZoom] = useSetting(settingsAtom, 'pageZoom');
|
||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
|
||||
settingsAtom,
|
||||
'hideMembershipEvents'
|
||||
);
|
||||
const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(
|
||||
settingsAtom,
|
||||
'hideNickAvatarEvents'
|
||||
);
|
||||
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||
const spacings = ['0', '100', '200', '300', '400', '500'];
|
||||
|
||||
const [currentZoom, setCurrentZoom] = useState(`${pageZoom}`);
|
||||
|
||||
const handleZoomChange = (evt) => {
|
||||
setCurrentZoom(evt.target.value);
|
||||
};
|
||||
|
||||
const handleZoomEnter = (evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.stopPropagation();
|
||||
setCurrentZoom(pageZoom);
|
||||
}
|
||||
if (isKeyHotkey('enter', evt)) {
|
||||
const newZoom = parseInt(evt.target.value, 10);
|
||||
if (Number.isNaN(newZoom)) return;
|
||||
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
|
||||
setPageZoom(safeZoom);
|
||||
setCurrentZoom(safeZoom);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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({});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
content={<Text variant="b3">Use light or dark mode based on the system settings.</Text>}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Theme"
|
||||
content={
|
||||
<SegmentedControls
|
||||
selected={settings.useSystemTheme ? -1 : settings.getThemeIndex()}
|
||||
segments={[
|
||||
{ text: 'Light' },
|
||||
{ text: 'Silver' },
|
||||
{ text: 'Dark' },
|
||||
{ text: 'Butter' },
|
||||
]}
|
||||
onSelect={(index) => {
|
||||
if (settings.useSystemTheme) toggleSystemTheme();
|
||||
settings.setTheme(index);
|
||||
updateState({});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Use Twitter Emoji"
|
||||
options={
|
||||
<Toggle isActive={twitterEmoji} onToggle={() => setTwitterEmoji(!twitterEmoji)} />
|
||||
}
|
||||
content={<Text variant="b3">Use Twitter emoji instead of system emoji.</Text>}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Page Zoom"
|
||||
options={
|
||||
<Input
|
||||
style={{ width: toRem(150) }}
|
||||
variant={pageZoom === parseInt(currentZoom, 10) ? 'Background' : 'Primary'}
|
||||
size="400"
|
||||
type="number"
|
||||
min="75"
|
||||
max="150"
|
||||
value={currentZoom}
|
||||
onChange={handleZoomChange}
|
||||
onKeyDown={handleZoomEnter}
|
||||
outlined
|
||||
after={<Text variant="b2">%</Text>}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
<Text variant="b3">
|
||||
Change page zoom to scale user interface between 75% to 150%. Default: 100%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-appearance__card">
|
||||
<MenuHeader>Room messages</MenuHeader>
|
||||
<SettingTile
|
||||
title="Message Layout"
|
||||
content={
|
||||
<SegmentedControls
|
||||
selected={messageLayout}
|
||||
segments={[{ text: 'Modern' }, { text: 'Compact' }, { text: 'Bubble' }]}
|
||||
onSelect={(index) => setMessageLayout(index)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Message Spacing"
|
||||
content={
|
||||
<SegmentedControls
|
||||
selected={spacings.findIndex((s) => s === messageSpacing)}
|
||||
segments={[
|
||||
{ text: 'No' },
|
||||
{ text: 'XXS' },
|
||||
{ text: 'XS' },
|
||||
{ text: 'S' },
|
||||
{ text: 'M' },
|
||||
{ text: 'L' },
|
||||
]}
|
||||
onSelect={(index) => {
|
||||
setMessageSpacing(spacings[index]);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Use ENTER for Newline"
|
||||
options={
|
||||
<Toggle
|
||||
isActive={enterForNewline}
|
||||
onToggle={() => setEnterForNewline(!enterForNewline)}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
<Text variant="b3">{`Use ${
|
||||
isMacOS() ? KeySymbol.Command : 'Ctrl'
|
||||
} + ENTER to send message and ENTER for newline.`}</Text>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Markdown formatting"
|
||||
options={<Toggle isActive={isMarkdown} onToggle={() => setIsMarkdown(!isMarkdown)} />}
|
||||
content={<Text variant="b3">Format messages with markdown syntax before sending.</Text>}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Hide membership events"
|
||||
options={
|
||||
<Toggle
|
||||
isActive={hideMembershipEvents}
|
||||
onToggle={() => setHideMembershipEvents(!hideMembershipEvents)}
|
||||
/>
|
||||
}
|
||||
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={hideNickAvatarEvents}
|
||||
onToggle={() => setHideNickAvatarEvents(!hideNickAvatarEvents)}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
<Text variant="b3">Hide nick and avatar change messages from room timeline.</Text>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Disable media auto load"
|
||||
options={
|
||||
<Toggle isActive={!mediaAutoLoad} onToggle={() => setMediaAutoLoad(!mediaAutoLoad)} />
|
||||
}
|
||||
content={
|
||||
<Text variant="b3">Prevent images and videos from auto loading to save bandwidth.</Text>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Url Preview"
|
||||
options={<Toggle isActive={urlPreview} onToggle={() => setUrlPreview(!urlPreview)} />}
|
||||
content={<Text variant="b3">Show url preview for link in messages.</Text>}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Url Preview in Encrypted Room"
|
||||
options={
|
||||
<Toggle isActive={encUrlPreview} onToggle={() => setEncUrlPreview(!encUrlPreview)} />
|
||||
}
|
||||
content={<Text variant="b3">Show url preview for link in encrypted messages.</Text>}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Show hidden events"
|
||||
options={
|
||||
<Toggle
|
||||
isActive={showHiddenEvents}
|
||||
onToggle={() => setShowHiddenEvents(!showHiddenEvents)}
|
||||
/>
|
||||
}
|
||||
content={<Text variant="b3">Show hidden state and message events.</Text>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsSection() {
|
||||
const notifPermission = usePermissionState(
|
||||
'notifications',
|
||||
window.Notification?.permission ?? 'denied'
|
||||
);
|
||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
||||
settingsAtom,
|
||||
'isNotificationSounds'
|
||||
);
|
||||
|
||||
const renderOptions = () => {
|
||||
if (window.Notification === undefined) {
|
||||
return (
|
||||
<Text className="settings-notifications__not-supported">
|
||||
Not supported in this browser.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (notifPermission === 'denied') {
|
||||
return <Text>Permission Denied</Text>;
|
||||
}
|
||||
|
||||
if (notifPermission === 'granted') {
|
||||
return (
|
||||
<Toggle
|
||||
isActive={showNotifications}
|
||||
onToggle={() => {
|
||||
setShowNotifications(!showNotifications);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
window.Notification.requestPermission().then(() => {
|
||||
setShowNotifications(window.Notification?.permission === 'granted');
|
||||
})
|
||||
}
|
||||
>
|
||||
Request permission
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-notifications">
|
||||
<MenuHeader>Notification & Sound</MenuHeader>
|
||||
<SettingTile
|
||||
title="Desktop notification"
|
||||
options={renderOptions()}
|
||||
content={<Text variant="b3">Show desktop notification when new messages arrive.</Text>}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Notification Sound"
|
||||
options={
|
||||
<Toggle
|
||||
isActive={isNotificationSounds}
|
||||
onToggle={() => setIsNotificationSounds(!isNotificationSounds)}
|
||||
/>
|
||||
}
|
||||
content={<Text variant="b3">Play sound when new messages arrive.</Text>}
|
||||
/>
|
||||
</div>
|
||||
<GlobalNotification />
|
||||
<KeywordNotification />
|
||||
<IgnoreUserList />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmojiSection() {
|
||||
return (
|
||||
<>
|
||||
<div className="settings-emoji__card">
|
||||
<ImagePackUser />
|
||||
</div>
|
||||
<div className="settings-emoji__card">
|
||||
<ImagePackGlobal />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SecuritySection() {
|
||||
return (
|
||||
<div className="settings-security">
|
||||
<div className="settings-security__card">
|
||||
<MenuHeader>Cross signing and backup</MenuHeader>
|
||||
<CrossSigning />
|
||||
<KeyBackup />
|
||||
</div>
|
||||
<DeviceManage />
|
||||
<div className="settings-security__card">
|
||||
<MenuHeader>Export/Import encryption keys</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() {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return (
|
||||
<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="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>
|
||||
<Button onClick={() => clearCacheAndReload(mx)} variant="danger">
|
||||
Clear cache & reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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://github.com/mozilla/twemoji-colr"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
twemoji-colr
|
||||
</a>{' '}
|
||||
font is ©{' '}
|
||||
<a href="https://mozilla.org/" target="_blank" rel="noreferrer noopener">
|
||||
Mozilla Foundation
|
||||
</a>{' '}
|
||||
used under the terms of{' '}
|
||||
<a
|
||||
href="http://www.apache.org/licenses/LICENSE-2.0"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export const tabText = {
|
||||
APPEARANCE: 'Appearance',
|
||||
NOTIFICATIONS: 'Notifications',
|
||||
EMOJI: 'Emoji',
|
||||
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.EMOJI,
|
||||
iconSrc: EmojiIC,
|
||||
disabled: false,
|
||||
render: () => <EmojiSection />,
|
||||
},
|
||||
{
|
||||
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 mx = useMatrixClient();
|
||||
|
||||
const handleTabChange = (tabItem) => setSelectedTab(tabItem);
|
||||
const handleLogout = async () => {
|
||||
if (
|
||||
await confirmDialog(
|
||||
'Logout',
|
||||
'Are you sure that you want to logout your session?',
|
||||
'Logout',
|
||||
'danger'
|
||||
)
|
||||
) {
|
||||
logoutClient(mx);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PopupWindow
|
||||
isOpen={isOpen}
|
||||
className="settings-window"
|
||||
title={
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
Settings
|
||||
</Text>
|
||||
}
|
||||
contentOptions={
|
||||
<>
|
||||
<Button variant="danger" iconSrc={PowerIC} onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />
|
||||
</>
|
||||
}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{isOpen && (
|
||||
<div className="settings-window__content">
|
||||
<ProfileEditor userId={mx.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>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/screen';
|
||||
|
||||
.settings-window {
|
||||
& .pw {
|
||||
background-color: var(--bg-surface-low);
|
||||
}
|
||||
|
||||
.header .btn-danger {
|
||||
margin: 0 var(--sp-tight);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
& .profile-editor {
|
||||
padding: var(--sp-loose) var(--sp-extra-loose);
|
||||
}
|
||||
|
||||
& .tabs__content {
|
||||
padding: 0 var(--sp-normal);
|
||||
}
|
||||
|
||||
&__cards-wrapper {
|
||||
padding: 0 var(--sp-normal);
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
}
|
||||
}
|
||||
.settings-window__card {
|
||||
margin: var(--sp-normal) 0;
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: var(--bo-radius);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
overflow: hidden;
|
||||
|
||||
& > .context-menu__header:first-child {
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
.settings-appearance__card,
|
||||
.settings-notifications,
|
||||
.global-notification,
|
||||
.keyword-notification,
|
||||
.ignore-user-list,
|
||||
.settings-security__card,
|
||||
.settings-security .device-manage,
|
||||
.settings-about__card,
|
||||
.settings-emoji__card {
|
||||
@extend .settings-window__card;
|
||||
}
|
||||
|
||||
.settings-window__cards-wrapper{
|
||||
& .setting-tile {
|
||||
margin: 0 var(--sp-normal);
|
||||
margin-top: var(--sp-normal);
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-notifications {
|
||||
&__not-supported {
|
||||
padding: 0 var(--sp-ultra-tight);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-about {
|
||||
&__branding {
|
||||
padding: var(--sp-normal);
|
||||
display: flex;
|
||||
|
||||
& > div {
|
||||
margin: 0 var(--sp-loose);
|
||||
}
|
||||
|
||||
}
|
||||
&__btns {
|
||||
& button {
|
||||
margin-top: var(--sp-tight);
|
||||
@include dir.side(margin, 0, var(--sp-tight));
|
||||
}
|
||||
}
|
||||
|
||||
&__credits {
|
||||
padding: 0 var(--sp-normal);
|
||||
& ul {
|
||||
color: var(--tc-surface-low);
|
||||
padding: var(--sp-normal);
|
||||
margin: var(--sp-extra-tight) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue