Merge branch 'develop' into foldleft/better-errors

This commit is contained in:
Zoe 2020-03-26 13:38:50 +00:00
commit 9c392ce8bb
193 changed files with 9547 additions and 4493 deletions

View file

@ -90,7 +90,8 @@ export default createReactClass({
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', '');
if (!pinnedEvent) return false;
return pinnedEvent.getContent().pinned.includes(this.props.mxEvent.getId());
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
},
onResendClick: function() {

View file

@ -72,7 +72,7 @@ export default createReactClass({
<button onClick={this._onInviteNeverWarnClicked}>
{ _t('Invite anyway and never warn me again') }
</button>
<button onClick={this._onInviteClicked} autoFocus="true">
<button onClick={this._onInviteClicked} autoFocus={true}>
{ _t('Invite anyway') }
</button>
</div>

View file

@ -32,6 +32,7 @@ export default createReactClass({
button: PropTypes.string,
onFinished: PropTypes.func,
hasCloseButton: PropTypes.bool,
onKeyDown: PropTypes.func,
},
getDefaultProps: function() {
@ -50,10 +51,13 @@ export default createReactClass({
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished}
<BaseDialog
className="mx_InfoDialog"
onFinished={this.props.onFinished}
title={this.props.title}
contentId='mx_Dialog_content'
hasCancel={this.props.hasCloseButton}
onKeyDown={this.props.onKeyDown}
>
<div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content">
{ this.props.description }

View file

@ -34,6 +34,8 @@ import {humanizeTime} from "../../../utils/humanize";
import createRoom, {canEncryptToAllUsers} from "../../../createRoom";
import {inviteMultipleToRoom} from "../../../RoomInvite";
import SettingsStore from '../../../settings/SettingsStore';
import RoomListStore, {TAG_DM} from "../../../stores/RoomListStore";
import {Key} from "../../../Keyboard";
export const KIND_DM = "dm";
export const KIND_INVITE = "invite";
@ -124,7 +126,7 @@ class ThreepidMember extends Member {
class DMUserTile extends React.PureComponent {
static propTypes = {
member: PropTypes.object.isRequired, // Should be a Member (see interface above)
onRemove: PropTypes.func.isRequired, // takes 1 argument, the member being removed
onRemove: PropTypes.func, // takes 1 argument, the member being removed
};
_onRemove = (e) => {
@ -155,18 +157,25 @@ class DMUserTile extends React.PureComponent {
width={avatarSize}
height={avatarSize} />;
return (
<span className='mx_InviteDialog_userTile'>
<span className='mx_InviteDialog_userTile_pill'>
{avatar}
<span className='mx_InviteDialog_userTile_name'>{this.props.member.name}</span>
</span>
let closeButton;
if (this.props.onRemove) {
closeButton = (
<AccessibleButton
className='mx_InviteDialog_userTile_remove'
onClick={this._onRemove}
>
<img src={require("../../../../res/img/icon-pill-remove.svg")} alt={_t('Remove')} width={8} height={8} />
</AccessibleButton>
);
}
return (
<span className='mx_InviteDialog_userTile'>
<span className='mx_InviteDialog_userTile_pill'>
{avatar}
<span className='mx_InviteDialog_userTile_name'>{this.props.member.name}</span>
</span>
{ closeButton }
</span>
);
}
@ -218,7 +227,7 @@ class DMRoomTile extends React.PureComponent {
}
// Push any text we missed (end of text)
if (i < (str.length - 1)) {
if (i < str.length) {
result.push(<span key={i + 'end'}>{str.substring(i)}</span>);
}
@ -332,7 +341,23 @@ export default class InviteDialog extends React.PureComponent {
}
_buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals();
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
// Also pull in all the rooms tagged as TAG_DM so we don't miss anything. Sometimes the
// room list doesn't tag the room for the DMRoomMap, but does for the room list.
const taggedRooms = RoomListStore.getRoomLists();
const dmTaggedRooms = taggedRooms[TAG_DM];
const myUserId = MatrixClientPeg.get().getUserId();
for (const dmRoom of dmTaggedRooms) {
const otherMembers = dmRoom.getJoinedMembers().filter(u => u.userId !== myUserId);
for (const member of otherMembers) {
if (rooms[member.userId]) continue; // already have a room
console.warn(`Adding DM room for ${member.userId} as ${dmRoom.roomId} from tag, not DM map`);
rooms[member.userId] = dmRoom;
}
}
const recents = [];
for (const userId in rooms) {
// Filter out user IDs that are already in the room / should be excluded
@ -547,7 +572,7 @@ export default class InviteDialog extends React.PureComponent {
return;
}
const createRoomOptions = {};
const createRoomOptions = {inlineErrors: true};
if (SettingsStore.isFeatureEnabled("feature_cross_signing")) {
// Check whether all users have uploaded device keys before.
@ -623,11 +648,14 @@ export default class InviteDialog extends React.PureComponent {
});
};
_cancel = () => {
// We do not want the user to close the dialog while an action is in progress
if (this.state.busy) return;
this.props.onFinished();
_onKeyDown = (e) => {
// when the field is empty and the user hits backspace remove the right-most target
if (!e.target.value && !this.state.busy && this.state.targets.length > 0 && e.key === Key.BACKSPACE &&
!e.ctrlKey && !e.shiftKey && !e.metaKey
) {
e.preventDefault();
this._removeMember(this.state.targets[this.state.targets.length - 1]);
}
};
_updateFilter = (e) => {
@ -872,7 +900,7 @@ export default class InviteDialog extends React.PureComponent {
_onManageSettingsClick = (e) => {
e.preventDefault();
dis.dispatch({ action: 'view_user_settings' });
this._cancel();
this.props.onFinished();
};
_renderSection(kind: "recents"|"suggestions") {
@ -889,24 +917,24 @@ export default class InviteDialog extends React.PureComponent {
// Mix in the server results if we have any, but only if we're searching. We track the additional
// members separately because we want to filter sourceMembers but trust the mixin arrays to have
// the right members in them.
let additionalMembers = [];
let priorityAdditionalMembers = []; // Shows up before our own suggestions, higher quality
let otherAdditionalMembers = []; // Shows up after our own suggestions, lower quality
const hasMixins = this.state.serverResultsMixin || this.state.threepidResultsMixin;
if (this.state.filterText && hasMixins && kind === 'suggestions') {
// We don't want to duplicate members though, so just exclude anyone we've already seen.
const notAlreadyExists = (u: Member): boolean => {
return !sourceMembers.some(m => m.userId === u.userId)
&& !additionalMembers.some(m => m.userId === u.userId);
&& !priorityAdditionalMembers.some(m => m.userId === u.userId)
&& !otherAdditionalMembers.some(m => m.userId === u.userId);
};
const uniqueServerResults = this.state.serverResultsMixin.filter(notAlreadyExists);
additionalMembers = additionalMembers.concat(...uniqueServerResults);
const uniqueThreepidResults = this.state.threepidResultsMixin.filter(notAlreadyExists);
additionalMembers = additionalMembers.concat(...uniqueThreepidResults);
otherAdditionalMembers = this.state.serverResultsMixin.filter(notAlreadyExists);
priorityAdditionalMembers = this.state.threepidResultsMixin.filter(notAlreadyExists);
}
const hasAdditionalMembers = priorityAdditionalMembers.length > 0 || otherAdditionalMembers.length > 0;
// Hide the section if there's nothing to filter by
if (sourceMembers.length === 0 && additionalMembers.length === 0) return null;
if (sourceMembers.length === 0 && !hasAdditionalMembers) return null;
// Do some simple filtering on the input before going much further. If we get no results, say so.
if (this.state.filterText) {
@ -914,7 +942,7 @@ export default class InviteDialog extends React.PureComponent {
sourceMembers = sourceMembers
.filter(m => m.user.name.toLowerCase().includes(filterBy) || m.userId.toLowerCase().includes(filterBy));
if (sourceMembers.length === 0 && additionalMembers.length === 0) {
if (sourceMembers.length === 0 && !hasAdditionalMembers) {
return (
<div className='mx_InviteDialog_section'>
<h3>{sectionName}</h3>
@ -926,7 +954,7 @@ export default class InviteDialog extends React.PureComponent {
// Now we mix in the additional members. Again, we presume these have already been filtered. We
// also assume they are more relevant than our suggestions and prepend them to the list.
sourceMembers = [...additionalMembers, ...sourceMembers];
sourceMembers = [...priorityAdditionalMembers, ...sourceMembers, ...otherAdditionalMembers];
// If we're going to hide one member behind 'show more', just use up the space of the button
// with the member's tile instead.
@ -967,17 +995,18 @@ export default class InviteDialog extends React.PureComponent {
_renderEditor() {
const targets = this.state.targets.map(t => (
<DMUserTile member={t} onRemove={this._removeMember} key={t.userId} />
<DMUserTile member={t} onRemove={!this.state.busy && this._removeMember} key={t.userId} />
));
const input = (
<textarea
key={"input"}
rows={1}
onKeyDown={this._onKeyDown}
onChange={this._updateFilter}
value={this.state.filterText}
ref={this._editorRef}
onPaste={this._onPaste}
autoFocus={true}
disabled={this.state.busy}
/>
);
return (
@ -1043,10 +1072,11 @@ export default class InviteDialog extends React.PureComponent {
title = _t("Direct Messages");
helpText = _t(
"If you can't find someone, ask them for their username, share your " +
"username (%(userId)s) or <a>profile link</a>.",
{userId},
{a: (sub) => <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{sub}</a>},
"Start a conversation with someone using their name, username (like <userId/>) or email address.",
{},
{userId: () => {
return <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{userId}</a>;
}},
);
buttonText = _t("Go");
goButtonFn = this._startDm;
@ -1070,7 +1100,7 @@ export default class InviteDialog extends React.PureComponent {
<BaseDialog
className='mx_InviteDialog'
hasCancel={true}
onFinished={this._cancel}
onFinished={this.props.onFinished}
title={title}
>
<div className='mx_InviteDialog_content'>

View file

@ -0,0 +1,108 @@
/*
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.
*/
import React, {useState, useCallback, useRef} from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
export default function KeySignatureUploadFailedDialog({
failures,
source,
continuation,
onFinished,
}) {
const RETRIES = 2;
const BaseDialog = sdk.getComponent('dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Spinner = sdk.getComponent('elements.Spinner');
const [retry, setRetry] = useState(RETRIES);
const [cancelled, setCancelled] = useState(false);
const [retrying, setRetrying] = useState(false);
const [success, setSuccess] = useState(false);
const onCancel = useRef(onFinished);
const causes = new Map([
["_afterCrossSigningLocalKeyChange", _t("a new master key signature")],
["checkOwnCrossSigningTrust", _t("a new cross-signing key signature")],
["setDeviceVerification", _t("a device cross-signing signature")],
]);
const defaultCause = _t("a key signature");
const onRetry = useCallback(async () => {
try {
setRetrying(true);
const cancel = new Promise((resolve, reject) => {
onCancel.current = reject;
}).finally(() => {
setCancelled(true);
});
await Promise.race([
continuation(),
cancel,
]);
setSuccess(true);
} catch (e) {
setRetry(r => r-1);
} finally {
onCancel.current = onFinished;
setRetrying(false);
}
}, [continuation, onFinished]);
let body;
if (!success && !cancelled && continuation && retry > 0) {
const reason = causes.get(source) || defaultCause;
body = (<div>
<p>{_t("Riot encountered an error during upload of:")}</p>
<p>{reason}</p>
{retrying && <Spinner />}
<pre>{JSON.stringify(failures, null, 2)}</pre>
<DialogButtons
primaryButton='Retry'
hasCancel={true}
onPrimaryButtonClick={onRetry}
onCancel={onCancel.current}
primaryDisabled={retrying}
/>
</div>);
} else {
body = (<div>
{success ?
<span>{_t("Upload completed")}</span> :
cancelled ?
<span>{_t("Cancelled signature upload")}</span> :
<span>{_t("Unabled to upload")}</span>}
<DialogButtons
primaryButton={_t("OK")}
hasCancel={false}
onPrimaryButtonClick={onFinished}
/>
</div>);
}
return (
<BaseDialog
title={success ?
_t("Signature upload success") :
_t("Signature upload failed")}
fixedWidth={false}
onFinished={() => {}}
>
{body}
</BaseDialog>
);
}

View file

@ -21,7 +21,6 @@ import * as sdk from '../../../index';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import SettingsStore from "../../../settings/SettingsStore";
export default class LogoutDialog extends React.Component {
defaultProps = {
@ -36,8 +35,8 @@ export default class LogoutDialog extends React.Component {
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
const lowBandwidth = SettingsStore.getValue("lowBandwidth");
const shouldLoadBackupStatus = !lowBandwidth && !MatrixClientPeg.get().getKeyBackupEnabled();
const cli = MatrixClientPeg.get();
const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled();
this.state = {
shouldLoadBackupStatus: shouldLoadBackupStatus,

View file

@ -0,0 +1,86 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import { _t } from '../../../languageHandler';
export default class ManualDeviceKeyVerificationDialog extends React.Component {
static propTypes = {
userId: PropTypes.string.isRequired,
device: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
};
_onCancelClick = () => {
this.props.onFinished(false);
}
_onLegacyFinished = (confirm) => {
if (confirm) {
MatrixClientPeg.get().setDeviceVerified(
this.props.userId, this.props.device.deviceId, true,
);
}
this.props.onFinished(confirm);
}
render() {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
let text;
if (MatrixClientPeg.get().getUserId() === this.props.userId) {
text = _t("Confirm by comparing the following with the User Settings in your other session:");
} else {
text = _t("Confirm this user's session by comparing the following with their User Settings:");
}
const key = FormattingUtils.formatCryptoKey(this.props.device.getFingerprint());
const body = (
<div>
<p>
{ text }
</p>
<div className="mx_DeviceVerifyDialog_cryptoSection">
<ul>
<li><label>{ _t("Session name") }:</label> <span>{ this.props.device.getDisplayName() }</span></li>
<li><label>{ _t("Session ID") }:</label> <span><code>{ this.props.device.deviceId }</code></span></li>
<li><label>{ _t("Session key") }:</label> <span><code><b>{ key }</b></code></span></li>
</ul>
</div>
<p>
{ _t("If they don't match, the security of your communication may be compromised.") }
</p>
</div>
);
return (
<QuestionDialog
title={_t("Verify session")}
description={body}
button={_t("Verify session")}
onFinished={this._onLegacyFinished}
/>
);
}
}

View file

@ -23,6 +23,7 @@ import VerificationRequestDialog from './VerificationRequestDialog';
import BaseDialog from './BaseDialog';
import DialogButtons from '../elements/DialogButtons';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from '../../../index';
@replaceableComponent("views.dialogs.NewSessionReviewDialog")
export default class NewSessionReviewDialog extends React.PureComponent {
@ -33,20 +34,38 @@ export default class NewSessionReviewDialog extends React.PureComponent {
}
onCancelClick = () => {
this.props.onFinished(false);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog("Verification failed", "insecure", ErrorDialog, {
headerImage: require("../../../../res/img/e2e/warning.svg"),
title: _t("Your account is not secure"),
description: <div>
{_t("One of the following may be compromised:")}
<ul>
<li>{_t("Your password")}</li>
<li>{_t("Your homeserver")}</li>
<li>{_t("This session, or the other session")}</li>
<li>{_t("The internet connection either session is using")}</li>
</ul>
<div>
{_t("We recommend you change your password and recovery key in Settings immediately")}
</div>
</div>,
onFinished: () => this.props.onFinished(false),
});
}
onContinueClick = async () => {
onContinueClick = () => {
const { userId, device } = this.props;
const cli = MatrixClientPeg.get();
const request = await cli.requestVerification(
const requestPromise = cli.requestVerification(
userId,
[device.deviceId],
);
this.props.onFinished(true);
Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, {
verificationRequest: request,
verificationRequestPromise: requestPromise,
member: cli.getUser(userId),
});
}

View file

@ -32,6 +32,8 @@ export default createReactClass({
focus: PropTypes.bool,
onFinished: PropTypes.func.isRequired,
headerImage: PropTypes.string,
quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x].
fixedWidth: PropTypes.bool,
},
getDefaultProps: function() {
@ -42,6 +44,7 @@ export default createReactClass({
focus: true,
hasCancelButton: true,
danger: false,
quitOnly: false,
};
},
@ -61,11 +64,14 @@ export default createReactClass({
primaryButtonClass = "danger";
}
return (
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished}
<BaseDialog
className="mx_QuestionDialog"
onFinished={this.props.onFinished}
title={this.props.title}
contentId='mx_Dialog_content'
headerImage={this.props.headerImage}
hasCancel={this.props.hasCancelButton}
fixedWidth={this.props.fixedWidth}
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ this.props.description }
@ -73,7 +79,7 @@ export default createReactClass({
<DialogButtons primaryButton={this.props.button || _t('OK')}
primaryButtonClass={primaryButtonClass}
cancelButton={this.props.cancelButton}
hasCancel={this.props.hasCancelButton}
hasCancel={this.props.hasCancelButton && !this.props.quitOnly}
onPrimaryButtonClick={this.onOk}
focus={this.props.focus}
onCancel={this.onCancel}

View file

@ -0,0 +1,29 @@
/*
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.
*/
import SetupEncryptionBody from '../../structures/auth/SetupEncryptionBody';
import BaseDialog from './BaseDialog';
import { _t } from '../../../languageHandler';
export default function SetupEncryptionDialog({onFinished}) {
return <BaseDialog
headerImage={require("../../../../res/img/e2e/warning.svg")}
onFinished={onFinished}
title={_t("Verify this session")}
>
<SetupEncryptionBody onFinished={onFinished} />
</BaseDialog>;
}

View file

@ -18,6 +18,7 @@ import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import Field from "../elements/Field";
export default createReactClass({
displayName: 'TextInputDialog',
@ -28,9 +29,13 @@ export default createReactClass({
PropTypes.string,
]),
value: PropTypes.string,
placeholder: PropTypes.string,
button: PropTypes.string,
focus: PropTypes.bool,
onFinished: PropTypes.func.isRequired,
hasCancel: PropTypes.bool,
validator: PropTypes.func, // result of withValidation
fixedWidth: PropTypes.bool,
},
getDefaultProps: function() {
@ -39,34 +44,70 @@ export default createReactClass({
value: "",
description: "",
focus: true,
hasCancel: true,
};
},
getInitialState: function() {
return {
value: this.props.value,
valid: false,
};
},
UNSAFE_componentWillMount: function() {
this._textinput = createRef();
this._field = createRef();
},
componentDidMount: function() {
if (this.props.focus) {
// Set the cursor at the end of the text input
this._textinput.current.value = this.props.value;
// this._field.current.value = this.props.value;
this._field.current.focus();
}
},
onOk: function() {
this.props.onFinished(true, this._textinput.current.value);
onOk: async function(ev) {
ev.preventDefault();
if (this.props.validator) {
await this._field.current.validate({ allowEmpty: false });
if (!this._field.current.state.valid) {
this._field.current.focus();
this._field.current.validate({ allowEmpty: false, focused: true });
return;
}
}
this.props.onFinished(true, this.state.value);
},
onCancel: function() {
this.props.onFinished(false);
},
onChange: function(ev) {
this.setState({
value: ev.target.value,
});
},
onValidate: async function(fieldState) {
const result = await this.props.validator(fieldState);
this.setState({
valid: result.valid,
});
return result;
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished}
<BaseDialog
className="mx_TextInputDialog"
onFinished={this.props.onFinished}
title={this.props.title}
fixedWidth={this.props.fixedWidth}
>
<form onSubmit={this.onOk}>
<div className="mx_Dialog_content">
@ -74,19 +115,26 @@ export default createReactClass({
<label htmlFor="textinput"> { this.props.description } </label>
</div>
<div>
<input
id="textinput"
ref={this._textinput}
<Field
id="mx_TextInputDialog_field"
className="mx_TextInputDialog_input"
defaultValue={this.props.value}
autoFocus={this.props.focus}
size="64" />
ref={this._field}
type="text"
label={this.props.placeholder}
value={this.state.value}
onChange={this.onChange}
onValidate={this.props.validator ? this.onValidate : undefined}
size="64"
/>
</div>
</div>
</form>
<DialogButtons primaryButton={this.props.button}
<DialogButtons
primaryButton={this.props.button}
onPrimaryButtonClick={this.onOk}
onCancel={this.onCancel} />
onCancel={this.onCancel}
hasCancel={this.props.hasCancel}
/>
</BaseDialog>
);
},

View file

@ -122,7 +122,6 @@ export default createReactClass({
},
render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.props.devices === null) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
@ -168,7 +167,7 @@ export default createReactClass({
title={_t('Room contains unknown sessions')}
contentId='mx_Dialog_content'
>
<GeminiScrollbarWrapper autoshow={false} className="mx_Dialog_content" id='mx_Dialog_content'>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<h4>
{ _t('"%(RoomName)s" contains sessions that you haven\'t seen before.', {RoomName: this.props.room.name}) }
</h4>
@ -176,7 +175,7 @@ export default createReactClass({
{ _t("Unknown sessions") }:
<UnknownDeviceList devices={this.props.devices} />
</GeminiScrollbarWrapper>
</div>
<DialogButtons primaryButton={sendButtonLabel}
onPrimaryButtonClick={sendButtonOnClick}
onCancel={this._onDismissClicked} />

View file

@ -22,7 +22,8 @@ import { _t } from '../../../languageHandler';
export default class VerificationRequestDialog extends React.Component {
static propTypes = {
verificationRequest: PropTypes.object.isRequired,
verificationRequest: PropTypes.object,
verificationRequestPromise: PropTypes.object,
onFinished: PropTypes.func.isRequired,
};
@ -34,6 +35,8 @@ export default class VerificationRequestDialog extends React.Component {
render() {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
const member = this.props.member ||
MatrixClientPeg.get().getUser(this.props.verificationRequest.otherUserId);
return <BaseDialog className="mx_InfoDialog" onFinished={this.onFinished}
contentId="mx_Dialog_content"
title={_t("Verification Request")}
@ -42,14 +45,19 @@ export default class VerificationRequestDialog extends React.Component {
<EncryptionPanel
layout="dialog"
verificationRequest={this.props.verificationRequest}
verificationRequestPromise={this.props.verificationRequestPromise}
onClose={this.props.onFinished}
member={MatrixClientPeg.get().getUser(this.props.verificationRequest.otherUserId)}
member={member}
/>
</BaseDialog>;
}
onFinished() {
this.props.verificationRequest.cancel();
async onFinished() {
this.props.onFinished();
let request = this.props.verificationRequest;
if (!request && this.props.verificationRequestPromise) {
request = await this.props.verificationRequestPromise;
}
request.cancel();
}
}

View file

@ -36,6 +36,9 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
// if false, will close the dialog as soon as the restore completes succesfully
// default: true
showSummary: PropTypes.bool,
// If specified, gather the key from the user but then call the function with the backup
// key rather than actually (necessarily) restoring the backup.
keyCallback: PropTypes.func,
};
static defaultProps = {
@ -103,9 +106,18 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
restoreType: RESTORE_TYPE_PASSPHRASE,
});
try {
// We do still restore the key backup: we must ensure that the key backup key
// is the right one and restoring it is currently the only way we can do this.
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword(
this.state.passPhrase, undefined, undefined, this.state.backupInfo,
);
if (this.props.keyCallback) {
const key = await MatrixClientPeg.get().keyBackupKeyFromPassword(
this.state.passPhrase, this.state.backupInfo,
);
this.props.keyCallback(key);
}
if (!this.props.showSummary) {
this.props.onFinished(true);
return;
@ -135,6 +147,10 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey(
this.state.recoveryKey, undefined, undefined, this.state.backupInfo,
);
if (this.props.keyCallback) {
const key = MatrixClientPeg.get().keyBackupKeyFromRecoveryKey(this.state.recoveryKey);
this.props.keyCallback(key);
}
if (!this.props.showSummary) {
this.props.onFinished(true);
return;
@ -184,6 +200,24 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
}
}
async _restoreWithCachedKey(backupInfo) {
if (!backupInfo) return false;
try {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithCache(
undefined, /* targetRoomId */
undefined, /* targetSessionId */
backupInfo,
);
this.setState({
recoverInfo,
});
return true;
} catch (e) {
console.log("restoreWithCachedKey failed:", e);
return false;
}
}
async _loadBackupStatus() {
this.setState({
loading: true,
@ -197,6 +231,15 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
backupKeyStored,
});
const gotCache = await this._restoreWithCachedKey(backupInfo);
if (gotCache) {
console.log("RestoreKeyBackupDialog: found cached backup key");
this.setState({
loading: false,
});
return;
}
// If the backup key is stored, we can proceed directly to restore.
if (backupKeyStored) {
return this._restoreWithSecretStorage();

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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.
@ -15,241 +16,275 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {useEffect, useState} from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {instanceForInstanceId} from '../../../utils/DirectoryUtils';
import {
ContextMenu,
useContextMenu,
ContextMenuButton,
MenuItemRadio,
MenuItem,
MenuGroup,
} from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import {useSettingValue} from "../../../hooks/useSettings";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import SettingsStore from "../../../settings/SettingsStore";
import withValidation from "../elements/Validation";
const DEFAULT_ICON_URL = require("../../../../res/img/network-matrix.svg");
export const ALL_ROOMS = Symbol("ALL_ROOMS");
export default class NetworkDropdown extends React.Component {
constructor(props) {
super(props);
const SETTING_NAME = "room_directory_servers";
this.dropdownRootElement = null;
this.ignoreEvent = null;
const inPlaceOf = (elementRect) => ({
right: window.innerWidth - elementRect.right,
top: elementRect.top,
chevronOffset: 0,
chevronFace: "none",
});
this.onInputClick = this.onInputClick.bind(this);
this.onRootClick = this.onRootClick.bind(this);
this.onDocumentClick = this.onDocumentClick.bind(this);
this.onMenuOptionClick = this.onMenuOptionClick.bind(this);
this.onInputKeyUp = this.onInputKeyUp.bind(this);
this.collectRoot = this.collectRoot.bind(this);
this.collectInputTextBox = this.collectInputTextBox.bind(this);
const validServer = withValidation({
rules: [
{
key: "required",
test: async ({ value }) => !!value,
invalid: () => _t("Enter a server name"),
}, {
key: "available",
final: true,
test: async ({ value }) => {
try {
const opts = {
limit: 1,
server: value,
};
// check if we can successfully load this server's room directory
await MatrixClientPeg.get().publicRooms(opts);
return true;
} catch (e) {
return false;
}
},
valid: () => _t("Looks good"),
invalid: () => _t("Can't find this server or its room list"),
},
],
});
this.inputTextBox = null;
// This dropdown sources homeservers from three places:
// + your currently connected homeserver
// + homeservers in config.json["roomDirectory"]
// + homeservers in SettingsStore["room_directory_servers"]
// if a server exists in multiple, only keep the top-most entry.
const server = MatrixClientPeg.getHomeserverName();
this.state = {
expanded: false,
selectedServer: server,
selectedInstanceId: null,
includeAllNetworks: false,
const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, selectedInstanceId}) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
const _userDefinedServers = useSettingValue(SETTING_NAME);
const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers);
const handlerFactory = (server, instanceId) => {
return () => {
onOptionChange(server, instanceId);
closeMenu();
};
}
};
componentWillMount() {
// Listen for all clicks on the document so we can close the
// menu when the user clicks somewhere else
document.addEventListener('click', this.onDocumentClick, false);
const setUserDefinedServers = servers => {
_setUserDefinedServers(servers);
SettingsStore.setValue(SETTING_NAME, null, "account", servers);
};
// keep local echo up to date with external changes
useEffect(() => {
_setUserDefinedServers(_userDefinedServers);
}, [_userDefinedServers]);
// fire this now so the defaults can be set up
const {selectedServer, selectedInstanceId, includeAllNetworks} = this.state;
this.props.onOptionChange(selectedServer, selectedInstanceId, includeAllNetworks);
}
// we either show the button or the dropdown in its place.
let content;
if (menuDisplayed) {
const config = SdkConfig.get();
const roomDirectory = config.roomDirectory || {};
componentWillUnmount() {
document.removeEventListener('click', this.onDocumentClick, false);
}
const hsName = MatrixClientPeg.getHomeserverName();
const configServers = new Set(roomDirectory.servers);
componentDidUpdate() {
if (this.state.expanded && this.inputTextBox) {
this.inputTextBox.focus();
}
}
onDocumentClick(ev) {
// Close the dropdown if the user clicks anywhere that isn't
// within our root element
if (ev !== this.ignoreEvent) {
this.setState({
expanded: false,
});
}
}
onRootClick(ev) {
// This captures any clicks that happen within our elements,
// such that we can then ignore them when they're seen by the
// click listener on the document handler, ie. not close the
// dropdown immediately after opening it.
// NB. We can't just stopPropagation() because then the event
// doesn't reach the React onClick().
this.ignoreEvent = ev;
}
onInputClick(ev) {
this.setState({
expanded: !this.state.expanded,
});
ev.preventDefault();
}
onMenuOptionClick(server, instance, includeAll) {
this.setState({
expanded: false,
selectedServer: server,
selectedInstanceId: instance ? instance.instance_id : null,
includeAllNetworks: includeAll,
});
this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll);
}
onInputKeyUp(e) {
if (e.key === 'Enter') {
this.setState({
expanded: false,
selectedServer: e.target.value,
selectedNetwork: null,
includeAllNetworks: false,
});
this.props.onOptionChange(e.target.value, null);
}
}
collectRoot(e) {
if (this.dropdownRootElement) {
this.dropdownRootElement.removeEventListener('click', this.onRootClick, false);
}
if (e) {
e.addEventListener('click', this.onRootClick, false);
}
this.dropdownRootElement = e;
}
collectInputTextBox(e) {
this.inputTextBox = e;
}
_getMenuOptions() {
const options = [];
const roomDirectory = this.props.config.roomDirectory || {};
let servers = [];
if (roomDirectory.servers) {
servers = servers.concat(roomDirectory.servers);
}
if (!servers.includes(MatrixClientPeg.getHomeserverName())) {
servers.unshift(MatrixClientPeg.getHomeserverName());
}
// configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.
const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName));
const servers = [
// we always show our connected HS, this takes precedence over it being configured or user-defined
hsName,
...Array.from(configServers).filter(s => s !== hsName).sort(),
...Array.from(removableServers).sort(),
];
// For our own HS, we can use the instance_ids given in the third party protocols
// response to get the server to filter the room list by network for us.
// We can't get thirdparty protocols for remote server yet though, so for those
// we can only show the default room list.
for (const server of servers) {
options.push(this._makeMenuOption(server, null, true));
if (server === MatrixClientPeg.getHomeserverName()) {
options.push(this._makeMenuOption(server, null, false));
if (this.props.protocols) {
for (const proto of Object.keys(this.props.protocols)) {
if (!this.props.protocols[proto].instances) continue;
const options = servers.map(server => {
const serverSelected = server === selectedServerName;
const entries = [];
const sortedInstances = this.props.protocols[proto].instances;
sortedInstances.sort(function(x, y) {
const a = x.desc;
const b = y.desc;
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else {
return 0;
}
});
for (const instance of sortedInstances) {
if (!instance.instance_id) continue;
options.push(this._makeMenuOption(server, instance, false));
}
}
}
const protocolsList = server === hsName ? Object.values(protocols) : [];
if (protocolsList.length > 0) {
// add a fake protocol with the ALL_ROOMS symbol
protocolsList.push({
instances: [{
instance_id: ALL_ROOMS,
desc: _t("All rooms"),
}],
});
}
}
return options;
}
protocolsList.forEach(({instances=[]}) => {
[...instances].sort((b, a) => {
return a.desc.localeCompare(b.desc);
}).forEach(({desc, instance_id: instanceId}) => {
entries.push(
<MenuItemRadio
key={String(instanceId)}
active={serverSelected && instanceId === selectedInstanceId}
onClick={handlerFactory(server, instanceId)}
label={desc}
className="mx_NetworkDropdown_server_network"
>
{ desc }
</MenuItemRadio>);
});
});
_makeMenuOption(server, instance, includeAll, handleClicks) {
if (handleClicks === undefined) handleClicks = true;
let subtitle;
if (server === hsName) {
subtitle = (
<div className="mx_NetworkDropdown_server_subtitle">
{_t("Your server")}
</div>
);
}
let icon;
let name;
let key;
let removeButton;
if (removableServers.has(server)) {
const onClick = async () => {
closeMenu();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, {
title: _t("Are you sure?"),
description: _t("Are you sure you want to remove <b>%(serverName)s</b>", {
serverName: server,
}, {
b: serverName => <b>{ serverName }</b>,
}),
button: _t("Remove"),
fixedWidth: false,
}, "mx_NetworkDropdown_dialog");
if (!instance && includeAll) {
key = server;
name = server;
} else if (!instance) {
key = server + '_all';
name = 'Matrix';
icon = <img src={require("../../../../res/img/network-matrix.svg")} />;
} else {
key = server + '_inst_' + instance.instance_id;
const imgUrl = instance.icon ?
MatrixClientPeg.get().mxcUrlToHttp(instance.icon, 25, 25, 'crop', true) :
DEFAULT_ICON_URL;
icon = <img src={imgUrl} />;
name = instance.desc;
}
const [ok] = await finished;
if (!ok) return;
const clickHandler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null;
// delete from setting
setUserDefinedServers(servers.filter(s => s !== server));
return <div key={key} className="mx_NetworkDropdown_networkoption" onClick={clickHandler}>
{icon}
<span className="mx_NetworkDropdown_menu_network">{name}</span>
</div>;
}
// the selected server is being removed, reset to our HS
if (serverSelected === server) {
onOptionChange(hsName, undefined);
}
};
removeButton = <MenuItem onClick={onClick} label={_t("Remove server")} />;
}
render() {
let currentValue;
// ARIA: in actual fact the entire menu is one large radio group but for better screen reader support
// we use group to notate server wrongly.
return (
<MenuGroup label={server} className="mx_NetworkDropdown_server" key={server}>
<div className="mx_NetworkDropdown_server_title">
{ server }
{ removeButton }
</div>
{ subtitle }
let menu;
if (this.state.expanded) {
const menuOptions = this._getMenuOptions();
menu = <div className="mx_NetworkDropdown_menu">
{menuOptions}
</div>;
currentValue = <input type="text" className="mx_NetworkDropdown_networkoption"
ref={this.collectInputTextBox} onKeyUp={this.onInputKeyUp}
placeholder="matrix.org" // 'matrix.org' as an example of an HS name
/>;
} else {
const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId);
currentValue = this._makeMenuOption(
this.state.selectedServer, instance, this.state.includeAllNetworks, false,
<MenuItemRadio
active={serverSelected && !selectedInstanceId}
onClick={handlerFactory(server, undefined)}
label={_t("Matrix")}
className="mx_NetworkDropdown_server_network"
>
{_t("Matrix")}
</MenuItemRadio>
{ entries }
</MenuGroup>
);
});
const onClick = async () => {
closeMenu();
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
const { finished } = Modal.createTrackedDialog("Network Dropdown", "Add a new server", TextInputDialog, {
title: _t("Add a new server"),
description: _t("Enter the name of a new server you want to explore."),
button: _t("Add"),
hasCancel: false,
placeholder: _t("Server name"),
validator: validServer,
fixedWidth: false,
}, "mx_NetworkDropdown_dialog");
const [ok, newServer] = await finished;
if (!ok) return;
if (!userDefinedServers.includes(newServer)) {
setUserDefinedServers([...userDefinedServers, newServer]);
}
onOptionChange(newServer); // change filter to the new server
};
const buttonRect = handle.current.getBoundingClientRect();
content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu}>
<div className="mx_NetworkDropdown_menu">
{options}
<MenuItem className="mx_NetworkDropdown_server_add" label={undefined} onClick={onClick}>
{_t("Add a new server...")}
</MenuItem>
</div>
</ContextMenu>;
} else {
let currentValue;
if (selectedInstanceId === ALL_ROOMS) {
currentValue = _t("All rooms");
} else if (selectedInstanceId) {
const instance = instanceForInstanceId(protocols, selectedInstanceId);
currentValue = _t("%(networkName)s rooms", {
networkName: instance.desc,
});
} else {
currentValue = _t("Matrix rooms");
}
return <div className="mx_NetworkDropdown" ref={this.collectRoot}>
<div className="mx_NetworkDropdown_input mx_no_textinput" onClick={this.onInputClick}>
content = <ContextMenuButton
className="mx_NetworkDropdown_handle"
onClick={openMenu}
isExpanded={menuDisplayed}
>
<span>
{currentValue}
<span className="mx_NetworkDropdown_arrow" />
{menu}
</div>
</div>;
</span> <span className="mx_NetworkDropdown_handle_server">
({selectedServerName})
</span>
</ContextMenuButton>;
}
}
return <div className="mx_NetworkDropdown" ref={handle}>
{content}
</div>;
};
NetworkDropdown.propTypes = {
onOptionChange: PropTypes.func.isRequired,
protocols: PropTypes.object,
// The room directory config. May have a 'servers' key that is a list of server names to include in the dropdown
config: PropTypes.object,
};
NetworkDropdown.defaultProps = {
protocols: {},
config: {},
};
export default NetworkDropdown;

View file

@ -419,6 +419,12 @@ export default class AppTile extends React.Component {
if (this.props.onCapabilityRequest) {
this.props.onCapabilityRequest(requestedCapabilities);
}
// We only tell Jitsi widgets that we're ready because they're realistically the only ones
// using this custom extension to the widget API.
if (this.props.type === 'jitsi') {
widgetMessaging.flagReadyToContinue();
}
}).catch((err) => {
console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err);
});
@ -520,7 +526,13 @@ export default class AppTile extends React.Component {
parsedWidgetUrl.query.react_perf = true;
}
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol) || (
// Check if the widget URL is a Jitsi widget in Electron
parsedWidgetUrl.protocol === 'vector:'
&& parsedWidgetUrl.host === 'vector'
&& parsedWidgetUrl.pathname === '/webapp/jitsi.html'
&& this.props.type === 'jitsi'
)) {
safeWidgetUrl = url.format(parsedWidgetUrl);
}
return safeWidgetUrl;

View file

@ -78,8 +78,7 @@ export class EditableItem extends React.Component {
return (
<div className="mx_EditableItem">
<img src={require("../../../../res/img/feather-customised/cancel.svg")} width={14} height={14}
onClick={this._onRemove} className="mx_EditableItem_delete" alt={_t("Remove")} />
<div onClick={this._onRemove} className="mx_EditableItem_delete" title={_t("Remove")} role="button" />
<span className="mx_EditableItem_item">{this.props.value}</span>
</div>
);
@ -123,8 +122,9 @@ export default class EditableItemList extends React.Component {
<form onSubmit={this._onItemAdded} autoComplete="off"
noValidate={true} className="mx_EditableItemList_newItem">
<Field id={`mx_EditableItemList_new_${this.props.id}`} label={this.props.placeholder} type="text"
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged} />
<AccessibleButton onClick={this._onItemAdded} kind="primary">
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
list={this.props.suggestionsListId} />
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit">
{_t("Add")}
</AccessibleButton>
</form>

View file

@ -32,6 +32,8 @@ export default class Field extends React.PureComponent {
element: PropTypes.oneOf(["input", "select", "textarea"]),
// The field's type (when used as an <input>). Defaults to "text".
type: PropTypes.string,
// id of a <datalist> element for suggestions
list: PropTypes.string,
// The field's label string.
label: PropTypes.string,
// The field's placeholder string. Defaults to the label.
@ -157,7 +159,7 @@ export default class Field extends React.PureComponent {
render() {
const {
element, prefix, postfix, className, onValidate, children,
tooltipContent, flagInvalid, tooltipClassName, ...inputProps} = this.props;
tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
const inputElement = element || "input";
@ -169,6 +171,7 @@ export default class Field extends React.PureComponent {
inputProps.onFocus = this.onFocus;
inputProps.onChange = this.onChange;
inputProps.onBlur = this.onBlur;
inputProps.list = list;
const fieldInput = React.createElement(inputElement, inputProps, children);

View file

@ -1,35 +0,0 @@
/*
Copyright 2018 New Vector Ltd.
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.
*/
import React from 'react';
import GeminiScrollbar from 'react-gemini-scrollbar';
function GeminiScrollbarWrapper(props) {
const {wrappedRef, ...wrappedProps} = props;
// Enable forceGemini so that gemini is always enabled. This is
// to avoid future issues where a feature is implemented without
// doing QA on every OS/browser combination.
//
// By default GeminiScrollbar allows native scrollbars to be used
// on macOS. Use forceGemini to enable Gemini's non-native
// scrollbars on all OSs.
return <GeminiScrollbar ref={wrappedRef} forceGemini={true} {...wrappedProps}>
{ props.children }
</GeminiScrollbar>;
}
export default GeminiScrollbarWrapper;

View file

@ -216,7 +216,7 @@ export default class ImageView extends React.Component {
{ this.getName() }
</div>
{ eventMeta }
<a className="mx_ImageView_link" href={ this.props.src } download={ this.props.name } rel="noreferrer noopener">
<a className="mx_ImageView_link" href={ this.props.src } download={ this.props.name } target="_blank" rel="noopener">
<div className="mx_ImageView_download">
{ _t('Download this file') }<br />
<span className="mx_ImageView_size">{ sizeRes }</span>

View file

@ -92,6 +92,7 @@ export default class RoomAliasField extends React.PureComponent {
invalid: () => _t("Please provide a room alias"),
}, {
key: "taken",
final: true,
test: async ({value}) => {
if (!value) {
return true;

View file

@ -0,0 +1,41 @@
/*
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.
*/
import React from 'react';
import PropTypes from 'prop-types';
import PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
import {_t} from "../../../languageHandler";
const SSOButton = ({matrixClient, loginType, ...props}) => {
const onClick = () => {
PlatformPeg.get().startSingleSignOn(matrixClient, loginType);
};
return (
<AccessibleButton {...props} kind="primary" onClick={onClick}>
{_t("Sign in with single sign-on")}
</AccessibleButton>
);
};
SSOButton.propTypes = {
matrixClient: PropTypes.object.isRequired, // does not use context as may use a temporary client
loginType: PropTypes.oneOf(["sso", "cas"]), // defaults to "sso" in base-apis
};
export default SSOButton;

View file

@ -28,9 +28,11 @@ import classNames from 'classnames';
* An array of rules describing how to check to input value. Each rule in an object
* and may have the following properties:
* - `key`: A unique ID for the rule. Required.
* - `skip`: A function used to determine whether the rule should even be evaluated.
* - `test`: A function used to determine the rule's current validity. Required.
* - `valid`: Function returning text to show when the rule is valid. Only shown if set.
* - `invalid`: Function returning text to show when the rule is invalid. Only shown if set.
* - `final`: A Boolean if true states that this rule will only be considered if all rules before it returned valid.
* @returns {Function}
* A validation function that takes in the current input value and returns
* the overall validity and a feedback UI that can be rendered for more detail.
@ -51,9 +53,20 @@ export default function withValidation({ description, rules }) {
if (!rule.key || !rule.test) {
continue;
}
if (!valid && rule.final) {
continue;
}
const data = { value, allowEmpty };
if (rule.skip && rule.skip.call(this, data)) {
continue;
}
// We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const ruleValid = await rule.test.call(this, { value, allowEmpty });
const ruleValid = await rule.test.call(this, data);
valid = valid && ruleValid;
if (ruleValid && rule.valid) {
// If the rule's result is valid and has text to show for

View file

@ -126,13 +126,13 @@ export default class VerificationQRCode extends React.PureComponent {
buf = Buffer.concat([buf, tmpBuf]);
};
const appendInt = (i: number) => {
const tmpBuf = Buffer.alloc(4);
tmpBuf.writeInt8(i, 0);
const tmpBuf = Buffer.alloc(2);
tmpBuf.writeInt16BE(i, 0);
buf = Buffer.concat([buf, tmpBuf]);
};
const appendStr = (s: string, enc: string) => {
const appendStr = (s: string, enc: string, withLengthPrefix = true) => {
const tmpBuf = Buffer.from(s, enc);
appendInt(tmpBuf.byteLength);
if (withLengthPrefix) appendInt(tmpBuf.byteLength);
buf = Buffer.concat([buf, tmpBuf]);
};
const appendEncBase64 = (b64: string) => {
@ -142,7 +142,7 @@ export default class VerificationQRCode extends React.PureComponent {
};
// Actually build the buffer for the QR code
appendStr(this.props.prefix, "ascii");
appendStr(this.props.prefix, "ascii", false);
appendByte(this.props.version);
appendByte(this.props.mode);
appendStr(this.props.transactionId, "utf-8");

View file

@ -27,6 +27,7 @@ import { GroupMemberType } from '../../../groups';
import GroupStore from '../../../stores/GroupStore';
import AccessibleButton from '../elements/AccessibleButton';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
export default createReactClass({
displayName: 'GroupMemberInfo',
@ -182,10 +183,9 @@ export default createReactClass({
this.props.groupMember.displayname || this.props.groupMember.userId
);
const GeminiScrollbarWrapper = sdk.getComponent('elements.GeminiScrollbarWrapper');
return (
<div className="mx_MemberInfo" role="tabpanel">
<GeminiScrollbarWrapper autoshow={true}>
<AutoHideScrollbar>
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this._onCancel}>
<img src={require("../../../../res/img/cancel.svg")} width="18" height="18" className="mx_filterFlipColor" />
</AccessibleButton>
@ -199,7 +199,7 @@ export default createReactClass({
</div>
{ adminTools }
</GeminiScrollbarWrapper>
</AutoHideScrollbar>
</div>
);
},

View file

@ -26,6 +26,7 @@ import { showGroupInviteDialog } from '../../../GroupAddressPicker';
import AccessibleButton from '../elements/AccessibleButton';
import TintableSvg from '../elements/TintableSvg';
import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
const INITIAL_LOAD_NUM_MEMBERS = 30;
@ -172,7 +173,6 @@ export default createReactClass({
},
render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.fetching || this.state.fetchingInvitedMembers) {
const Spinner = sdk.getComponent("elements.Spinner");
return (<div className="mx_MemberList">
@ -225,10 +225,10 @@ export default createReactClass({
return (
<div className="mx_MemberList" role="tabpanel">
{ inviteButton }
<GeminiScrollbarWrapper autoshow={true}>
<AutoHideScrollbar>
{ joined }
{ invited }
</GeminiScrollbarWrapper>
</AutoHideScrollbar>
{ inputBox }
</div>
);

View file

@ -60,6 +60,7 @@ export default createReactClass({
const av = (
<BaseAvatar
aria-hidden="true"
name={this.props.member.displayname || this.props.member.userId}
idName={this.props.member.userId}
width={36} height={36}

View file

@ -24,6 +24,7 @@ import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import GroupStore from '../../../stores/GroupStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
export default createReactClass({
displayName: 'GroupRoomInfo',
@ -153,7 +154,6 @@ export default createReactClass({
render: function() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const InlineSpinner = sdk.getComponent('elements.InlineSpinner');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.groupRoomRemoveLoading || !this.state.groupRoom) {
const Spinner = sdk.getComponent("elements.Spinner");
return <div className="mx_MemberInfo">
@ -216,7 +216,7 @@ export default createReactClass({
const groupRoomName = this.state.groupRoom.displayname;
return (
<div className="mx_MemberInfo" role="tabpanel">
<GeminiScrollbarWrapper autoshow={true}>
<AutoHideScrollbar>
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this._onCancel}>
<img src={require("../../../../res/img/cancel.svg")} width="18" height="18" className="mx_filterFlipColor" />
</AccessibleButton>
@ -231,7 +231,7 @@ export default createReactClass({
</div>
{ adminTools }
</GeminiScrollbarWrapper>
</AutoHideScrollbar>
</div>
);
},

View file

@ -22,6 +22,7 @@ import PropTypes from 'prop-types';
import { showGroupAddRoomDialog } from '../../../GroupAddressPicker';
import AccessibleButton from '../elements/AccessibleButton';
import TintableSvg from '../elements/TintableSvg';
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
const INITIAL_LOAD_NUM_ROOMS = 30;
@ -150,17 +151,16 @@ export default createReactClass({
placeholder={_t('Filter community rooms')} autoComplete="off" />
);
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const TruncatedList = sdk.getComponent("elements.TruncatedList");
return (
<div className="mx_GroupRoomList" role="tabpanel">
{ inviteButton }
<GeminiScrollbarWrapper autoshow={true} className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
<AutoHideScrollbar className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
<TruncatedList className="mx_GroupRoomList_wrapper" truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
{ this.makeGroupRoomTiles(this.state.searchQuery) }
</TruncatedList>
</GeminiScrollbarWrapper>
</AutoHideScrollbar>
{ inputBox }
</div>
);

View file

@ -247,6 +247,8 @@ export default createReactClass({
});
};
// This button should actually Download because usercontent/ will try to click itself
// but it is not guaranteed between various browsers' settings.
return (
<span className="mx_MFileBody">
<div className="mx_MFileBody_download">
@ -269,6 +271,8 @@ export default createReactClass({
// We can't provide a Content-Disposition header like we would for HTTP.
download: fileName,
textContent: _t("Download %(text)s", { text: text }),
// only auto-download if a user triggered this iframe explicitly
auto: !this.props.decryptedBlob,
}, "*");
};
@ -290,7 +294,7 @@ export default createReactClass({
src={`${url}?origin=${encodeURIComponent(window.location.origin)}`}
onLoad={onIframeLoad}
ref={this._iframe}
sandbox="allow-scripts allow-downloads" />
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
</div>
</span>
);

View file

@ -62,8 +62,8 @@ export default class MKeyVerificationRequest extends React.Component {
const request = this.props.mxEvent.verificationRequest;
if (request) {
try {
await request.accept();
this._openRequest();
await request.accept();
} catch (err) {
console.error(err.message);
}
@ -136,9 +136,9 @@ export default class MKeyVerificationRequest extends React.Component {
} else if (request.cancelled) {
stateLabel = this._cancelledLabel(request.cancellingUserId);
} else if (request.accepting) {
stateLabel = _t("accepting …");
stateLabel = _t("Accepting …");
} else if (request.declining) {
stateLabel = _t("declining …");
stateLabel = _t("Declining …");
}
stateNode = (<div className="mx_cryptoEvent_state">{stateLabel}</div>);
}

View file

@ -121,10 +121,12 @@ export default class MessageActionBar extends React.PureComponent {
componentDidMount() {
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
this.props.mxEvent.on("Event.beforeRedaction", this.onBeforeRedaction);
}
componentWillUnmount() {
this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted);
this.props.mxEvent.removeListener("Event.beforeRedaction", this.onBeforeRedaction);
}
onDecrypted = () => {
@ -133,6 +135,11 @@ export default class MessageActionBar extends React.PureComponent {
this.forceUpdate();
};
onBeforeRedaction = () => {
// When an event is redacted, we can't edit it so update the available actions.
this.forceUpdate();
};
onFocusChange = (focused) => {
if (!this.props.onFocusChange) {
return;

View file

@ -28,12 +28,17 @@ export const PendingActionSpinner = ({text}) => {
</div>;
};
const EncryptionInfo = ({pending, member, onStartVerification}) => {
const EncryptionInfo = ({waitingForOtherParty, waitingForNetwork, member, onStartVerification}) => {
let content;
if (pending) {
const text = _t("Waiting for %(displayName)s to accept…", {
displayName: member.displayName || member.name || member.userId,
});
if (waitingForOtherParty || waitingForNetwork) {
let text;
if (waitingForOtherParty) {
text = _t("Waiting for %(displayName)s to accept…", {
displayName: member.displayName || member.name || member.userId,
});
} else {
text = _t("Accepting…");
}
content = <PendingActionSpinner text={text} />;
} else {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');

View file

@ -30,16 +30,32 @@ import {_t} from "../../../languageHandler";
// cancellation codes which constitute a key mismatch
const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"];
const EncryptionPanel = ({verificationRequest, member, onClose, layout}) => {
const EncryptionPanel = ({verificationRequest, verificationRequestPromise, member, onClose, layout}) => {
const [request, setRequest] = useState(verificationRequest);
// state to show a spinner immediately after clicking "start verification",
// before we have a request
const [isRequesting, setRequesting] = useState(false);
const [phase, setPhase] = useState(request && request.phase);
useEffect(() => {
setRequest(verificationRequest);
if (verificationRequest) {
setRequesting(false);
setPhase(verificationRequest.phase);
}
}, [verificationRequest]);
useEffect(() => {
async function awaitPromise() {
setRequesting(true);
const request = await verificationRequestPromise;
setRequesting(false);
setRequest(request);
setPhase(request.phase);
}
if (verificationRequestPromise) {
awaitPromise();
}
}, [verificationRequestPromise]);
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)) {
@ -68,6 +84,7 @@ const EncryptionPanel = ({verificationRequest, member, onClose, layout}) => {
useEventEmitter(request, "change", changeHandler);
const onStartVerification = useCallback(async () => {
setRequesting(true);
const cli = MatrixClientPeg.get();
const roomId = await ensureDMExists(cli, member.userId);
const verificationRequest = await cli.requestVerificationDM(member.userId, roomId);
@ -75,9 +92,16 @@ const EncryptionPanel = ({verificationRequest, member, onClose, layout}) => {
setPhase(verificationRequest.phase);
}, [member.userId]);
const requested = request && (phase === PHASE_REQUESTED || phase === PHASE_UNSENT || phase === undefined);
const requested =
(!request && isRequesting) ||
(request && (phase === PHASE_REQUESTED || phase === PHASE_UNSENT || phase === undefined));
if (!request || requested) {
return <EncryptionInfo onStartVerification={onStartVerification} member={member} pending={requested} />;
const initiatedByMe = (!request && isRequesting) || (request && request.initiatedByMe);
return <EncryptionInfo
onStartVerification={onStartVerification}
member={member}
waitingForOtherParty={requested && initiatedByMe}
waitingForNetwork={requested && !initiatedByMe} />;
} else {
return (
<VerificationPanel

View file

@ -25,7 +25,7 @@ import dis from '../../../dispatcher';
import Modal from '../../../Modal';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import createRoom, {findDMForUser} from '../../../createRoom';
import createRoom from '../../../createRoom';
import DMRoomMap from '../../../utils/DMRoomMap';
import AccessibleButton from '../elements/AccessibleButton';
import SdkConfig from '../../../SdkConfig';
@ -43,6 +43,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases";
import EncryptionPanel from "./EncryptionPanel";
import { useAsyncMemo } from '../../../hooks/useAsyncMemo';
import { verifyUser, legacyVerifyUser, verifyDevice } from '../../../verification';
const _disambiguateDevices = (devices) => {
const names = Object.create(null);
@ -136,54 +137,21 @@ function useIsEncrypted(cli, room) {
return isEncrypted;
}
async function verifyDevice(userId, device) {
const cli = MatrixClientPeg.get();
const member = cli.getUser(userId);
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog("Verification warning", "unverified session", QuestionDialog, {
headerImage: require("../../../../res/img/e2e/warning.svg"),
title: _t("Not Trusted"),
description: <div>
<p>{_t("%(name)s (%(userId)s) signed in to a new session without verifying it:", {name: member.displayName, userId})}</p>
<p>{device.getDisplayName()} ({device.deviceId})</p>
<p>{_t("Ask this user to verify their session, or manually verify it below.")}</p>
</div>,
onFinished: async (doneClicked) => {
const manuallyVerifyClicked = !doneClicked;
if (!manuallyVerifyClicked) {
return;
}
const cli = MatrixClientPeg.get();
const verificationRequest = await cli.requestVerification(
userId,
[device.deviceId],
);
dis.dispatch({
action: "set_right_panel_phase",
phase: RIGHT_PANEL_PHASES.EncryptionPanel,
refireParams: {member, verificationRequest},
});
},
primaryButton: _t("Done"),
cancelButton: _t("Manually Verify"),
});
}
function verifyUser(user) {
const cli = MatrixClientPeg.get();
const dmRoom = findDMForUser(cli, user.userId);
let existingRequest;
if (dmRoom) {
existingRequest = cli.findVerificationRequestDMInProgress(dmRoom.roomId);
}
dis.dispatch({
action: "set_right_panel_phase",
phase: RIGHT_PANEL_PHASES.EncryptionPanel,
refireParams: {
member: user,
verificationRequest: existingRequest,
},
});
function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) {
return useAsyncMemo(async () => {
if (!canVerify) {
return false;
}
setUpdating(true);
try {
await cli.downloadKeys([member.userId]);
const xsi = cli.getStoredCrossSigningForUser(member.userId);
const key = xsi && xsi.getId();
return !!key;
} finally {
setUpdating(false);
}
}, [cli, member, canVerify], false);
}
function DeviceItem({userId, device}) {
@ -212,7 +180,7 @@ function DeviceItem({userId, device}) {
const onDeviceClick = () => {
if (!isVerified) {
verifyDevice(userId, device);
verifyDevice(cli.getUser(userId), device);
}
};
@ -1324,21 +1292,32 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
let verifyButton;
const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
if (
SettingsStore.isFeatureEnabled("feature_cross_signing") &&
homeserverSupportsCrossSigning
) {
const userTrust = cli.checkUserTrust(member.userId);
const userVerified = userTrust.isCrossSigningVerified();
const isMe = member.userId === cli.getUserId();
if (isRoomEncrypted && !userVerified && !isMe) {
verifyButton = (
<AccessibleButton className="mx_UserInfo_field" onClick={() => verifyUser(member)}>
{_t("Verify")}
</AccessibleButton>
);
}
const userTrust = cli.checkUserTrust(member.userId);
const userVerified = userTrust.isCrossSigningVerified();
const isMe = member.userId === cli.getUserId();
const canVerify = SettingsStore.isFeatureEnabled("feature_cross_signing") &&
homeserverSupportsCrossSigning &&
isRoomEncrypted && !userVerified && !isMe;
const setUpdating = (updating) => {
setPendingUpdateCount(count => count + (updating ? 1 : -1));
};
const hasCrossSigningKeys =
useHasCrossSigningKeys(cli, member, canVerify, setUpdating );
if (canVerify) {
verifyButton = (
<AccessibleButton className="mx_UserInfo_field" onClick={() => {
if (hasCrossSigningKeys) {
verifyUser(member);
} else {
legacyVerifyUser(member);
}
}}>
{_t("Verify")}
</AccessibleButton>
);
}
let devicesSection;

View file

@ -72,8 +72,8 @@ export default class VerificationPanel extends React.PureComponent {
renderQRPhase(pending) {
const {member, request} = this.props;
const showSAS = request.methods.includes(verificationMethods.SAS);
const showQR = this.props.request.otherPartySupportsMethod(SCAN_QR_CODE_METHOD);
const showSAS = request.otherPartySupportsMethod(verificationMethods.SAS);
const showQR = request.otherPartySupportsMethod(SCAN_QR_CODE_METHOD);
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const noCommonMethodError = !showSAS && !showQR ?

View file

@ -25,6 +25,7 @@ import Field from "../elements/Field";
import ErrorDialog from "../dialogs/ErrorDialog";
import AccessibleButton from "../elements/AccessibleButton";
import Modal from "../../../Modal";
import RoomPublishSetting from "./RoomPublishSetting";
class EditableAliasesList extends EditableItemList {
constructor(props) {
@ -46,6 +47,11 @@ class EditableAliasesList extends EditableItemList {
};
_renderNewItemField() {
// if we don't need the RoomAliasField,
// we don't need to overriden version of _renderNewItemField
if (!this.props.domain) {
return super._renderNewItemField();
}
const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField');
const onChange = (alias) => this._onNewItemChanged({target: {value: alias}});
return (
@ -87,91 +93,64 @@ export default class AliasSettings extends React.Component {
super(props);
const state = {
domainToAliases: {}, // { domain.com => [#alias1:domain.com, #alias2:domain.com] }
remoteDomains: [], // [ domain.com, foobar.com ]
canonicalAlias: null, // #canonical:domain.com
altAliases: [], // [ #alias:domain.tld, ... ]
localAliases: [], // [ #alias:my-hs.tld, ... ]
canonicalAlias: null, // #canonical:domain.tld
updatingCanonicalAlias: false,
localAliasesLoading: true,
localAliasesLoading: false,
detailsOpen: false,
};
if (props.canonicalAliasEvent) {
state.canonicalAlias = props.canonicalAliasEvent.getContent().alias;
const content = props.canonicalAliasEvent.getContent();
const altAliases = content.alt_aliases;
if (Array.isArray(altAliases)) {
state.altAliases = altAliases.slice();
}
state.canonicalAlias = content.alias;
}
this.state = state;
}
async componentWillMount() {
const cli = MatrixClientPeg.get();
componentDidMount() {
if (this.props.canSetCanonicalAlias) {
// load local aliases for providing recommendations
// for the canonical alias and alt_aliases
this.loadLocalAliases();
}
}
async loadLocalAliases() {
this.setState({ localAliasesLoading: true });
try {
const cli = MatrixClientPeg.get();
let localAliases = [];
if (await cli.doesServerSupportUnstableFeature("org.matrix.msc2432")) {
const response = await cli.unstableGetLocalAliases(this.props.roomId);
const localAliases = response.aliases;
const localDomain = cli.getDomain();
const domainToAliases = Object.assign(
{},
// FIXME, any localhost alt_aliases will be ignored as they are overwritten by localAliases
this.aliasesToDictionary(this._getAltAliases()),
{[localDomain]: localAliases || []},
);
const remoteDomains = Object.keys(domainToAliases).filter((domain) => {
return domain !== localDomain && domainToAliases[domain].length > 0;
});
this.setState({ domainToAliases, remoteDomains });
} else {
const state = {};
const localDomain = cli.getDomain();
state.domainToAliases = this.aliasEventsToDictionary(this.props.aliasEvents || []);
state.remoteDomains = Object.keys(state.domainToAliases).filter((domain) => {
return domain !== localDomain && state.domainToAliases[domain].length > 0;
});
this.setState(state);
if (Array.isArray(response.aliases)) {
localAliases = response.aliases;
}
}
this.setState({ localAliases });
} finally {
this.setState({localAliasesLoading: false});
this.setState({ localAliasesLoading: false });
}
}
aliasesToDictionary(aliases) {
return aliases.reduce((dict, alias) => {
const domain = alias.split(":")[1];
dict[domain] = dict[domain] || [];
dict[domain].push(alias);
return dict;
}, {});
}
aliasEventsToDictionary(aliasEvents) { // m.room.alias events
const dict = {};
aliasEvents.forEach((event) => {
dict[event.getStateKey()] = (
(event.getContent().aliases || []).slice() // shallow-copy
);
});
return dict;
}
_getAltAliases() {
if (this.props.canonicalAliasEvent) {
const altAliases = this.props.canonicalAliasEvent.getContent().alt_aliases;
if (Array.isArray(altAliases)) {
return altAliases;
}
}
return [];
}
changeCanonicalAlias(alias) {
if (!this.props.canSetCanonicalAlias) return;
const oldAlias = this.state.canonicalAlias;
this.setState({
canonicalAlias: alias,
updatingCanonicalAlias: true,
});
const eventContent = {};
const altAliases = this._getAltAliases();
if (altAliases) eventContent["alt_aliases"] = altAliases;
const eventContent = {
alt_aliases: this.state.altAliases,
};
if (alias) eventContent["alias"] = alias;
MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.canonical_alias",
@ -184,6 +163,39 @@ export default class AliasSettings extends React.Component {
"or a temporary failure occurred.",
),
});
this.setState({canonicalAlias: oldAlias});
}).finally(() => {
this.setState({updatingCanonicalAlias: false});
});
}
changeAltAliases(altAliases) {
if (!this.props.canSetCanonicalAlias) return;
this.setState({
updatingCanonicalAlias: true,
altAliases,
});
const eventContent = {};
if (this.state.canonicalAlias) {
eventContent.alias = this.state.canonicalAlias;
}
if (altAliases) {
eventContent["alt_aliases"] = altAliases;
}
MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.canonical_alias",
eventContent, "").catch((err) => {
console.error(err);
Modal.createTrackedDialog('Error updating alternative addresses', '', ErrorDialog, {
title: _t("Error updating main address"),
description: _t(
"There was an error updating the room's alternative addresses. " +
"It may not be allowed by the server or a temporary failure occurred.",
),
});
}).finally(() => {
this.setState({updatingCanonicalAlias: false});
});
@ -200,16 +212,10 @@ export default class AliasSettings extends React.Component {
if (!alias.includes(':')) alias += ':' + localDomain;
MatrixClientPeg.get().createAlias(alias, this.props.roomId).then(() => {
const localAliases = this.state.domainToAliases[localDomain] || [];
const domainAliases = Object.assign({}, this.state.domainToAliases);
domainAliases[localDomain] = [...localAliases, alias];
this.setState({
domainToAliases: domainAliases,
// Reset the add field
newAlias: "",
localAliases: this.state.localAliases.concat(alias),
newAlias: null,
});
if (!this.state.canonicalAlias) {
this.changeCanonicalAlias(alias);
}
@ -226,38 +232,77 @@ export default class AliasSettings extends React.Component {
};
onLocalAliasDeleted = (index) => {
const localDomain = MatrixClientPeg.get().getDomain();
const alias = this.state.domainToAliases[localDomain][index];
const alias = this.state.localAliases[index];
// TODO: In future, we should probably be making sure that the alias actually belongs
// to this room. See https://github.com/vector-im/riot-web/issues/7353
MatrixClientPeg.get().deleteAlias(alias).then(() => {
const localAliases = this.state.domainToAliases[localDomain].filter((a) => a !== alias);
const domainAliases = Object.assign({}, this.state.domainToAliases);
domainAliases[localDomain] = localAliases;
this.setState({domainToAliases: domainAliases});
const localAliases = this.state.localAliases.filter(a => a !== alias);
this.setState({localAliases});
if (this.state.canonicalAlias === alias) {
this.changeCanonicalAlias(null);
}
}).catch((err) => {
console.error(err);
Modal.createTrackedDialog('Error removing alias', '', ErrorDialog, {
title: _t("Error removing alias"),
description: _t(
let description;
if (err.errcode === "M_FORBIDDEN") {
description = _t("You don't have permission to delete the alias.");
} else {
description = _t(
"There was an error removing that alias. It may no longer exist or a temporary " +
"error occurred.",
),
);
}
Modal.createTrackedDialog('Error removing alias', '', ErrorDialog, {
title: _t("Error removing alias"),
description,
});
});
};
onLocalAliasesToggled = (event) => {
// expanded
if (event.target.open) {
// if local aliases haven't been preloaded yet at component mount
if (!this.props.canSetCanonicalAlias && this.state.localAliases.length === 0) {
this.loadLocalAliases();
}
}
this.setState({detailsOpen: event.target.open});
};
onCanonicalAliasChange = (event) => {
this.changeCanonicalAlias(event.target.value);
};
onNewAltAliasChanged = (value) => {
this.setState({newAltAlias: value});
}
onAltAliasAdded = (alias) => {
const altAliases = this.state.altAliases.slice();
if (!altAliases.some(a => a.trim() === alias.trim())) {
altAliases.push(alias.trim());
this.changeAltAliases(altAliases);
this.setState({newAltAlias: ""});
}
}
onAltAliasDeleted = (index) => {
const altAliases = this.state.altAliases.slice();
altAliases.splice(index, 1);
this.changeAltAliases(altAliases);
}
_getAliases() {
return this.state.altAliases.concat(this._getLocalNonAltAliases());
}
_getLocalNonAltAliases() {
const {altAliases} = this.state;
return this.state.localAliases.filter(alias => !altAliases.includes(alias));
}
render() {
const localDomain = MatrixClientPeg.get().getDomain();
@ -269,15 +314,13 @@ export default class AliasSettings extends React.Component {
element='select' id='canonicalAlias' label={_t('Main address')}>
<option value="" key="unset">{ _t('not specified') }</option>
{
Object.keys(this.state.domainToAliases).map((domain, i) => {
return this.state.domainToAliases[domain].map((alias, j) => {
if (alias === this.state.canonicalAlias) found = true;
return (
<option value={alias} key={i + "_" + j}>
{ alias }
</option>
);
});
this._getAliases().map((alias, i) => {
if (alias === this.state.canonicalAlias) found = true;
return (
<option value={alias} key={i}>
{ alias }
</option>
);
})
}
{
@ -289,53 +332,60 @@ export default class AliasSettings extends React.Component {
</Field>
);
let remoteAliasesSection;
if (this.state.remoteDomains.length) {
remoteAliasesSection = (
<div>
<div>
{ _t("Remote addresses for this room:") }
</div>
<ul>
{ this.state.remoteDomains.map((domain, i) => {
return this.state.domainToAliases[domain].map((alias, j) => {
return <li key={i + "_" + j}>{alias}</li>;
});
}) }
</ul>
</div>
);
}
let localAliasesList;
if (this.state.localAliasesLoading) {
const Spinner = sdk.getComponent("elements.Spinner");
localAliasesList = <Spinner />;
} else {
localAliasesList = <EditableAliasesList
localAliasesList = (<EditableAliasesList
id="roomAliases"
className={"mx_RoomSettings_localAliases"}
items={this.state.domainToAliases[localDomain] || []}
items={this.state.localAliases}
newItem={this.state.newAlias}
onNewItemChanged={this.onNewAliasChanged}
canRemove={this.props.canSetAliases}
canEdit={this.props.canSetAliases}
onItemAdded={this.onLocalAliasAdded}
onItemRemoved={this.onLocalAliasDeleted}
itemsLabel={_t('Local addresses for this room:')}
noItemsLabel={_t('This room has no local addresses')}
placeholder={_t(
'New address (e.g. #foo:%(localDomain)s)', {localDomain: localDomain},
)}
placeholder={_t('Local address')}
domain={localDomain}
/>;
/>);
}
return (
<div className='mx_AliasSettings'>
<span className='mx_SettingsTab_subheading'>{_t("Published Addresses")}</span>
<p>{_t("Published addresses can be used by anyone on any server to join your room. " +
"To publish an address, it needs to be set as a local address first.")}</p>
{canonicalAliasSection}
{localAliasesList}
{remoteAliasesSection}
<RoomPublishSetting roomId={this.props.roomId} canSetCanonicalAlias={this.props.canSetCanonicalAlias} />
<datalist id="mx_AliasSettings_altRecommendations">
{this._getLocalNonAltAliases().map(alias => {
return <option value={alias} key={alias} />;
})};
</datalist>
<EditableAliasesList
id="roomAltAliases"
className={"mx_RoomSettings_altAliases"}
items={this.state.altAliases}
newItem={this.state.newAltAlias}
onNewItemChanged={this.onNewAltAliasChanged}
canRemove={this.props.canSetCanonicalAlias}
canEdit={this.props.canSetCanonicalAlias}
onItemAdded={this.onAltAliasAdded}
onItemRemoved={this.onAltAliasDeleted}
suggestionsListId="mx_AliasSettings_altRecommendations"
itemsLabel={_t('Other published addresses:')}
noItemsLabel={_t('No other published addresses yet, add one below')}
placeholder={_t('New published address (e.g. #alias:server)')}
/>
<span className='mx_SettingsTab_subheading mx_AliasSettings_localAliasHeader'>{_t("Local Addresses")}</span>
<p>{_t("Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)", {localDomain})}</p>
<details onToggle={this.onLocalAliasesToggled}>
<summary>{ this.state.detailsOpen ? _t('Show less') : _t("Show more")}</summary>
{localAliasesList}
</details>
</div>
);
}

View file

@ -0,0 +1,60 @@
/*
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.
*/
import React from 'react';
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import {_t} from "../../../languageHandler";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
export default class RoomPublishSetting extends React.PureComponent {
constructor(props) {
super(props);
this.state = {isRoomPublished: false};
}
onRoomPublishChange = (e) => {
const valueBefore = this.state.isRoomPublished;
const newValue = !valueBefore;
this.setState({isRoomPublished: newValue});
const client = MatrixClientPeg.get();
client.setRoomDirectoryVisibility(
this.props.roomId,
newValue ? 'public' : 'private',
).catch(() => {
// Roll back the local echo on the change
this.setState({isRoomPublished: valueBefore});
});
};
componentDidMount() {
const client = MatrixClientPeg.get();
client.getRoomDirectoryVisibility(this.props.roomId).then((result => {
this.setState({isRoomPublished: result.visibility === 'public'});
}));
}
render() {
const client = MatrixClientPeg.get();
return (<LabelledToggleSwitch value={this.state.isRoomPublished}
onChange={this.onRoomPublishChange}
disabled={!this.props.canSetCanonicalAlias}
label={_t("Publish this room to the public in %(domain)s's room directory?", {
domain: client.getDomain(),
})} />);
}
}

View file

@ -149,14 +149,17 @@ export default class BasicMessageEditor extends React.Component {
const position = selection.end || selection;
this._setLastCaretFromPosition(position);
}
const {isEmpty} = this.props.model;
if (this.props.placeholder) {
const {isEmpty} = this.props.model;
if (isEmpty) {
this._showPlaceholder();
} else {
this._hidePlaceholder();
}
}
if (isEmpty) {
this._formatBarRef.hide();
}
this.setState({autoComplete: this.props.model.autoComplete});
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
TypingStore.sharedInstance().setSelfTyping(this.props.room.roomId, !this.props.model.isEmpty);
@ -370,6 +373,16 @@ export default class BasicMessageEditor extends React.Component {
} else if (modKey && event.key === Key.GREATER_THAN) {
this._onFormatAction("quote");
handled = true;
// redo
} else if ((!IS_MAC && modKey && event.key === Key.Y) ||
(IS_MAC && modKey && event.shiftKey && event.key === Key.Z)) {
if (this.historyManager.canRedo()) {
const {parts, caret} = this.historyManager.redo();
// pass matching inputType so historyManager doesn't push echo
// when invoked from rerender callback.
model.reset(parts, caret, "historyRedo");
}
handled = true;
// undo
} else if (modKey && event.key === Key.Z) {
if (this.historyManager.canUndo()) {
@ -379,28 +392,19 @@ export default class BasicMessageEditor extends React.Component {
model.reset(parts, caret, "historyUndo");
}
handled = true;
// redo
} else if (modKey && event.key === Key.Y) {
if (this.historyManager.canRedo()) {
const {parts, caret} = this.historyManager.redo();
// pass matching inputType so historyManager doesn't push echo
// when invoked from rerender callback.
model.reset(parts, caret, "historyRedo");
}
handled = true;
// insert newline on Shift+Enter
} else if (event.key === Key.ENTER && (event.shiftKey || (IS_MAC && event.altKey))) {
this._insertText("\n");
handled = true;
// move selection to start of composer
} else if (modKey && event.key === Key.HOME) {
} else if (modKey && event.key === Key.HOME && !event.shiftKey) {
setSelection(this._editorRef, model, {
index: 0,
offset: 0,
});
handled = true;
// move selection to end of composer
} else if (modKey && event.key === Key.END) {
} else if (modKey && event.key === Key.END && !event.shiftKey) {
setSelection(this._editorRef, model, {
index: model.parts.length - 1,
offset: model.parts[model.parts.length - 1].text.length,
@ -471,10 +475,14 @@ export default class BasicMessageEditor extends React.Component {
const addedLen = range.replace([partCreator.pillCandidate(range.text)]);
return model.positionForOffset(caret.offset + addedLen, true);
});
await model.autoComplete.onTab();
if (!model.autoComplete.hasSelection()) {
this.setState({showVisualBell: true});
model.autoComplete.close();
// Don't try to do things with the autocomplete if there is none shown
if (model.autoComplete) {
await model.autoComplete.onTab();
if (!model.autoComplete.hasSelection()) {
this.setState({showVisualBell: true});
model.autoComplete.close();
}
}
} catch (err) {
console.error(err);
@ -559,6 +567,7 @@ export default class BasicMessageEditor extends React.Component {
return;
}
this.historyManager.ensureLastChangesPushed(this.props.model);
this._modifiedFlag = true;
switch (action) {
case "bold":
toggleInlineFormat(range, "**");

View file

@ -175,7 +175,8 @@ const EntityTile = createReactClass({
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const av = this.props.avatarJsx || <BaseAvatar name={this.props.name} width={36} height={36} />;
const av = this.props.avatarJsx ||
<BaseAvatar name={this.props.name} width={36} height={36} aria-hidden="true" />;
// The wrapping div is required to make the magic mouse listener work, for some reason.
return (

View file

@ -48,8 +48,6 @@ const eventTileTypes = {
const stateEventTileTypes = {
'm.room.encryption': 'messages.EncryptionEvent',
'm.room.aliases': 'messages.TextualEvent',
// 'm.room.aliases': 'messages.RoomAliasesEvent', // too complex
'm.room.canonical_alias': 'messages.TextualEvent',
'm.room.create': 'messages.RoomCreate',
'm.room.member': 'messages.TextualEvent',

View file

@ -33,7 +33,6 @@ export default createReactClass({
componentWillMount: function() {
dis.dispatch({
action: 'panel_disable',
rightDisabled: true,
middleDisabled: true,
});
},
@ -45,7 +44,6 @@ export default createReactClass({
componentWillUnmount: function() {
dis.dispatch({
action: 'panel_disable',
sideDisabled: false,
middleDisabled: false,
});
document.removeEventListener('keydown', this._onKeyDown);

View file

@ -24,7 +24,7 @@ export default (props) => {
}
return (<div className="mx_JumpToBottomButton">
<AccessibleButton className="mx_JumpToBottomButton_scrollDown"
title={_t("Scroll to bottom of page")}
title={_t("Scroll to most recent messages")}
onClick={props.onScrollToBottomClick}>
</AccessibleButton>
{ badge }

View file

@ -208,7 +208,7 @@ export default createReactClass({
}
const av = (
<MemberAvatar member={member} width={36} height={36} />
<MemberAvatar member={member} width={36} height={36} aria-hidden="true" />
);
if (member.user) {

View file

@ -178,6 +178,7 @@ export default class MessageComposer extends React.Component {
isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(),
showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"),
};
}
@ -325,10 +326,20 @@ export default class MessageComposer extends React.Component {
permalinkCreator={this.props.permalinkCreator} />,
<Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
callInProgress ? <HangupButton key="controls_hangup" roomId={this.props.room.roomId} /> : null,
callInProgress ? null : <CallButton key="controls_call" roomId={this.props.room.roomId} />,
callInProgress ? null : <VideoCallButton key="controls_videocall" roomId={this.props.room.roomId} />,
);
if (this.state.showCallButtons) {
if (callInProgress) {
controls.push(
<HangupButton key="controls_hangup" roomId={this.props.room.roomId} />,
);
} else {
controls.push(
<CallButton key="controls_call" roomId={this.props.room.roomId} />,
<VideoCallButton key="controls_videocall" roomId={this.props.room.roomId} />,
);
}
}
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 Vector Creations Ltd
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.
@ -28,7 +29,7 @@ import rate_limited_func from "../../../ratelimitedfunc";
import * as Rooms from '../../../Rooms';
import DMRoomMap from '../../../utils/DMRoomMap';
import TagOrderStore from '../../../stores/TagOrderStore';
import RoomListStore from '../../../stores/RoomListStore';
import RoomListStore, {TAG_DM} from '../../../stores/RoomListStore';
import CustomRoomTagStore from '../../../stores/CustomRoomTagStore';
import GroupStore from '../../../stores/GroupStore';
import RoomSubList from '../../structures/RoomSubList';
@ -40,6 +41,8 @@ import * as Receipt from "../../../utils/Receipt";
import {Resizer} from '../../../resizer';
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
import * as Unread from "../../../Unread";
import RoomViewStore from "../../../stores/RoomViewStore";
const HIDE_CONFERENCE_CHANS = true;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
@ -242,6 +245,54 @@ export default createReactClass({
});
}
break;
case 'view_room_delta': {
const currentRoomId = RoomViewStore.getRoomId();
const {
"im.vector.fake.invite": inviteRooms,
"m.favourite": favouriteRooms,
[TAG_DM]: dmRooms,
"im.vector.fake.recent": recentRooms,
"m.lowpriority": lowPriorityRooms,
"im.vector.fake.archived": historicalRooms,
"m.server_notice": serverNoticeRooms,
...tags
} = this.state.lists;
const shownCustomTagRooms = Object.keys(tags).filter(tagName => {
return (!this.state.customTags || this.state.customTags[tagName]) &&
!tagName.match(STANDARD_TAGS_REGEX);
}).map(tagName => tags[tagName]);
// this order matches the one when generating the room sublists below.
let rooms = this._applySearchFilter([
...inviteRooms,
...favouriteRooms,
...dmRooms,
...recentRooms,
...[].concat.apply([], shownCustomTagRooms), // eslint-disable-line prefer-spread
...lowPriorityRooms,
...historicalRooms,
...serverNoticeRooms,
], this.props.searchFilter);
if (payload.unread) {
// filter to only notification rooms (and our current active room so we can index properly)
rooms = rooms.filter(room => {
return room.roomId === currentRoomId || Unread.doesRoomHaveUnreadMessages(room);
});
}
const currentIndex = rooms.findIndex(room => room.roomId === currentRoomId);
// use slice to account for looping around the start
const [room] = rooms.slice((currentIndex + payload.delta) % rooms.length);
if (room) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
});
}
break;
}
}
},
@ -596,8 +647,13 @@ export default createReactClass({
// case insensitive if room name includes filter,
// or if starts with `#` and one of room's aliases starts with filter
return list.filter((room) => {
if (filter[0] === "#" && room.getAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))) {
return true;
if (filter[0] === "#") {
if (room.getCanonicalAlias() && room.getCanonicalAlias().toLowerCase().startsWith(lcFilter)) {
return true;
}
if (room.getAltAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))) {
return true;
}
}
return room.name && utils.removeHiddenChars(room.name.toLowerCase()).toLowerCase().includes(fuzzyFilter);
});
@ -700,13 +756,11 @@ export default createReactClass({
list: [],
extraTiles: this._makeGroupInviteTiles(this.props.searchFilter),
label: _t('Community Invites'),
order: "recent",
isInvite: true,
},
{
list: this.state.lists['im.vector.fake.invite'],
label: _t('Invites'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.invite'),
isInvite: true,
},
@ -714,22 +768,19 @@ export default createReactClass({
list: this.state.lists['m.favourite'],
label: _t('Favourites'),
tagName: "m.favourite",
order: "manual",
incomingCall: incomingCallIfTaggedAs('m.favourite'),
},
{
list: this.state.lists['im.vector.fake.direct'],
list: this.state.lists[TAG_DM],
label: _t('Direct Messages'),
tagName: "im.vector.fake.direct",
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
tagName: TAG_DM,
incomingCall: incomingCallIfTaggedAs(TAG_DM),
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
addRoomLabel: _t("Start chat"),
},
{
list: this.state.lists['im.vector.fake.recent'],
label: _t('Rooms'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
onAddRoom: () => {dis.dispatch({action: 'view_create_room'});},
},
@ -744,7 +795,6 @@ export default createReactClass({
key: tagName,
label: labelForTagName(tagName),
tagName: tagName,
order: "manual",
incomingCall: incomingCallIfTaggedAs(tagName),
};
});
@ -754,13 +804,11 @@ export default createReactClass({
list: this.state.lists['m.lowpriority'],
label: _t('Low priority'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.lowpriority'),
},
{
list: this.state.lists['im.vector.fake.archived'],
label: _t('Historical'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.archived'),
startAsHidden: true,
showSpinner: this.state.isLoadingLeftRooms,
@ -770,7 +818,6 @@ export default createReactClass({
list: this.state.lists['m.server_notice'],
label: _t('System Alerts'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.server_notice'),
},
]);
@ -787,6 +834,9 @@ export default createReactClass({
className="mx_RoomList"
role="tree"
aria-label={_t("Rooms")}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex="-1"
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
>

View file

@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import classNames from 'classnames';
@ -225,15 +225,34 @@ export default createReactClass({
case 'feature_custom_status_changed':
this.forceUpdate();
break;
case 'view_room':
// when the room is selected make sure its tile is visible, for breadcrumbs/keyboard shortcut access
if (payload.room_id === this.props.room.roomId) {
this._scrollIntoView();
}
break;
}
},
_scrollIntoView: function() {
if (!this._roomTile.current) return;
this._roomTile.current.scrollIntoView({
block: "nearest",
behavior: "auto",
});
},
_onActiveRoomChange: function() {
this.setState({
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
});
},
UNSAFE_componentWillMount: function() {
this._roomTile = createRef();
},
componentDidMount: function() {
/* We bind here rather than in the definition because otherwise we wind up with the
method only being callable once every 500ms across all instances, which would be wrong */
@ -257,6 +276,11 @@ export default createReactClass({
statusUser.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
}
// when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
if (this.state.selected) {
this._scrollIntoView();
}
},
componentWillUnmount: function() {
@ -490,16 +514,16 @@ export default createReactClass({
height="13"
alt="dm"
/>;
}
const { room } = this.props;
const member = room.getMember(dmUserId);
if (
member && member.membership === "join" && room.getJoinedMemberCount() === 2 &&
SettingsStore.isFeatureEnabled("feature_presence_in_room_list")
) {
const UserOnlineDot = sdk.getComponent('rooms.UserOnlineDot');
dmOnline = <UserOnlineDot userId={dmUserId} />;
}
const { room } = this.props;
const member = room.getMember(dmUserId);
if (
member && member.membership === "join" && room.getJoinedMemberCount() === 2 &&
SettingsStore.isFeatureEnabled("feature_presence_in_room_list")
) {
const UserOnlineDot = sdk.getComponent('rooms.UserOnlineDot');
dmOnline = <UserOnlineDot userId={dmUserId} />;
}
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
@ -538,7 +562,7 @@ export default createReactClass({
}
return <React.Fragment>
<RovingTabIndexWrapper>
<RovingTabIndexWrapper inputRef={this._roomTile}>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}

View file

@ -1,186 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 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.
*/
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import * as sdk from "../../../index";
import { _t } from '../../../languageHandler';
// A list capable of displaying entities which conform to the SearchableEntity
// interface which is an object containing getJsx(): Jsx and matches(query: string): boolean
const SearchableEntityList = createReactClass({
displayName: 'SearchableEntityList',
propTypes: {
emptyQueryShowsAll: PropTypes.bool,
showInputBox: PropTypes.bool,
onQueryChanged: PropTypes.func, // fn(inputText)
onSubmit: PropTypes.func, // fn(inputText)
entities: PropTypes.array,
truncateAt: PropTypes.number,
},
getDefaultProps: function() {
return {
showInputBox: true,
entities: [],
emptyQueryShowsAll: false,
onSubmit: function() {},
onQueryChanged: function(input) {},
};
},
getInitialState: function() {
return {
query: "",
focused: false,
truncateAt: this.props.truncateAt,
results: this.getSearchResults("", this.props.entities),
};
},
componentWillReceiveProps: function(newProps) {
// recalculate the search results in case we got new entities
this.setState({
results: this.getSearchResults(this.state.query, newProps.entities),
});
},
componentWillUnmount: function() {
// pretend the query box was blanked out else filters could still be
// applied to other components which rely on onQueryChanged.
this.props.onQueryChanged("");
},
/**
* Public-facing method to set the input query text to the given input.
* @param {string} input
*/
setQuery: function(input) {
this.setState({
query: input,
results: this.getSearchResults(input, this.props.entities),
});
},
onQueryChanged: function(ev) {
const q = ev.target.value;
this.setState({
query: q,
// reset truncation if they back out the entire text
truncateAt: (q.length === 0 ? this.props.truncateAt : this.state.truncateAt),
results: this.getSearchResults(q, this.props.entities),
}, () => {
// invoke the callback AFTER we've flushed the new state. We need to
// do this because onQueryChanged can result in new props being passed
// to this component, which will then try to recalculate the search
// list. If we do this without flushing, we'll recalc with the last
// search term and not the current one!
this.props.onQueryChanged(q);
});
},
onQuerySubmit: function(ev) {
ev.preventDefault();
this.props.onSubmit(this.state.query);
},
getSearchResults: function(query, entities) {
if (!query || query.length === 0) {
return this.props.emptyQueryShowsAll ? entities : [];
}
return entities.filter(function(e) {
return e.matches(query);
});
},
_showAll: function() {
this.setState({
truncateAt: -1,
});
},
_createOverflowEntity: function(overflowCount, totalCount) {
const EntityTile = sdk.getComponent("rooms.EntityTile");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={this._showAll} />
);
},
render: function() {
let inputBox;
if (this.props.showInputBox) {
inputBox = (
<form onSubmit={this.onQuerySubmit} autoComplete="off">
<input className="mx_SearchableEntityList_query" id="mx_SearchableEntityList_query" type="text"
onChange={this.onQueryChanged} value={this.state.query}
onFocus= {() => { this.setState({ focused: true }); }}
onBlur= {() => { this.setState({ focused: false }); }}
placeholder={_t("Search")} />
</form>
);
}
let list;
if (this.state.results.length > 1 || this.state.focused) {
if (this.props.truncateAt) { // caller wants list truncated
const TruncatedList = sdk.getComponent("elements.TruncatedList");
list = (
<TruncatedList className="mx_SearchableEntityList_list"
truncateAt={this.state.truncateAt} // use state truncation as it may be expanded
createOverflowElement={this._createOverflowEntity}>
{ this.state.results.map((entity) => {
return entity.getJsx();
}) }
</TruncatedList>
);
} else {
list = (
<div className="mx_SearchableEntityList_list">
{ this.state.results.map((entity) => {
return entity.getJsx();
}) }
</div>
);
}
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
list = (
<GeminiScrollbarWrapper autoshow={true}
className="mx_SearchableEntityList_listWrapper">
{ list }
</GeminiScrollbarWrapper>
);
}
return (
<div className={"mx_SearchableEntityList " + (list ? "mx_SearchableEntityList_expanded" : "")}>
{ inputBox }
{ list }
{ list ? <div className="mx_SearchableEntityList_hrWrapper"><hr /></div> : '' }
</div>
);
},
});
export default SearchableEntityList;

View file

@ -42,6 +42,8 @@ import {_t, _td} from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages';
import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RateLimitedFunc from '../../../ratelimitedfunc';
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -102,6 +104,12 @@ export default class SendMessageComposer extends React.Component {
this.model = null;
this._editorRef = null;
this.currentlyComposedEditorState = null;
const cli = MatrixClientPeg.get();
if (cli.isCryptoEnabled() && cli.isRoomEncrypted(this.props.room.roomId)) {
this._prepareToEncrypt = new RateLimitedFunc(() => {
cli.prepareToEncrypt(this.props.room);
}, 60000);
}
}
_setEditorRef = ref => {
@ -121,14 +129,18 @@ export default class SendMessageComposer extends React.Component {
this.onVerticalArrow(event, true);
} else if (event.key === Key.ARROW_DOWN) {
this.onVerticalArrow(event, false);
} else if (this._prepareToEncrypt) {
this._prepareToEncrypt();
}
}
onVerticalArrow(e, up) {
if (e.ctrlKey || e.shiftKey || e.metaKey) return;
// arrows from an initial-caret composer navigates recent messages to edit
// ctrl-alt-arrows navigate send history
if (e.shiftKey || e.metaKey) return;
const shouldSelectHistory = e.altKey;
const shouldEditLastMessage = !e.altKey && up && !RoomViewStore.getQuotingEvent();
const shouldSelectHistory = e.altKey && e.ctrlKey;
const shouldEditLastMessage = !e.altKey && !e.ctrlKey && up && !RoomViewStore.getQuotingEvent();
if (shouldSelectHistory) {
// Try select composer history

View file

@ -27,6 +27,7 @@ export default createReactClass({
propTypes: {
onScrollUpClick: PropTypes.func,
onCloseClick: PropTypes.func,
},
render: function() {
@ -36,6 +37,10 @@ export default createReactClass({
title={_t('Jump to first unread message.')}
onClick={this.props.onScrollUpClick}>
</AccessibleButton>
<AccessibleButton className="mx_TopUnreadMessagesBar_markAsRead"
title={_t('Mark all as read')}
onClick={this.props.onCloseClick}>
</AccessibleButton>
</div>
);
},

View file

@ -32,7 +32,10 @@ export default class CrossSigningPanel extends React.PureComponent {
error: null,
crossSigningPublicKeysOnDevice: false,
crossSigningPrivateKeysInStorage: false,
selfSigningPrivateKeyCached: false,
userSigningPrivateKeyCached: false,
secretStorageKeyInAccount: false,
secretStorageKeyNeedsUpgrade: null,
};
}
@ -60,26 +63,38 @@ export default class CrossSigningPanel extends React.PureComponent {
}
};
_onBootstrapClick = () => {
this._bootstrapSecureSecretStorage(false);
};
onStatusChanged = () => {
this._getUpdatedStatus();
};
async _getUpdatedStatus() {
// XXX: Add public accessors if we keep this around in production
const cli = MatrixClientPeg.get();
const pkCache = cli.getCrossSigningCacheCallbacks();
const crossSigning = cli._crypto._crossSigningInfo;
const secretStorage = cli._crypto._secretStorage;
const crossSigningPublicKeysOnDevice = crossSigning.getId();
const crossSigningPrivateKeysInStorage = await crossSigning.isStoredInSecretStorage(secretStorage);
const selfSigningPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("self_signing"));
const userSigningPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("user_signing"));
const secretStorageKeyInAccount = await secretStorage.hasKey();
const homeserverSupportsCrossSigning =
await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
const crossSigningReady = await cli.isCrossSigningReady();
const secretStorageKeyNeedsUpgrade = await cli.secretStorageKeyNeedsUpgrade();
this.setState({
crossSigningPublicKeysOnDevice,
crossSigningPrivateKeysInStorage,
selfSigningPrivateKeyCached,
userSigningPrivateKeyCached,
secretStorageKeyInAccount,
homeserverSupportsCrossSigning,
crossSigningReady,
secretStorageKeyNeedsUpgrade,
});
}
@ -122,8 +137,12 @@ export default class CrossSigningPanel extends React.PureComponent {
error,
crossSigningPublicKeysOnDevice,
crossSigningPrivateKeysInStorage,
selfSigningPrivateKeyCached,
userSigningPrivateKeyCached,
secretStorageKeyInAccount,
homeserverSupportsCrossSigning,
crossSigningReady,
secretStorageKeyNeedsUpgrade,
} = this.state;
let errorSection;
@ -139,11 +158,14 @@ export default class CrossSigningPanel extends React.PureComponent {
);
let summarisedStatus;
if (!homeserverSupportsCrossSigning) {
if (homeserverSupportsCrossSigning === undefined) {
const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner');
summarisedStatus = <p><InlineSpinner /></p>;
} else if (!homeserverSupportsCrossSigning) {
summarisedStatus = <p>{_t(
"Your homeserver does not support cross-signing.",
)}</p>;
} else if (enabledForAccount && crossSigningPublicKeysOnDevice) {
} else if (crossSigningReady) {
summarisedStatus = <p> {_t(
"Cross-signing and secret storage are enabled.",
)}</p>;
@ -158,18 +180,27 @@ export default class CrossSigningPanel extends React.PureComponent {
)}</p>;
}
let bootstrapButton;
let resetButton;
if (enabledForAccount) {
bootstrapButton = (
<AccessibleButton kind="danger" onClick={this._destroySecureSecretStorage}>
{_t("Reset cross-signing and secret storage")}
</AccessibleButton>
resetButton = (
<div className="mx_CrossSigningPanel_buttonRow">
<AccessibleButton kind="danger" onClick={this._destroySecureSecretStorage}>
{_t("Reset cross-signing and secret storage")}
</AccessibleButton>
</div>
);
} else if (!enabledForAccount && homeserverSupportsCrossSigning) {
}
let bootstrapButton;
if (
(!enabledForAccount || !crossSigningPublicKeysOnDevice) &&
homeserverSupportsCrossSigning
) {
bootstrapButton = (
<AccessibleButton kind="primary" onClick={this._bootstrapSecureSecretStorage}>
{_t("Bootstrap cross-signing and secret storage")}
</AccessibleButton>
<div className="mx_CrossSigningPanel_buttonRow">
<AccessibleButton kind="primary" onClick={this._onBootstrapClick}>
{_t("Bootstrap cross-signing and secret storage")}
</AccessibleButton>
</div>
);
}
@ -187,6 +218,14 @@ export default class CrossSigningPanel extends React.PureComponent {
<td>{_t("Cross-signing private keys:")}</td>
<td>{crossSigningPrivateKeysInStorage ? _t("in secret storage") : _t("not found")}</td>
</tr>
<tr>
<td>{_t("Self signing private key:")}</td>
<td>{selfSigningPrivateKeyCached ? _t("cached locally") : _t("not found locally")}</td>
</tr>
<tr>
<td>{_t("User signing private key:")}</td>
<td>{userSigningPrivateKeyCached ? _t("cached locally") : _t("not found locally")}</td>
</tr>
<tr>
<td>{_t("Secret storage public key:")}</td>
<td>{secretStorageKeyInAccount ? _t("in account data") : _t("not found")}</td>
@ -195,12 +234,15 @@ export default class CrossSigningPanel extends React.PureComponent {
<td>{_t("Homeserver feature support:")}</td>
<td>{homeserverSupportsCrossSigning ? _t("exists") : _t("not found")}</td>
</tr>
<tr>
<td>{_t("Secret Storage key format:")}</td>
<td>{secretStorageKeyNeedsUpgrade ? _t("outdated") : _t("up to date")}</td>
</tr>
</tbody></table>
</details>
{errorSection}
<div className="mx_CrossSigningPanel_buttonRow">
{bootstrapButton}
</div>
{bootstrapButton}
{resetButton}
</div>
);
}

View file

@ -277,25 +277,25 @@ export default class KeyBackupPanel extends React.PureComponent {
"Backup has an <validity>invalid</validity> signature from this session",
{}, { validity },
);
} else if (sig.valid && sig.device.isVerified()) {
} else if (sig.valid && sig.deviceTrust.isVerified()) {
sigStatus = _t(
"Backup has a <validity>valid</validity> signature from " +
"<verify>verified</verify> session <device></device>",
{}, { validity, verify, device },
);
} else if (sig.valid && !sig.device.isVerified()) {
} else if (sig.valid && !sig.deviceTrust.isVerified()) {
sigStatus = _t(
"Backup has a <validity>valid</validity> signature from " +
"<verify>unverified</verify> session <device></device>",
{}, { validity, verify, device },
);
} else if (!sig.valid && sig.device.isVerified()) {
} else if (!sig.valid && sig.deviceTrust.isVerified()) {
sigStatus = _t(
"Backup has an <validity>invalid</validity> signature from " +
"<verify>verified</verify> session <device></device>",
{}, { validity, verify, device },
);
} else if (!sig.valid && !sig.device.isVerified()) {
} else if (!sig.valid && !sig.deviceTrust.isVerified()) {
sigStatus = _t(
"Backup has an <validity>invalid</validity> signature from " +
"<verify>unverified</verify> session <device></device>",

View file

@ -21,7 +21,6 @@ import RoomProfileSettings from "../../../room_settings/RoomProfileSettings";
import * as sdk from "../../../../..";
import AccessibleButton from "../../../elements/AccessibleButton";
import dis from "../../../../../dispatcher";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
export default class GeneralRoomSettingsTab extends React.Component {
@ -39,26 +38,6 @@ export default class GeneralRoomSettingsTab extends React.Component {
};
}
componentWillMount() {
this.context.getRoomDirectoryVisibility(this.props.roomId).then((result => {
this.setState({isRoomPublished: result.visibility === 'public'});
}));
}
onRoomPublishChange = (e) => {
const valueBefore = this.state.isRoomPublished;
const newValue = !valueBefore;
this.setState({isRoomPublished: newValue});
this.context.setRoomDirectoryVisibility(
this.props.roomId,
newValue ? 'public' : 'private',
).catch(() => {
// Roll back the local echo on the change
this.setState({isRoomPublished: valueBefore});
});
};
_onLeaveClick = () => {
dis.dispatch({
action: 'leave_room',
@ -75,7 +54,6 @@ export default class GeneralRoomSettingsTab extends React.Component {
const room = client.getRoom(this.props.roomId);
const canSetAliases = true; // Previously, we arbitrarily only allowed admins to do this
const canActuallySetAliases = room.currentState.mayClientSendStateEvent("m.room.aliases", client);
const canSetCanonical = room.currentState.mayClientSendStateEvent("m.room.canonical_alias", client);
const canonicalAliasEv = room.currentState.getStateEvents("m.room.canonical_alias", '');
const aliasEvents = room.currentState.getStateEvents("m.room.aliases");
@ -90,21 +68,13 @@ export default class GeneralRoomSettingsTab extends React.Component {
<RoomProfileSettings roomId={this.props.roomId} />
</div>
<span className='mx_SettingsTab_subheading'>{_t("Room Addresses")}</span>
<div className="mx_SettingsTab_heading">{_t("Room Addresses")}</div>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<AliasSettings roomId={this.props.roomId}
canSetCanonicalAlias={canSetCanonical} canSetAliases={canSetAliases}
canonicalAliasEvent={canonicalAliasEv} aliasEvents={aliasEvents} />
</div>
<div className='mx_SettingsTab_section'>
<LabelledToggleSwitch value={this.state.isRoomPublished}
onChange={this.onRoomPublishChange}
disabled={!canActuallySetAliases}
label={_t("Publish this room to the public in %(domain)s's room directory?", {
domain: client.getDomain(),
})} />
</div>
<div className="mx_SettingsTab_heading">{_t("Other")}</div>
<span className='mx_SettingsTab_subheading'>{_t("Flair")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<RelatedGroupSettings roomId={room.roomId}

View file

@ -61,6 +61,8 @@ export default class GeneralUserSettingsTab extends React.Component {
emails: [],
msisdns: [],
...this._calculateThemeState(),
customThemeUrl: "",
customThemeMessage: {isError: false, text: ""},
};
this.dispatcherRef = dis.register(this._onAction);
@ -274,6 +276,41 @@ export default class GeneralUserSettingsTab extends React.Component {
});
};
_onAddCustomTheme = async () => {
let currentThemes = SettingsStore.getValue("custom_themes");
if (!currentThemes) currentThemes = [];
currentThemes = currentThemes.map(c => c); // cheap clone
if (this._themeTimer) {
clearTimeout(this._themeTimer);
}
try {
const r = await fetch(this.state.customThemeUrl);
const themeInfo = await r.json();
if (!themeInfo || typeof(themeInfo['name']) !== 'string' || typeof(themeInfo['colors']) !== 'object') {
this.setState({customThemeMessage: {text: _t("Invalid theme schema."), isError: true}});
return;
}
currentThemes.push(themeInfo);
} catch (e) {
console.error(e);
this.setState({customThemeMessage: {text: _t("Error downloading theme information."), isError: true}});
return; // Don't continue on error
}
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
this.setState({customThemeUrl: "", customThemeMessage: {text: _t("Theme added!"), isError: false}});
this._themeTimer = setTimeout(() => {
this.setState({customThemeMessage: {text: "", isError: false}});
}, 3000);
};
_onCustomThemeChange = (e) => {
this.setState({customThemeUrl: e.target.value});
};
_renderProfileSection() {
return (
<div className="mx_SettingsTab_section">
@ -368,6 +405,45 @@ export default class GeneralUserSettingsTab extends React.Component {
/>
</div>;
}
let customThemeForm;
if (SettingsStore.isFeatureEnabled("feature_custom_themes")) {
let messageElement = null;
if (this.state.customThemeMessage.text) {
if (this.state.customThemeMessage.isError) {
messageElement = <div className='text-error'>{this.state.customThemeMessage.text}</div>;
} else {
messageElement = <div className='text-success'>{this.state.customThemeMessage.text}</div>;
}
}
customThemeForm = (
<div className='mx_SettingsTab_section'>
<form onSubmit={this._onAddCustomTheme}>
<Field
label={_t("Custom theme URL")}
type='text'
id='mx_GeneralUserSettingsTab_customThemeInput'
autoComplete="off"
onChange={this._onCustomThemeChange}
value={this.state.customThemeUrl}
/>
<AccessibleButton
onClick={this._onAddCustomTheme}
type="submit" kind="primary_sm"
disabled={!this.state.customThemeUrl.trim()}
>{_t("Add theme")}</AccessibleButton>
{messageElement}
</form>
</div>
);
}
const themes = Object.entries(enumerateThemes())
.map(p => ({id: p[0], name: p[1]})); // convert pairs to objects for code readability
const builtInThemes = themes.filter(p => !p.id.startsWith("custom-"));
const customThemes = themes.filter(p => !builtInThemes.includes(p))
.sort((a, b) => a.name.localeCompare(b.name));
const orderedThemes = [...builtInThemes, ...customThemes];
return (
<div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_themeSection">
<span className="mx_SettingsTab_subheading">{_t("Theme")}</span>
@ -376,10 +452,11 @@ export default class GeneralUserSettingsTab extends React.Component {
value={this.state.theme} onChange={this._onThemeChange}
disabled={this.state.useSystemTheme}
>
{Object.entries(enumerateThemes()).map(([theme, text]) => {
return <option key={theme} value={theme}>{text}</option>;
{orderedThemes.map(theme => {
return <option key={theme.id} value={theme.id}>{theme.name}</option>;
})}
</Field>
{customThemeForm}
<SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} />
</div>
);

View file

@ -24,21 +24,7 @@ import createRoom from "../../../../../createRoom";
import Modal from "../../../../../Modal";
import * as sdk from "../../../../../";
import PlatformPeg from "../../../../../PlatformPeg";
// Simple method to help prettify GH Release Tags and Commit Hashes.
const semVerRegex = /^v?(\d+\.\d+\.\d+(?:-rc.+)?)(?:-(?:\d+-g)?([0-9a-fA-F]+))?(?:-dirty)?$/i;
const ghVersionLabel = function(repo, token='') {
const match = token.match(semVerRegex);
let url;
if (match && match[1]) { // basic semVer string possibly with commit hash
url = (match.length > 1 && match[2])
? `https://github.com/${repo}/commit/${match[2]}`
: `https://github.com/${repo}/releases/tag/v${match[1]}`;
} else {
url = `https://github.com/${repo}/commit/${token.split('-')[0]}`;
}
return <a target="_blank" rel="noreferrer noopener" href={url}>{ token }</a>;
};
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
export default class HelpUserSettingsTab extends React.Component {
static propTypes = {
@ -163,7 +149,7 @@ export default class HelpUserSettingsTab extends React.Component {
render() {
let faqText = _t('For help with using Riot, click <a>here</a>.', {}, {
'a': (sub) =>
<a href="https://about.riot.im/need-help/" rel='noreferrer noopener' target='_blank'>{sub}</a>,
<a href="https://about.riot.im/need-help/" rel="noreferrer noopener" target="_blank">{sub}</a>,
});
if (SdkConfig.get().welcomeUserId && getCurrentLanguage().startsWith('en')) {
faqText = (
@ -184,9 +170,7 @@ export default class HelpUserSettingsTab extends React.Component {
);
}
const vectorVersion = this.state.vectorVersion
? ghVersionLabel('vector-im/riot-web', this.state.vectorVersion)
: 'unknown';
const vectorVersion = this.state.vectorVersion || 'unknown';
let olmVersion = MatrixClientPeg.get().olmVersion;
olmVersion = olmVersion ? `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}` : '<not-enabled>';
@ -225,6 +209,15 @@ export default class HelpUserSettingsTab extends React.Component {
{_t("Clear cache and reload")}
</AccessibleButton>
</div>
{
_t( "To report a Matrix-related security issue, please read the Matrix.org " +
"<a>Security Disclosure Policy</a>.", {},
{
'a': (sub) =>
<a href="https://matrix.org/security-disclosure-policy/"
rel="noreferrer noopener" target="_blank">{sub}</a>,
})
}
</div>
</div>
<div className='mx_SettingsTab_section'>
@ -232,6 +225,9 @@ export default class HelpUserSettingsTab extends React.Component {
<div className='mx_SettingsTab_subsectionText'>
{faqText}
</div>
<AccessibleButton kind="primary" onClick={KeyboardShortcuts.toggleDialog}>
{ _t("Keyboard Shortcuts") }
</AccessibleButton>
</div>
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
<span className='mx_SettingsTab_subheading'>{_t("Versions")}</span>

View file

@ -25,6 +25,12 @@ import * as sdk from "../../../../..";
import PlatformPeg from "../../../../../PlatformPeg";
export default class PreferencesUserSettingsTab extends React.Component {
static ROOM_LIST_SETTINGS = [
'RoomList.orderAlphabetically',
'RoomList.orderByImportance',
'breadcrumbs',
];
static COMPOSER_SETTINGS = [
'MessageComposerInput.autoReplaceEmoji',
'MessageComposerInput.suggestEmoji',
@ -47,11 +53,6 @@ export default class PreferencesUserSettingsTab extends React.Component {
'showImages',
];
static ROOM_LIST_SETTINGS = [
'RoomList.orderByImportance',
'breadcrumbs',
];
static ADVANCED_SETTINGS = [
'alwaysShowEncryptionIcons',
'Pill.shouldShowPillAvatar',
@ -173,15 +174,21 @@ export default class PreferencesUserSettingsTab extends React.Component {
<div className="mx_SettingsTab_heading">{_t("Preferences")}</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Composer")}</span>
{this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
<span className="mx_SettingsTab_subheading">{_t("Timeline")}</span>
{this._renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
<span className="mx_SettingsTab_subheading">{_t("Room list")}</span>
{this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Composer")}</span>
{this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Timeline")}</span>
{this._renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Advanced")}</span>
{this._renderGroup(PreferencesUserSettingsTab.ADVANCED_SETTINGS)}
{minimizeToTrayOption}

View file

@ -18,7 +18,9 @@ import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from "../../../index";
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import DeviceListener from '../../../DeviceListener';
import SetupEncryptionDialog from "../dialogs/SetupEncryptionDialog";
import { accessSecretStorage } from '../../../CrossSigningManager';
export default class SetupEncryptionToast extends React.PureComponent {
@ -32,7 +34,12 @@ export default class SetupEncryptionToast extends React.PureComponent {
};
_onSetupClick = async () => {
accessSecretStorage();
if (this.props.kind === "verify_this_session") {
Modal.createTrackedDialog('Verify session', 'Verify session', SetupEncryptionDialog,
{}, null, /* priority = */ false, /* static = */ true);
} else {
accessSecretStorage();
}
};
getDescription() {

View file

@ -42,6 +42,12 @@ export default class UnverifiedSessionToast extends React.PureComponent {
Modal.createTrackedDialog('New Session Review', 'Starting dialog', NewSessionReviewDialog, {
userId: MatrixClientPeg.get().getUserId(),
device,
onFinished: (r) => {
if (!r) {
/* This'll come back false if the user clicks "this wasn't me" and saw a warning dialog */
this._onLaterClick();
}
},
}, null, /* priority = */ false, /* static = */ true);
};

View file

@ -78,7 +78,6 @@ export default class VerificationRequestToast extends React.PureComponent {
// no room id for to_device requests
const cli = MatrixClientPeg.get();
try {
await request.accept();
if (request.channel.roomId) {
dis.dispatch({
action: 'view_room',
@ -99,6 +98,7 @@ export default class VerificationRequestToast extends React.PureComponent {
verificationRequest: request,
}, null, /* priority = */ false, /* static = */ true);
}
await request.accept();
} catch (err) {
console.error(err.message);
}

View file

@ -48,6 +48,11 @@ export default class VerificationShowSas extends React.Component {
this.props.onDone();
};
onDontMatchClick = () => {
this.setState({ cancelling: true });
this.props.onCancel();
};
render() {
let sasDisplay;
let sasCaption;
@ -98,9 +103,14 @@ export default class VerificationShowSas extends React.Component {
}
let confirm;
if (this.state.pending) {
const {displayName} = this.props;
const text = _t("Waiting for %(displayName)s to verify…", {displayName});
if (this.state.pending || this.state.cancelling) {
let text;
if (this.state.pending) {
const {displayName} = this.props;
text = _t("Waiting for %(displayName)s to verify…", {displayName});
} else {
text = _t("Cancelling…");
}
confirm = <PendingActionSpinner text={text} />;
} else {
// FIXME: stop using DialogButtons here once this component is only used in the right panel verification
@ -109,7 +119,7 @@ export default class VerificationShowSas extends React.Component {
onPrimaryButtonClick={this.onMatchClick}
primaryButtonClass="mx_UserInfo_wideButton"
cancelButton={_t("They don't match")}
onCancel={this.props.onCancel}
onCancel={this.onDontMatchClick}
cancelButtonClass="mx_UserInfo_wideButton"
/>;
}