Merge pull request #3950 from matrix-org/t3chguy/cs_verification_decoration

Cross Signing Right Panel Verification Decoration
pull/21833/head
Michael Telatynski 2020-01-28 23:57:48 +00:00 committed by GitHub
commit 2c973f7467
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 751 additions and 385 deletions

View File

@ -143,7 +143,9 @@
@import "./views/messages/_TextualEvent.scss";
@import "./views/messages/_UnknownBody.scss";
@import "./views/messages/_ViewSourceEvent.scss";
@import "./views/right_panel/_EncryptionInfo.scss";
@import "./views/right_panel/_UserInfo.scss";
@import "./views/right_panel/_VerificationPanel.scss";
@import "./views/room_settings/_AliasSettings.scss";
@import "./views/room_settings/_ColorSettings.scss";
@import "./views/rooms/_AppsDrawer.scss";

View File

@ -0,0 +1,26 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_UserInfo {
.mx_EncryptionInfo_spinner {
.mx_Spinner {
margin-top: 25px;
margin-bottom: 15px;
}
text-align: center;
}
}

View File

@ -49,12 +49,17 @@ limitations under the License.
}
.mx_UserInfo_container {
padding: 0 16px 16px 16px;
padding: 8px 16px;
}
.mx_UserInfo_separator {
border-bottom: 1px solid lightgray;
}
.mx_UserInfo_memberDetailsContainer {
padding-top: 0;
padding-bottom: 0;
margin-bottom: 8px;
}
.mx_RoomTile_nameContainer {
@ -76,6 +81,7 @@ limitations under the License.
.mx_UserInfo_avatar > div {
max-width: 30vh;
margin: 0 auto;
transition: 0.5s;
}
.mx_UserInfo_avatar > div > div {
@ -105,6 +111,7 @@ limitations under the License.
// override the calculated sizes so that the letter isn't HUGE
font-size: 56px !important;
width: 100% !important;
transition: font-size 0.5s;
}
.mx_UserInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image {
@ -204,10 +211,9 @@ limitations under the License.
padding-bottom: 16px;
}
.mx_UserInfo_scrollContainer .mx_UserInfo_container {
.mx_UserInfo_scrollContainer:not(.mx_UserInfo_separator) {
padding-top: 16px;
padding-bottom: 0;
border-bottom: none;
> :not(h3) {
margin-left: 8px;
@ -256,11 +262,17 @@ limitations under the License.
.mx_UserInfo_verify {
display: block;
background-color: $accent-color;
color: $accent-fg-color;
border-radius: 4px;
padding: 7px 1.5em;
text-align: center;
margin: 16px 0;
}
}
.mx_UserInfo.mx_UserInfo_smallAvatar {
.mx_UserInfo_avatar > div {
max-width: 72px;
margin: 0 auto;
}
.mx_UserInfo_avatar .mx_BaseAvatar_initial {
font-size: 40px !important; // override the other override because here the avatar is smaller
}
}

View File

@ -0,0 +1,37 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_UserInfo {
.mx_VerificationPanel_verified_section .mx_E2EIcon {
margin: 0 auto;
}
.mx_VerificationPanel_qrCode {
padding: 4px 4px 0 4px;
background: white;
border-radius: 4px;
width: max-content;
max-width: 100%;
margin: 0 auto;
canvas {
// override height and width which are set on the element directly
height: auto !important;
width: 100% !important;
max-width: 240px;
}
}
}

View File

@ -169,7 +169,6 @@ export default class RightPanel extends React.Component {
const MemberList = sdk.getComponent('rooms.MemberList');
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
const UserInfo = sdk.getComponent('right_panel.UserInfo');
const EncryptionPanel = sdk.getComponent('right_panel.EncryptionPanel');
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
const FilePanel = sdk.getComponent('structures.FilePanel');
@ -181,64 +180,82 @@ export default class RightPanel extends React.Component {
let panel = <div />;
if (this.props.roomId && this.state.phase === RIGHT_PANEL_PHASES.RoomMemberList) {
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
} else if (this.props.groupId && this.state.phase === RIGHT_PANEL_PHASES.GroupMemberList) {
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomList) {
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo) {
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
const onClose = () => {
dis.dispatch({
action: "view_user",
member: null,
});
};
panel = <UserInfo
user={this.state.member}
roomId={this.props.roomId}
key={this.props.roomId || this.state.member.userId}
onClose={onClose}
/>;
} else {
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
}
} else if (this.state.phase === RIGHT_PANEL_PHASES.Room3pidMemberInfo) {
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
} else if (this.state.phase === RIGHT_PANEL_PHASES.GroupMemberInfo) {
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
const onClose = () => {
dis.dispatch({
action: "view_user",
member: null,
});
};
panel = <UserInfo
user={this.state.member}
groupId={this.props.groupId}
key={this.state.member.userId}
onClose={onClose} />;
} else {
panel = (
<GroupMemberInfo
groupMember={this.state.member}
switch (this.state.phase) {
case RIGHT_PANEL_PHASES.RoomMemberList:
if (this.props.roomId) {
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
}
break;
case RIGHT_PANEL_PHASES.GroupMemberList:
if (this.props.groupId) {
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
}
break;
case RIGHT_PANEL_PHASES.GroupRoomList:
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
break;
case RIGHT_PANEL_PHASES.RoomMemberInfo:
case RIGHT_PANEL_PHASES.EncryptionPanel:
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
const onClose = () => {
dis.dispatch({
action: "view_user",
member: this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel ? this.state.member : null,
});
};
panel = <UserInfo
user={this.state.member}
roomId={this.props.roomId}
key={this.props.roomId || this.state.member.userId}
onClose={onClose}
phase={this.state.phase}
verificationRequest={this.state.verificationRequest}
/>;
} else {
panel = <MemberInfo
member={this.state.member}
key={this.props.roomId || this.state.member.userId}
/>;
}
break;
case RIGHT_PANEL_PHASES.Room3pidMemberInfo:
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
break;
case RIGHT_PANEL_PHASES.GroupMemberInfo:
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
const onClose = () => {
dis.dispatch({
action: "view_user",
member: null,
});
};
panel = <UserInfo
user={this.state.member}
groupId={this.props.groupId}
key={this.state.member.user_id}
/>
);
}
} else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomInfo) {
panel = <GroupRoomInfo
groupRoomId={this.state.groupRoomId}
groupId={this.props.groupId}
key={this.state.groupRoomId} />;
} else if (this.state.phase === RIGHT_PANEL_PHASES.NotificationPanel) {
panel = <NotificationPanel />;
} else if (this.state.phase === RIGHT_PANEL_PHASES.FilePanel) {
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
} else if (this.state.phase === RIGHT_PANEL_PHASES.EncryptionPanel) {
panel = <EncryptionPanel member={this.state.member} verificationRequest={this.state.verificationRequest} />;
key={this.state.member.userId}
onClose={onClose} />;
} else {
panel = (
<GroupMemberInfo
groupMember={this.state.member}
groupId={this.props.groupId}
key={this.state.member.user_id}
/>
);
}
break;
case RIGHT_PANEL_PHASES.GroupRoomInfo:
panel = <GroupRoomInfo
groupRoomId={this.state.groupRoomId}
groupId={this.props.groupId}
key={this.state.groupRoomId} />;
break;
case RIGHT_PANEL_PHASES.NotificationPanel:
panel = <NotificationPanel />;
break;
case RIGHT_PANEL_PHASES.FilePanel:
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
break;
}
const classes = classNames("mx_RightPanel", "mx_fadable", {

View File

@ -42,6 +42,7 @@ export default createReactClass({
button: PropTypes.string,
focus: PropTypes.bool,
onFinished: PropTypes.func.isRequired,
headerImage: PropTypes.string,
},
getDefaultProps: function() {
@ -56,9 +57,12 @@ export default createReactClass({
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
title={this.props.title || _t('Error')}
contentId='mx_Dialog_content'
<BaseDialog
className="mx_ErrorDialog"
onFinished={this.props.onFinished}
title={this.props.title || _t('Error')}
headerImage={this.props.headerImage}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ this.props.description || _t('An error has occurred.') }

View File

@ -51,6 +51,6 @@ export default class VerificationQRCode extends React.PureComponent {
const uri = `https://matrix.to/#/${this.props.keyholderUserId}?${qs.stringify(query)}`;
return <QRCode value={uri} size={256} logoWidth={48} logo={require("../../../../../res/img/matrix-m.svg")} />;
return <QRCode value={uri} size={512} logoWidth={64} logo={require("../../../../../res/img/matrix-m.svg")} />;
}
}

View File

@ -45,10 +45,11 @@ export default class MKeyVerificationRequest extends React.Component {
_openRequest = () => {
const {verificationRequest} = this.props.mxEvent;
const member = MatrixClientPeg.get().getUser(verificationRequest.otherUserId);
dis.dispatch({
action: "set_right_panel_phase",
phase: RIGHT_PANEL_PHASES.EncryptionPanel,
refireParams: {verificationRequest},
refireParams: {verificationRequest, member},
});
};

View File

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,18 +14,58 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import React from "react";
import PropTypes from "prop-types";
import * as sdk from "../../../index";
import {_t} from "../../../languageHandler";
export default class EncryptionInfo extends React.PureComponent {
render() {
export const PendingActionSpinner = ({text}) => {
const Spinner = sdk.getComponent('elements.Spinner');
return <div className="mx_EncryptionInfo_spinner">
<Spinner />
{ text }
</div>;
};
const EncryptionInfo = ({pending, member, onStartVerification}) => {
let content;
if (pending) {
const text = _t("Waiting for %(displayName)s to accept…", {
displayName: member.displayName || member.name || member.userId,
});
content = <PendingActionSpinner text={text} />;
} else {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (<div className="mx_UserInfo"><div className="mx_UserInfo_container">
<h3>{_t("Verify User")}</h3>
<p>{_t("For extra security, verify this user by checking a one-time code on both of your devices.")}</p>
<p>{_t("For maximum security, do this in person.")}</p>
<AccessibleButton kind="primary" onClick={this.props.onStartVerification}>{_t("Start Verification")}</AccessibleButton>
</div></div>);
content = (
<AccessibleButton kind="primary" className="mx_UserInfo_verify" onClick={onStartVerification}>
{_t("Start Verification")}
</AccessibleButton>
);
}
}
return <React.Fragment>
<div className="mx_UserInfo_container">
<h3>{_t("Encryption")}</h3>
<div>
<p>{_t("Messages in this room are end-to-end encrypted.")}</p>
<p>{_t("Your messages are secured and only you and the recipient have the unique keys to unlock them.")}</p>
</div>
</div>
<div className="mx_UserInfo_container">
<h3>{_t("Verify User")}</h3>
<div>
<p>{_t("For extra security, verify this user by checking a one-time code on both of your devices.")}</p>
<p>{_t("For maximum security, do this in person.")}</p>
{ content }
</div>
</div>
</React.Fragment>;
};
EncryptionInfo.propTypes = {
member: PropTypes.object.isRequired,
onStartVerification: PropTypes.func.isRequired,
request: PropTypes.object,
};
export default EncryptionInfo;

View File

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,35 +14,81 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {useCallback, useEffect, useState} from "react";
import PropTypes from "prop-types";
import EncryptionInfo from "./EncryptionInfo";
import VerificationPanel from "./VerificationPanel";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {ensureDMExists} from "../../../createRoom";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import Modal from "../../../Modal";
import {PHASE_REQUESTED} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import * as sdk from "../../../index";
import {_t} from "../../../languageHandler";
export default class EncryptionPanel extends React.PureComponent {
constructor(props) {
super(props);
this.state = {};
}
// cancellation codes which constitute a key mismatch
const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"];
render() {
const request = this.props.verificationRequest || this.state.verificationRequest;
const {member} = this.props;
if (request) {
return <VerificationPanel request={request} key={request.channel.transactionId} />;
} else if (member) {
return <EncryptionInfo onStartVerification={this._onStartVerification} member={member} />;
} else {
return <p>Not a member nor request, not sure what to render</p>;
const EncryptionPanel = ({verificationRequest, member, onClose}) => {
const [request, setRequest] = useState(verificationRequest);
useEffect(() => {
setRequest(verificationRequest);
}, [verificationRequest]);
const [phase, setPhase] = useState(false);
const changeHandler = useCallback(() => {
// handle transitions -> cancelled for mismatches which fire a modal instead of showing a card
if (request && request.cancelled && MISMATCHES.includes(request.cancellationCode)) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog("Verification failed", "insecure", ErrorDialog, {
headerImage: require("../../../../res/img/e2e/warning.svg"),
title: _t("Your messages are not secure"),
description: <div>
{_t("One of the following may be compromised:")}
<ul>
<li>{_t("Your homeserver")}</li>
<li>{_t("The homeserver the user youre verifying is connected to")}</li>
<li>{_t("Yours, or the other users internet connection")}</li>
<li>{_t("Yours, or the other users device")}</li>
</ul>
</div>,
onFinished: onClose,
});
return; // don't update phase here as we will be transitioning away from this view shortly
}
}
_onStartVerification = async () => {
const client = MatrixClientPeg.get();
const {member} = this.props;
const roomId = await ensureDMExists(client, member.userId);
const verificationRequest = await client.requestVerificationDM(member.userId, roomId);
this.setState({verificationRequest});
};
}
if (request) {
setPhase(request.phase);
}
}, [onClose, request]);
useEventEmitter(request, "change", changeHandler);
const onStartVerification = useCallback(async () => {
const cli = MatrixClientPeg.get();
const roomId = await ensureDMExists(cli, member.userId);
const verificationRequest = await cli.requestVerificationDM(member.userId, roomId);
setRequest(verificationRequest);
}, [member.userId]);
const requested = request && phase === PHASE_REQUESTED;
if (!request || requested) {
return <EncryptionInfo onStartVerification={onStartVerification} member={member} pending={requested} />;
} else {
return (
<VerificationPanel
onClose={onClose}
member={member}
request={request}
key={request.channel.transactionId}
phase={phase} />
);
}
};
EncryptionPanel.propTypes = {
member: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
verificationRequest: PropTypes.object,
};
export default EncryptionPanel;

View File

@ -41,6 +41,7 @@ import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {textualPowerLevel} from '../../../Roles';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
import EncryptionPanel from "./EncryptionPanel";
const _disambiguateDevices = (devices) => {
const names = Object.create(null);
@ -59,7 +60,7 @@ const _disambiguateDevices = (devices) => {
}
};
const _getE2EStatus = (cli, userId, devices) => {
export const getE2EStatus = (cli, userId, devices) => {
if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) {
const hasUnverifiedDevice = devices.some((device) => device.isUnverified());
return hasUnverifiedDevice ? "warning" : "verified";
@ -1053,33 +1054,85 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
);
};
const UserInfo = ({user, groupId, roomId, onClose}) => {
export const useDevices = (userId) => {
const cli = useContext(MatrixClientContext);
// Load room if we are given a room id and memoize it
const room = useMemo(() => roomId ? cli.getRoom(roomId) : null, [cli, roomId]);
// fetch latest room member if we have a room, so we don't show historical information, falling back to user
const member = useMemo(() => room ? (room.getMember(user.userId) || user) : user, [room, user]);
// undefined means yet to be loaded, null means failed to load, otherwise list of devices
const [devices, setDevices] = useState(undefined);
// Download device lists
useEffect(() => {
setDevices(undefined);
// only display the devices list if our client supports E2E
const _enableDevices = cli.isCryptoEnabled();
let cancelled = false;
async function _downloadDeviceList() {
try {
await cli.downloadKeys([userId], true);
const devices = await cli.getStoredDevicesForUser(userId);
if (cancelled) {
// we got cancelled - presumably a different user now
return;
}
_disambiguateDevices(devices);
setDevices(devices);
} catch (err) {
setDevices(null);
}
}
_downloadDeviceList();
// Handle being unmounted
return () => {
cancelled = true;
};
}, [cli, userId]);
// Listen to changes
useEffect(() => {
let cancel = false;
const onDeviceVerificationChanged = (_userId, device) => {
if (_userId === userId) {
// no need to re-download the whole thing; just update our copy of the list.
// Promise.resolve to handle transition from static result to promise; can be removed in future
Promise.resolve(cli.getStoredDevicesForUser(userId)).then((devices) => {
if (cancel) return;
setDevices(devices);
});
}
};
cli.on("deviceVerificationChanged", onDeviceVerificationChanged);
// Handle being unmounted
return () => {
cancel = true;
cli.removeListener("deviceVerificationChanged", onDeviceVerificationChanged);
};
}, [cli, userId]);
return devices;
};
const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
const cli = useContext(MatrixClientContext);
const powerLevels = useRoomPowerLevels(cli, room);
// Load whether or not we are a Synapse Admin
const isSynapseAdmin = useIsSynapseAdmin(cli);
// Check whether the user is ignored
const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(user.userId));
const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId));
// Recheck if the user or client changes
useEffect(() => {
setIsIgnored(cli.isUserIgnored(user.userId));
}, [cli, user.userId]);
setIsIgnored(cli.isUserIgnored(member.userId));
}, [cli, member.userId]);
// Recheck also if we receive new accountData m.ignored_user_list
const accountDataHandler = useCallback((ev) => {
if (ev.getType() === "m.ignored_user_list") {
setIsIgnored(cli.isUserIgnored(user.userId));
setIsIgnored(cli.isUserIgnored(member.userId));
}
}, [cli, user.userId]);
}, [cli, member.userId]);
useEventEmitter(cli, "accountData", accountDataHandler);
// Count of how many operations are currently in progress, if > 0 then show a Spinner
@ -1110,7 +1163,7 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
const [accepted] = await finished;
if (!accepted) return;
try {
await cli.deactivateSynapseUser(user.userId);
await cli.deactivateSynapseUser(member.userId);
} catch (err) {
console.error("Failed to deactivate user");
console.error(err);
@ -1121,21 +1174,7 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
}
}, [cli, user.userId]);
const onMemberAvatarClick = useCallback(() => {
const avatarUrl = member.getMxcAvatarUrl ? member.getMxcAvatarUrl() : member.avatarUrl;
if (!avatarUrl) return;
const httpUrl = cli.mxcUrlToHttp(avatarUrl);
const ImageView = sdk.getComponent("elements.ImageView");
const params = {
src: httpUrl,
name: member.name,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
}, [cli, member]);
}, [cli, member.userId]);
let synapseDeactivateButton;
let spinner;
@ -1143,7 +1182,7 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
// We don't need a perfect check here, just something to pass as "probably not our homeserver". If
// someone does figure out how to bypass this check the worst that happens is an error.
// FIXME this should be using cli instead of MatrixClientPeg.matrixClient
if (isSynapseAdmin && user.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) {
if (isSynapseAdmin && member.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) {
synapseDeactivateButton = (
<AccessibleButton onClick={onSynapseDeactivate} className="mx_UserInfo_field mx_UserInfo_destructive">
{_t("Deactivate user")}
@ -1167,7 +1206,7 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
adminToolsContainer = (
<GroupAdminToolsSection
groupId={groupId}
groupMember={user}
groupMember={member}
startUpdating={startUpdating}
stopUpdating={stopUpdating}>
{ synapseDeactivateButton }
@ -1186,7 +1225,124 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
spinner = <Loader imgClassName="mx_ContextualMenu_spinner" />;
}
const displayName = member.name || member.displayname;
const memberDetails = (
<PowerLevelSection
powerLevels={powerLevels}
user={member}
room={room}
roomPermissions={roomPermissions}
/>
);
// only display the devices list if our client supports E2E
const _enableDevices = cli.isCryptoEnabled();
let text;
if (!isRoomEncrypted) {
if (!_enableDevices) {
text = _t("This client does not support end-to-end encryption.");
} else if (room) {
text = _t("Messages in this room are not end-to-end encrypted.");
} else {
// TODO what to render for GroupMember
}
} else {
text = _t("Messages in this room are end-to-end encrypted.");
}
const userTrust = cli.checkUserTrust(member.userId);
const userVerified = SettingsStore.isFeatureEnabled("feature_cross_signing") ?
userTrust.isCrossSigningVerified() :
userTrust.isVerified();
const isMe = member.userId === cli.getUserId();
let verifyButton;
if (isRoomEncrypted && !userVerified && !isMe) {
verifyButton = (
<AccessibleButton kind="primary" className="mx_UserInfo_verify" onClick={() => verifyUser(member)}>
{_t("Verify")}
</AccessibleButton>
);
}
let devicesSection;
if (isRoomEncrypted) {
devicesSection = <DevicesSection
loading={devices === undefined}
devices={devices}
userId={member.userId} />;
}
const securitySection = (
<div className="mx_UserInfo_container">
<h3>{ _t("Security") }</h3>
<p>{ text }</p>
{ verifyButton }
{ devicesSection }
</div>
);
return <React.Fragment>
{ memberDetails &&
<div className="mx_UserInfo_container mx_UserInfo_separator mx_UserInfo_memberDetailsContainer">
<div className="mx_UserInfo_memberDetails">
{ memberDetails }
</div>
</div> }
{ securitySection }
<UserOptionsSection
devices={devices}
canInvite={roomPermissions.canInvite}
isIgnored={isIgnored}
member={member} />
{ adminToolsContainer }
{ spinner }
</React.Fragment>;
};
const UserInfoHeader = ({onClose, member, e2eStatus}) => {
const cli = useContext(MatrixClientContext);
let closeButton;
if (onClose) {
closeButton = <AccessibleButton className="mx_UserInfo_cancel" onClick={onClose} title={_t('Close')}>
<div />
</AccessibleButton>;
}
const onMemberAvatarClick = useCallback(() => {
const avatarUrl = member.getMxcAvatarUrl ? member.getMxcAvatarUrl() : member.avatarUrl;
if (!avatarUrl) return;
const httpUrl = cli.mxcUrlToHttp(avatarUrl);
const ImageView = sdk.getComponent("elements.ImageView");
const params = {
src: httpUrl,
name: member.name,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
}, [cli, member]);
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const avatarElement = (
<div className="mx_UserInfo_avatar">
<div>
<div>
<MemberAvatar
member={member}
width={2 * 0.3 * window.innerHeight} // 2x@30vh
height={2 * 0.3 * window.innerHeight} // 2x@30vh
resizeMethod="scale"
fallbackUserId={member.userId}
onClick={onMemberAvatarClick}
urls={member.avatarUrl ? [member.avatarUrl] : undefined} />
</div>
</div>
</div>
);
let presenceState;
let presenceLastActiveAgo;
@ -1222,181 +1378,79 @@ const UserInfo = ({user, groupId, roomId, onClose}) => {
statusLabel = <span className="mx_UserInfo_statusMessage">{ statusMessage }</span>;
}
// const avatarUrl = user.getMxcAvatarUrl ? user.getMxcAvatarUrl() : user.avatarUrl;
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const avatarElement = (
<div className="mx_UserInfo_avatar">
<div>
<div>
<MemberAvatar
member={member}
width={2 * 0.3 * window.innerHeight} // 2x@30vh
height={2 * 0.3 * window.innerHeight} // 2x@30vh
resizeMethod="scale"
fallbackUserId={member.userId}
onClick={onMemberAvatarClick}
urls={member.avatarUrl ? [member.avatarUrl] : undefined} />
</div>
</div>
</div>
);
let closeButton;
if (onClose) {
closeButton = <AccessibleButton className="mx_UserInfo_cancel" onClick={onClose} title={_t('Close')}>
<div />
</AccessibleButton>;
}
const memberDetails = (
<PowerLevelSection
powerLevels={powerLevels}
user={member}
room={room}
roomPermissions={roomPermissions}
/>
);
const isRoomEncrypted = useIsEncrypted(cli, room);
// undefined means yet to be loaded, null means failed to load, otherwise list of devices
const [devices, setDevices] = useState(undefined);
// Download device lists
useEffect(() => {
setDevices(undefined);
let cancelled = false;
async function _downloadDeviceList() {
try {
await cli.downloadKeys([user.userId], true);
const devices = await cli.getStoredDevicesForUser(user.userId);
if (cancelled) {
// we got cancelled - presumably a different user now
return;
}
_disambiguateDevices(devices);
setDevices(devices);
} catch (err) {
setDevices(null);
}
}
_downloadDeviceList();
// Handle being unmounted
return () => {
cancelled = true;
};
}, [cli, user.userId]);
// Listen to changes
useEffect(() => {
let cancel = false;
const onDeviceVerificationChanged = (_userId, device) => {
if (_userId === user.userId) {
// no need to re-download the whole thing; just update our copy of the list.
// Promise.resolve to handle transition from static result to promise; can be removed in future
Promise.resolve(cli.getStoredDevicesForUser(user.userId)).then((devices) => {
if (cancel) return;
setDevices(devices);
});
}
};
cli.on("deviceVerificationChanged", onDeviceVerificationChanged);
// Handle being unmounted
return () => {
cancel = true;
cli.removeListener("deviceVerificationChanged", onDeviceVerificationChanged);
};
}, [cli, user.userId]);
let text;
if (!isRoomEncrypted) {
if (!_enableDevices) {
text = _t("This client does not support end-to-end encryption.");
} else if (room) {
text = _t("Messages in this room are not end-to-end encrypted.");
} else {
// TODO what to render for GroupMember
}
} else {
text = _t("Messages in this room are end-to-end encrypted.");
}
const userTrust = cli.checkUserTrust(user.userId);
const userVerified = SettingsStore.isFeatureEnabled("feature_cross_signing") ?
userTrust.isCrossSigningVerified() :
userTrust.isVerified();
const isMe = user.userId === cli.getUserId();
let verifyButton;
if (isRoomEncrypted && !userVerified && !isMe) {
verifyButton = <AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyUser(user)}>
{_t("Verify")}
</AccessibleButton>;
}
let devicesSection;
if (isRoomEncrypted) {
devicesSection = <DevicesSection
loading={devices === undefined}
devices={devices} userId={user.userId} />;
}
const securitySection = (
<div className="mx_UserInfo_container">
<h3>{ _t("Security") }</h3>
<p>{ text }</p>
{ verifyButton }
{ devicesSection }
</div>
);
let e2eIcon;
if (isRoomEncrypted && devices) {
const e2eStatus = _getE2EStatus(cli, user.userId, devices);
if (e2eStatus) {
e2eIcon = <E2EIcon size={18} status={e2eStatus} isUser={true} />;
}
return (
<div className="mx_UserInfo" role="tabpanel">
<AutoHideScrollbar className="mx_UserInfo_scrollContainer">
{ closeButton }
{ avatarElement }
const displayName = member.name || member.displayname;
return <React.Fragment>
{ closeButton }
{ avatarElement }
<div className="mx_UserInfo_container">
<div className="mx_UserInfo_profile">
<div>
<h2 aria-label={displayName}>
{ e2eIcon }
{ displayName }
</h2>
</div>
<div>{ user.userId }</div>
<div className="mx_UserInfo_profileStatus">
{presenceLabel}
{statusLabel}
</div>
</div>
<div className="mx_UserInfo_container mx_UserInfo_separator">
<div className="mx_UserInfo_profile">
<div>
<h2 aria-label={displayName}>
{ e2eIcon }
{ displayName }
</h2>
</div>
<div>{ member.userId }</div>
<div className="mx_UserInfo_profileStatus">
{presenceLabel}
{statusLabel}
</div>
</div>
</div>
</React.Fragment>;
};
{ memberDetails && <div className="mx_UserInfo_container mx_UserInfo_memberDetailsContainer">
<div className="mx_UserInfo_memberDetails">
{ memberDetails }
</div>
</div> }
const UserInfo = ({user, groupId, roomId, onClose, phase=RIGHT_PANEL_PHASES.RoomMemberInfo, ...props}) => {
const cli = useContext(MatrixClientContext);
{ securitySection }
<UserOptionsSection
// Load room if we are given a room id and memoize it
const room = useMemo(() => roomId ? cli.getRoom(roomId) : null, [cli, roomId]);
// fetch latest room member if we have a room, so we don't show historical information, falling back to user
const member = useMemo(() => room ? (room.getMember(user.userId) || user) : user, [room, user]);
const isRoomEncrypted = useIsEncrypted(cli, room);
const devices = useDevices(user.userId);
let e2eStatus;
if (isRoomEncrypted && devices) {
e2eStatus = getE2EStatus(cli, user.userId, devices);
}
const classes = ["mx_UserInfo"];
let content;
switch (phase) {
case RIGHT_PANEL_PHASES.RoomMemberInfo:
case RIGHT_PANEL_PHASES.GroupMemberInfo:
content = (
<BasicUserInfo
room={room}
member={member}
groupId={groupId}
devices={devices}
canInvite={roomPermissions.canInvite}
isIgnored={isIgnored}
member={member} />
isRoomEncrypted={isRoomEncrypted} />
);
break;
case RIGHT_PANEL_PHASES.EncryptionPanel:
classes.push("mx_UserInfo_smallAvatar");
content = (
<EncryptionPanel {...props} member={member} onClose={onClose} />
);
break;
}
{ adminToolsContainer }
return (
<div className={classes.join(" ")} role="tabpanel">
<AutoHideScrollbar className="mx_UserInfo_scrollContainer">
<UserInfoHeader member={member} e2eStatus={e2eStatus} onClose={onClose} />
{ spinner }
{ content }
</AutoHideScrollbar>
</div>
);

View File

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,11 +15,20 @@ limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
import VerificationQRCode from "../elements/crypto/VerificationQRCode";
import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {_t} from "../../../languageHandler";
import E2EIcon from "../rooms/E2EIcon";
import {
PHASE_READY,
PHASE_DONE,
PHASE_STARTED,
PHASE_CANCELLED,
} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import Spinner from "../elements/Spinner";
export default class VerificationPanel extends React.PureComponent {
constructor(props) {
@ -28,65 +37,144 @@ export default class VerificationPanel extends React.PureComponent {
this._hasVerifier = !!props.request.verifier;
}
render() {
return <div className="mx_UserInfo">
renderQRPhase(pending) {
const {member, request} = this.props;
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let button;
if (pending) {
button = <Spinner />;
} else {
button = (
<AccessibleButton kind="primary" className="mx_UserInfo_verify" onClick={this._startSAS}>
{_t("Verify by emoji")}
</AccessibleButton>
);
}
const cli = MatrixClientPeg.get();
const crossSigningInfo = cli.getStoredCrossSigningForUser(request.otherUserId);
if (!crossSigningInfo || !request.requestEvent || !request.requestEvent.getId()) {
// for whatever reason we can't generate a QR code, offer only SAS Verification
return <div className="mx_UserInfo_container">
<h3>Verify by emoji</h3>
<p>{_t("Verify by comparing unique emoji.")}</p>
{ button }
</div>;
}
const myKeyId = cli.getCrossSigningId();
const qrCodeKeys = [
[cli.getDeviceId(), cli.getDeviceEd25519Key()],
[myKeyId, myKeyId],
];
// TODO: add way to open camera to scan a QR code
return <React.Fragment>
<div className="mx_UserInfo_container">
{ this.renderStatus() }
<h3>Verify by scanning</h3>
<p>{_t("Ask %(displayName)s to scan your code:", {
displayName: member.displayName || member.name || member.userId,
})}</p>
<div className="mx_VerificationPanel_qrCode">
<VerificationQRCode
keyholderUserId={MatrixClientPeg.get().getUserId()}
requestEventId={request.requestEvent.getId()}
otherUserKey={crossSigningInfo.getId("master")}
secret={request.encodedSharedSecret}
keys={qrCodeKeys}
/>
</div>
</div>
</div>;
<div className="mx_UserInfo_container">
<h3>Verify by emoji</h3>
<p>{_t("If you can't scan the code above, verify by comparing unique emoji.")}</p>
{ button }
</div>
</React.Fragment>;
}
renderStatus() {
renderVerifiedPhase() {
const {member} = this.props;
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Spinner = sdk.getComponent('elements.Spinner');
const {request: req} = this.props;
const request: VerificationRequest = req;
return (
<div className="mx_UserInfo_container mx_VerificationPanel_verified_section">
<h3>Verified</h3>
<p>{_t("You've successfully verified %(displayName)s!", {
displayName: member.displayName || member.name || member.userId,
})}</p>
<E2EIcon isUser={true} status="verified" size={128} />
<p>Verify all users in a room to ensure it's secure.</p>
if (request.requested) {
return (<p>Waiting for {request.otherUserId} to accept ... <Spinner /></p>);
} else if (request.ready) {
const verifyButton = <AccessibleButton kind="primary" onClick={this._startSAS}>
Verify by emoji
</AccessibleButton>;
<AccessibleButton kind="primary" className="mx_UserInfo_verify" onClick={this.props.onClose}>
{_t("Got it")}
</AccessibleButton>
</div>
);
}
const crossSigningInfo = MatrixClientPeg.get().getStoredCrossSigningForUser(request.otherUserId);
const myKeyId = MatrixClientPeg.get().getCrossSigningId();
if (request.requestEvent && request.requestEvent.getId() && crossSigningInfo) {
const qrCodeKeys = [
[MatrixClientPeg.get().getDeviceId(), MatrixClientPeg.get().getDeviceEd25519Key()],
[myKeyId, myKeyId],
];
const qrCode = <VerificationQRCode
keyholderUserId={MatrixClientPeg.get().getUserId()}
requestEventId={request.requestEvent.getId()}
otherUserKey={crossSigningInfo.getId("master")}
secret={request.encodedSharedSecret}
keys={qrCodeKeys}
/>;
return (<p>{request.otherUserId} is ready, start {verifyButton} or have them scan: {qrCode}</p>);
}
renderCancelledPhase() {
const {member, request} = this.props;
return (<p>{request.otherUserId} is ready, start {verifyButton}</p>);
} else if (request.started) {
if (this.state.sasWaitingForOtherParty) {
return <p>Waiting for {request.otherUserId} to confirm ...</p>;
} else if (this.state.sasEvent) {
const VerificationShowSas = sdk.getComponent('views.verification.VerificationShowSas');
return (<div>
<VerificationShowSas
sas={this.state.sasEvent.sas}
onCancel={this._onSasMismatchesClick}
onDone={this._onSasMatchesClick}
/>
</div>);
} else {
return (<p>Setting up SAS verification...</p>);
}
} else if (request.done) {
return <p>verified {request.otherUserId}!!</p>;
} else if (request.cancelled) {
return <p>cancelled by {request.cancellingUserId}!</p>;
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let text;
if (request.cancellationCode === "m.timeout") {
text = _t("Verification timed out. Start verification again from their profile.");
} else if (request.cancellingUserId === request.otherUserId) {
text = _t("%(displayName)s cancelled verification. Start verification again from their profile.", {
displayName: member.displayName || member.name || member.userId,
});
} else {
text = _t("You cancelled verification. Start verification again from their profile.");
}
return (
<div className="mx_UserInfo_container">
<h3>Verification cancelled</h3>
<p>{ text }</p>
<AccessibleButton kind="primary" className="mx_UserInfo_verify" onClick={this.props.onClose}>
{_t("Got it")}
</AccessibleButton>
</div>
);
}
render() {
const {member} = this.props;
const displayName = member.displayName || member.name || member.userId;
switch (this.props.phase) {
case PHASE_READY:
return this.renderQRPhase();
case PHASE_STARTED:
if (this.state.sasEvent) {
const VerificationShowSas = sdk.getComponent('views.verification.VerificationShowSas');
return <div className="mx_UserInfo_container">
<h3>Compare emoji</h3>
<VerificationShowSas
displayName={displayName}
sas={this.state.sasEvent.sas}
onCancel={this._onSasMismatchesClick}
onDone={this._onSasMatchesClick}
/>
</div>;
} else {
return this.renderQRPhase(true); // keep showing same phase but with a spinner
}
case PHASE_DONE:
return this.renderVerifiedPhase();
case PHASE_CANCELLED:
return this.renderCancelledPhase();
}
return null;
}
_startSAS = async () => {
@ -95,18 +183,15 @@ export default class VerificationPanel extends React.PureComponent {
await verifier.verify();
} catch (err) {
console.error(err);
} finally {
this.setState({sasEvent: null});
}
};
_onSasMatchesClick = () => {
this.setState({sasWaitingForOtherParty: true});
this.state.sasEvent.confirm();
};
_onSasMismatchesClick = () => {
this.state.sasEvent.cancel();
this.state.sasEvent.mismatch();
};
_onVerifierShowSas = (sasEvent) => {
@ -128,7 +213,6 @@ export default class VerificationPanel extends React.PureComponent {
request.verifier.removeListener('show_sas', this._onVerifierShowSas);
}
this._hasVerifier = !!request.verifier;
this.forceUpdate();
};
componentDidMount() {

View File

@ -82,10 +82,14 @@ export default class VerificationRequestToast extends React.PureComponent {
should_peek: false,
});
await request.accept();
const cli = MatrixClientPeg.get();
dis.dispatch({
action: "set_right_panel_phase",
phase: RIGHT_PANEL_PHASES.EncryptionPanel,
refireParams: {verificationRequest: request},
refireParams: {
verificationRequest: request,
member: cli.getUser(request.otherUserId),
},
});
} else if (request.channel.deviceId && request.verifier) {
// show to_device verifications in dialog still

View File

@ -16,8 +16,10 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import {PendingActionSpinner} from "../right_panel/EncryptionInfo";
import AccessibleButton from "../elements/AccessibleButton";
import DialogButtons from "../elements/DialogButtons";
function capFirst(s) {
return s.charAt(0).toUpperCase() + s.slice(1);
@ -25,18 +27,26 @@ function capFirst(s) {
export default class VerificationShowSas extends React.Component {
static propTypes = {
displayName: PropTypes.string.isRequired,
onDone: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
sas: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = {
pending: false,
};
}
constructor() {
super();
}
onMatchClick = () => {
this.setState({ pending: true });
this.props.onDone();
};
render() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let sasDisplay;
let sasCaption;
if (this.props.sas.emoji) {
@ -69,26 +79,34 @@ export default class VerificationShowSas extends React.Component {
} else {
return <div>
{_t("Unable to find a supported verification method.")}
<DialogButtons
primaryButton={_t('Cancel')}
hasCancel={false}
onPrimaryButtonClick={this.props.onCancel}
/>
<AccessibleButton kind="primary" onClick={this.props.onCancel} className="mx_UserInfo_verify">
{_t('Cancel')}
</AccessibleButton>
</div>;
}
let confirm;
if (this.state.pending) {
const {displayName} = this.props;
const text = _t("Waiting for %(displayName)s to verify…", {displayName});
confirm = <PendingActionSpinner text={text} />;
} else {
// FIXME: stop using DialogButtons here once this component is only used in the right panel verification
confirm = <DialogButtons
primaryButton={_t("They match")}
onPrimaryButtonClick={this.onMatchClick}
primaryButtonClassName="mx_UserInfo_verify"
cancelButton={_t("They don't match")}
onCancel={this.props.onCancel}
cancelButtonClass="mx_UserInfo_verify"
/>;
}
return <div className="mx_VerificationShowSas">
<p>{sasCaption}</p>
<p>{_t(
"For maximum security, we recommend you do this in person or use another " +
"trusted means of communication.",
)}</p>
<p>{_t("For ultimate security, do this in person or use another way to communicate.")}</p>
{sasDisplay}
<DialogButtons onPrimaryButtonClick={this.props.onDone}
primaryButton={_t("Continue")}
hasCancel={true}
onCancel={this.props.onCancel}
/>
{confirm}
</div>;
}
}

View File

@ -448,7 +448,10 @@
"Verify this user by confirming the following number appears on their screen.": "Verify this user by confirming the following number appears on their screen.",
"Unable to find a supported verification method.": "Unable to find a supported verification method.",
"Cancel": "Cancel",
"For maximum security, we recommend you do this in person or use another trusted means of communication.": "For maximum security, we recommend you do this in person or use another trusted means of communication.",
"Waiting for %(displayName)s to verify…": "Waiting for %(displayName)s to verify…",
"They match": "They match",
"They don't match": "They don't match",
"For ultimate security, do this in person or use another way to communicate.": "For ultimate security, do this in person or use another way to communicate.",
"Dog": "Dog",
"Cat": "Cat",
"Lion": "Lion",
@ -1141,10 +1144,19 @@
"URL previews are disabled by default for participants in this room.": "URL previews are disabled by default for participants in this room.",
"In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.",
"When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.",
"Waiting for %(displayName)s to accept…": "Waiting for %(displayName)s to accept…",
"Start Verification": "Start Verification",
"Messages in this room are end-to-end encrypted.": "Messages in this room are end-to-end encrypted.",
"Your messages are secured and only you and the recipient have the unique keys to unlock them.": "Your messages are secured and only you and the recipient have the unique keys to unlock them.",
"Verify User": "Verify User",
"For extra security, verify this user by checking a one-time code on both of your devices.": "For extra security, verify this user by checking a one-time code on both of your devices.",
"For maximum security, do this in person.": "For maximum security, do this in person.",
"Start Verification": "Start Verification",
"Your messages are not secure": "Your messages are not secure",
"One of the following may be compromised:": "One of the following may be compromised:",
"Your homeserver": "Your homeserver",
"The homeserver the user youre verifying is connected to": "The homeserver the user youre verifying is connected to",
"Yours, or the other users internet connection": "Yours, or the other users internet connection",
"Yours, or the other users device": "Yours, or the other users device",
"Members": "Members",
"Files": "Files",
"Trusted": "Trusted",
@ -1161,8 +1173,16 @@
"<strong>%(role)s</strong> in %(roomName)s": "<strong>%(role)s</strong> in %(roomName)s",
"This client does not support end-to-end encryption.": "This client does not support end-to-end encryption.",
"Messages in this room are not end-to-end encrypted.": "Messages in this room are not end-to-end encrypted.",
"Messages in this room are end-to-end encrypted.": "Messages in this room are end-to-end encrypted.",
"Security": "Security",
"Verify by emoji": "Verify by emoji",
"Verify by comparing unique emoji.": "Verify by comparing unique emoji.",
"Ask %(displayName)s to scan your code:": "Ask %(displayName)s to scan your code:",
"If you can't scan the code above, verify by comparing unique emoji.": "If you can't scan the code above, verify by comparing unique emoji.",
"You've successfully verified %(displayName)s!": "You've successfully verified %(displayName)s!",
"Got it": "Got it",
"Verification timed out. Start verification again from their profile.": "Verification timed out. Start verification again from their profile.",
"%(displayName)s cancelled verification. Start verification again from their profile.": "%(displayName)s cancelled verification. Start verification again from their profile.",
"You cancelled verification. Start verification again from their profile.": "You cancelled verification. Start verification again from their profile.",
"Sunday": "Sunday",
"Monday": "Monday",
"Tuesday": "Tuesday",
@ -1442,6 +1462,7 @@
"Verify device": "Verify device",
"Use Legacy Verification (for older clients)": "Use Legacy Verification (for older clients)",
"Verify by comparing a short text string.": "Verify by comparing a short text string.",
"For maximum security, we recommend you do this in person or use another trusted means of communication.": "For maximum security, we recommend you do this in person or use another trusted means of communication.",
"Begin Verifying": "Begin Verifying",
"Waiting for partner to accept...": "Waiting for partner to accept...",
"Nothing appearing? Not all clients support interactive verification yet. <button>Use legacy verification</button>.": "Nothing appearing? Not all clients support interactive verification yet. <button>Use legacy verification</button>.",