Merge branches 'develop' and 't3chguy/accesibility' of github.com:matrix-org/matrix-react-sdk into t3chguy/accesibility

This commit is contained in:
Michael Telatynski 2019-09-25 16:59:46 +01:00
commit ab3e5c3b87
27 changed files with 596 additions and 124 deletions

View file

@ -99,6 +99,7 @@
@import "./views/elements/_ResizeHandle.scss";
@import "./views/elements/_RichText.scss";
@import "./views/elements/_RoleButton.scss";
@import "./views/elements/_RoomAliasField.scss";
@import "./views/elements/_Spinner.scss";
@import "./views/elements/_SyntaxHighlight.scss";
@import "./views/elements/_TextWithTooltip.scss";

View file

@ -14,8 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CreateRoomDialog_details_summary {
outline: none;
.mx_CreateRoomDialog_details {
.mx_CreateRoomDialog_details_summary {
outline: none;
list-style: none;
font-weight: 600;
cursor: pointer;
color: $accent-color;
// list-style doesn't do it for webkit
&::-webkit-details-marker {
display: none;
}
}
> div {
display: flex;
align-items: start;
margin: 5px 0;
input[type=checkbox] {
margin-right: 10px;
}
}
}
.mx_CreateRoomDialog_label {
@ -36,3 +57,38 @@ limitations under the License.
background-color: $primary-bg-color;
width: 100%;
}
// needed to make the alias field only grow as wide as needed
// as opposed to full width
.mx_CreateRoomDialog_aliasContainer {
display: flex;
// put margin on container so it can collapse with siblings
margin: 10px 0;
.mx_RoomAliasField {
margin: 0;
}
}
.mx_CreateRoomDialog {
&.mx_Dialog_fixedWidth {
width: 450px;
}
.mx_SettingsFlag {
display: flex;
}
.mx_SettingsFlag_label {
flex: 1 1 0;
min-width: 0;
font-weight: 600;
}
.mx_ToggleSwitch {
flex: 0 0 auto;
margin-left: 30px;
}
}

View file

@ -31,6 +31,10 @@ limitations under the License.
border-right: 1px solid $input-border-color;
}
.mx_Field_postfix {
border-left: 1px solid $input-border-color;
}
.mx_Field input,
.mx_Field select,
.mx_Field textarea {

View file

@ -0,0 +1,56 @@
/*
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.
*/
.mx_RoomAliasField {
// if parent is a flex container, this allows the
// width to be as wide as needed, and not 100%
flex: 0 1 auto;
display: flex;
align-items: stretch;
min-width: 0;
max-width: 100%;
input {
width: 150px;
padding-left: 0;
padding-right: 0;
}
input::placeholder {
color: $greyed-fg-color;
font-weight: normal;
}
.mx_Field_prefix, .mx_Field_postfix {
color: $greyed-fg-color;
border-left: none;
border-right: none;
font-weight: 600;
padding: 9px 10px;
flex: 0 0 auto;
}
.mx_Field_postfix {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
// this allows the domain name to show
// as long as it doesn't make the input shrink
// if it's too big, it shows an ellipsis
// 180: 28 for prefix, 152 for input
max-width: calc(100% - 180px);
}
}

View file

@ -23,6 +23,9 @@ limitations under the License.
border-radius: 4px;
background-color: $message-action-bar-bg-color;
user-select: none;
// equal to z-index of mx_ReplyPreview and mx_RoomView_statusArea (1000)
// but as it appears after them in the DOM, will appear on top.
z-index: 1000;
&.mx_MessageComposerFormatBar_shown {
display: block;

View file

@ -35,6 +35,8 @@ import IdentityAuthClient from './IdentityAuthClient';
export default class AddThreepid {
constructor() {
this.clientSecret = MatrixClientPeg.get().generateClientSecret();
this.sessionId = null;
this.submitUrl = null;
}
/**
@ -101,6 +103,7 @@ export default class AddThreepid {
phoneCountry, phoneNumber, this.clientSecret, 1,
).then((res) => {
this.sessionId = res.sid;
this.submitUrl = res.submit_url;
return res;
}, function(err) {
if (err.errcode === 'M_THREEPID_IN_USE') {
@ -197,13 +200,23 @@ export default class AddThreepid {
*/
async haveMsisdnToken(msisdnToken) {
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
const result = await MatrixClientPeg.get().submitMsisdnToken(
this.sessionId,
this.clientSecret,
msisdnToken,
identityAccessToken,
);
let result;
if (this.submitUrl) {
result = await MatrixClientPeg.get().submitMsisdnTokenOtherUrl(
this.submitUrl,
this.sessionId,
this.clientSecret,
msisdnToken,
);
} else {
result = await MatrixClientPeg.get().submitMsisdnToken(
this.sessionId,
this.clientSecret,
msisdnToken,
await authClient.getAccessToken(),
);
}
if (result.errcode) {
throw result;
}
@ -211,13 +224,11 @@ export default class AddThreepid {
const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
if (this.bind) {
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
await MatrixClientPeg.get().bindThreePid({
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: identityServerDomain,
id_access_token: identityAccessToken,
id_access_token: await authClient.getAccessToken(),
});
} else {
await MatrixClientPeg.get().addThreePidOnly({

View file

@ -1,5 +1,6 @@
/*
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.
@ -72,15 +73,21 @@ class PasswordReset {
* with a "message" property which contains a human-readable message detailing why
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
*/
checkEmailLinkClicked() {
return this.client.setPassword({
type: "m.login.email.identity",
threepid_creds: {
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: this.identityServerDomain,
},
}, this.password).catch(function(err) {
async checkEmailLinkClicked() {
const creds = {
sid: this.sessionId,
client_secret: this.clientSecret,
};
if (await this.doesServerRequireIdServerParam()) {
creds.id_server = this.identityServerDomain;
}
try {
await this.client.setPassword({
type: "m.login.email.identity",
threepid_creds: creds,
}, this.password);
} catch (err) {
if (err.httpStatus === 401) {
err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
} else if (err.httpStatus === 404) {
@ -90,7 +97,7 @@ class PasswordReset {
err.message += ` (Status ${err.httpStatus})`;
}
throw err;
});
}
}
}

View file

@ -64,6 +64,7 @@ export default class CommandProvider extends AutocompleteProvider {
return matches.map((result) => ({
// If the command is the same as the one they entered, we don't want to discard their arguments
completion: result.command === command[1] ? command[0] : (result.command + ' '),
type: "command",
component: <TextualCompletion
title={result.command}
subtitle={result.args}

View file

@ -84,6 +84,7 @@ export default class CommunityProvider extends AutocompleteProvider {
]).map(({avatarUrl, groupId, name}) => ({
completion: groupId,
suffix: ' ',
type: "community",
href: makeGroupPermalink(groupId),
component: (
<PillCompletion initialComponent={

View file

@ -42,6 +42,7 @@ export default class NotifProvider extends AutocompleteProvider {
return [{
completion: '@room',
completionId: '@room',
type: "at-room",
suffix: ' ',
component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />

View file

@ -89,6 +89,7 @@ export default class RoomProvider extends AutocompleteProvider {
return {
completion: displayAlias,
completionId: displayAlias,
type: "room",
suffix: ' ',
href: makeRoomPermalink(displayAlias),
component: (

View file

@ -114,6 +114,7 @@ export default class UserProvider extends AutocompleteProvider {
// relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName,
completionId: user.userId,
type: "user",
suffix: (selection.beginning && range.start === 0) ? ': ' : ' ',
href: makeUserPermalink(user.userId),
component: (

View file

@ -167,6 +167,7 @@ export default class ContextualMenu extends React.Component {
const menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_HiddenFocusable': true, // hide browser outline
'mx_ContextualMenu_left': !hasChevron && position.left,
'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top,

View file

@ -271,6 +271,10 @@ export default createReactClass({
this.focusComposer = false;
// object field used for tracking the status info appended to the title tag.
// we don't do it as react state as i'm scared about triggering needless react refreshes.
this.subTitleStatus = '';
// this can technically be done anywhere but doing this here keeps all
// the routing url path logic together.
if (this.onAliasClick) {
@ -870,9 +874,10 @@ export default createReactClass({
if (roomInfo.event_id && roomInfo.highlighted) {
presentedId += "/" + roomInfo.event_id;
}
this.notifyNewScreen('room/' + presentedId);
newState.ready = true;
this.setState(newState);
this.setState(newState, ()=>{
this.notifyNewScreen('room/' + presentedId);
});
});
},
@ -962,11 +967,8 @@ export default createReactClass({
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog);
const [shouldCreate, name, noFederate] = await modal.finished;
const [shouldCreate, createOpts] = await modal.finished;
if (shouldCreate) {
const createOpts = {};
if (name) createOpts.name = name;
if (noFederate) createOpts.creation_content = {'m.federate': false};
createRoom({createOpts}).done();
}
},
@ -1300,6 +1302,7 @@ export default createReactClass({
collapsedRhs: false,
currentRoomId: null,
});
this.subTitleStatus = '';
this._setPageSubtitle();
},
@ -1315,6 +1318,7 @@ export default createReactClass({
collapsedRhs: false,
currentRoomId: null,
});
this.subTitleStatus = '';
this._setPageSubtitle();
},
@ -1709,6 +1713,7 @@ export default createReactClass({
if (this.props.onNewScreen) {
this.props.onNewScreen(screen);
}
this._setPageSubtitle();
},
onAliasClick: function(event, alias) {
@ -1824,7 +1829,14 @@ export default createReactClass({
},
_setPageSubtitle: function(subtitle='') {
document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle}`;
if (this.state.currentRoomId) {
const client = MatrixClientPeg.get();
const room = client && client.getRoom(this.state.currentRoomId);
if (room) {
subtitle = `| ${ room.name } ${subtitle}`;
}
}
document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle} ${this.subTitleStatus}`;
},
updateStatusIndicator: function(state, prevState) {
@ -1835,15 +1847,15 @@ export default createReactClass({
PlatformPeg.get().setNotificationCount(notifCount);
}
let subtitle = '';
this.subTitleStatus = '';
if (state === "ERROR") {
subtitle += `[${_t("Offline")}] `;
this.subTitleStatus += `[${_t("Offline")}] `;
}
if (notifCount > 0) {
subtitle += `[${notifCount}]`;
this.subTitleStatus += `[${notifCount}]`;
}
this._setPageSubtitle(subtitle);
this._setPageSubtitle();
},
onCloseAllSettings() {

View file

@ -117,17 +117,18 @@ module.exports = createReactClass({
});
},
onVerify: function(ev) {
onVerify: async function(ev) {
ev.preventDefault();
if (!this.reset) {
console.error("onVerify called before submitPasswordReset!");
return;
}
this.reset.checkEmailLinkClicked().done((res) => {
try {
await this.reset.checkEmailLinkClicked();
this.setState({ phase: PHASE_DONE });
}, (err) => {
} catch (err) {
this.showErrorDialog(err.message);
});
}
},
onSubmitForm: async function(ev) {

View file

@ -420,6 +420,7 @@ export const MsisdnAuthEntry = createReactClass({
},
componentWillMount: function() {
this._submitUrl = null;
this._sid = null;
this._msisdn = null;
this._tokenBox = null;
@ -442,6 +443,7 @@ export const MsisdnAuthEntry = createReactClass({
this.props.clientSecret,
1, // TODO: Multiple send attempts?
).then((result) => {
this._submitUrl = result.submit_url;
this._sid = result.sid;
this._msisdn = result.msisdn;
});
@ -453,45 +455,52 @@ export const MsisdnAuthEntry = createReactClass({
});
},
_onFormSubmit: function(e) {
_onFormSubmit: async function(e) {
e.preventDefault();
if (this.state.token == '') return;
this.setState({
errorText: null,
});
this.setState({
errorText: null,
});
this.props.matrixClient.submitMsisdnToken(
this._sid, this.props.clientSecret, this.state.token,
).then((result) => {
if (result.success) {
const idServerParsedUrl = url.parse(
this.props.matrixClient.getIdentityServerUrl(),
try {
let result;
if (this._submitUrl) {
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
this._submitUrl, this._sid, this.props.clientSecret, this.state.token,
);
} else {
result = await this.props.matrixClient.submitMsisdnToken(
this._sid, this.props.clientSecret, this.state.token,
);
}
if (result.success) {
const creds = {
sid: this._sid,
client_secret: this.props.clientSecret,
};
if (await this.props.matrixClient.doesServerRequireIdServerParam()) {
const idServerParsedUrl = url.parse(
this.props.matrixClient.getIdentityServerUrl(),
);
creds.id_server = idServerParsedUrl.host;
}
this.props.submitAuthDict({
type: MsisdnAuthEntry.LOGIN_TYPE,
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/riot-web/issues/10312
threepid_creds: {
sid: this._sid,
client_secret: this.props.clientSecret,
id_server: idServerParsedUrl.host,
},
threepidCreds: {
sid: this._sid,
client_secret: this.props.clientSecret,
id_server: idServerParsedUrl.host,
},
threepid_creds: creds,
threepidCreds: creds,
});
} else {
this.setState({
errorText: _t("Token incorrect"),
});
}
}).catch((e) => {
} catch (e) {
this.props.fail(e);
console.log("Failed to submit msisdn token");
}).done();
}
},
render: function() {

View file

@ -19,7 +19,9 @@ import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import withValidation from '../elements/Validation';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default createReactClass({
displayName: 'CreateRoomDialog',
@ -27,47 +29,164 @@ export default createReactClass({
onFinished: PropTypes.func.isRequired,
},
componentWillMount: function() {
getInitialState() {
const config = SdkConfig.get();
// Dialog shows inverse of m.federate (noFederate) strict false check to skip undefined check (default = true)
this.defaultNoFederate = config.default_federate === false;
return {
isPublic: false,
name: "",
topic: "",
alias: "",
detailsOpen: false,
noFederate: config.default_federate === false,
nameIsValid: false,
};
},
onOk: function() {
this.props.onFinished(true, this.refs.textinput.value, this.refs.checkbox.checked);
_roomCreateOptions() {
const createOpts = {};
createOpts.name = this.state.name;
if (this.state.isPublic) {
createOpts.visibility = "public";
createOpts.preset = "public_chat";
// to prevent createRoom from enabling guest access
createOpts['initial_state'] = [];
const {alias} = this.state;
const localPart = alias.substr(1, alias.indexOf(":") - 1);
createOpts['room_alias_name'] = localPart;
}
if (this.state.topic) {
createOpts.topic = this.state.topic;
}
if (this.state.noFederate) {
createOpts.creation_content = {'m.federate': false};
}
return createOpts;
},
componentDidMount() {
this._detailsRef.addEventListener("toggle", this.onDetailsToggled);
// move focus to first field when showing dialog
this._nameFieldRef.focus();
},
componentWillUnmount() {
this._detailsRef.removeEventListener("toggle", this.onDetailsToggled);
},
onOk: async function() {
const activeElement = document.activeElement;
if (activeElement) {
activeElement.blur();
}
await this._nameFieldRef.validate({allowEmpty: false});
if (this._aliasFieldRef) {
await this._aliasFieldRef.validate({allowEmpty: false});
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.state.nameIsValid && (!this._aliasFieldRef || this._aliasFieldRef.isValid)) {
this.props.onFinished(true, this._roomCreateOptions());
} else {
let field;
if (!this.state.nameIsValid) {
field = this._nameFieldRef;
} else if (this._aliasFieldRef && !this._aliasFieldRef.isValid) {
field = this._aliasFieldRef;
}
if (field) {
field.focus();
field.validate({ allowEmpty: false, focused: true });
}
}
},
onCancel: function() {
this.props.onFinished(false);
},
onNameChange(ev) {
this.setState({name: ev.target.value});
},
onTopicChange(ev) {
this.setState({topic: ev.target.value});
},
onPublicChange(isPublic) {
this.setState({isPublic});
},
onAliasChange(alias) {
this.setState({alias});
},
onDetailsToggled(ev) {
this.setState({detailsOpen: ev.target.open});
},
onNoFederateChange(noFederate) {
this.setState({noFederate});
},
collectDetailsRef(ref) {
this._detailsRef = ref;
},
async onNameValidate(fieldState) {
const result = await this._validateRoomName(fieldState);
this.setState({nameIsValid: result.valid});
return result;
},
_validateRoomName: withValidation({
rules: [
{
key: "required",
test: async ({ value }) => !!value,
invalid: () => _t("Please enter a name for the room"),
},
],
}),
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField');
let privateLabel;
let publicLabel;
let aliasField;
if (this.state.isPublic) {
publicLabel = (<p>{_t("Set a room alias to easily share your room with other people.")}</p>);
const domain = MatrixClientPeg.get().getDomain();
aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer">
<RoomAliasField id="alias" ref={ref => this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} />
</div>
);
} else {
privateLabel = (<p>{_t("This room is private, and can only be joined by invitation.")}</p>);
}
const title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room');
return (
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
title={_t('Create Room')}
title={title}
>
<form onSubmit={this.onOk}>
<div className="mx_Dialog_content">
<div className="mx_CreateRoomDialog_label">
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
</div>
<div className="mx_CreateRoomDialog_input_container">
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} />
</div>
<br />
<details className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ _t('Advanced options') }</summary>
<div>
<input type="checkbox" id="checkbox" ref="checkbox" defaultChecked={this.defaultNoFederate} />
<label htmlFor="checkbox">
{ _t('Block users on other matrix homeservers from joining this room') }
<br />
({ _t('This setting cannot be changed later!') })
</label>
</div>
<Field id="name" ref={ref => this._nameFieldRef = ref} label={ _t('Name') } onChange={this.onNameChange} onValidate={this.onNameValidate} value={this.state.name} className="mx_CreateRoomDialog_name" />
<Field id="topic" label={ _t('Topic (optional)') } onChange={this.onTopicChange} value={this.state.topic} />
<LabelledToggleSwitch label={ _t("Make this room public")} onChange={this.onPublicChange} value={this.state.isPublic} />
{ privateLabel }
{ publicLabel }
{ aliasField }
<details ref={this.collectDetailsRef} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }</summary>
<LabelledToggleSwitch label={ _t('Block users on other matrix homeservers from joining this room (This setting cannot be changed later!)')} onChange={this.onNoFederateChange} value={this.state.noFederate} />
</details>
</div>
</form>

View file

@ -41,6 +41,8 @@ export default class Field extends React.PureComponent {
value: PropTypes.string.isRequired,
// Optional component to include inside the field before the input.
prefix: PropTypes.node,
// Optional component to include inside the field after the input.
postfix: PropTypes.node,
// The callback called whenever the contents of the field
// changes. Returns an object with `valid` boolean field
// and a `feedback` react component field to provide feedback
@ -54,6 +56,8 @@ export default class Field extends React.PureComponent {
// If specified alongside tooltipContent, the class name to apply to the
// tooltip itself.
tooltipClassName: PropTypes.string,
// If specified, an additional class name to apply to the field container
className: PropTypes.string,
// All other props pass through to the <input>.
};
@ -143,8 +147,8 @@ export default class Field extends React.PureComponent {
render() {
const {
element, prefix, onValidate, children, tooltipContent, flagInvalid,
tooltipClassName, ...inputProps} = this.props;
element, prefix, postfix, className, onValidate, children,
tooltipContent, flagInvalid, tooltipClassName, ...inputProps} = this.props;
const inputElement = element || "input";
@ -163,9 +167,13 @@ export default class Field extends React.PureComponent {
if (prefix) {
prefixContainer = <span className="mx_Field_prefix">{prefix}</span>;
}
let postfixContainer = null;
if (postfix) {
postfixContainer = <span className="mx_Field_postfix">{postfix}</span>;
}
const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined;
const fieldClasses = classNames("mx_Field", `mx_Field_${inputElement}`, {
const fieldClasses = classNames("mx_Field", `mx_Field_${inputElement}`, className, {
// If we have a prefix element, leave the label always at the top left and
// don't animate it, as it looks a bit clunky and would add complexity to do
// properly.
@ -192,6 +200,7 @@ export default class Field extends React.PureComponent {
{prefixContainer}
{fieldInput}
<label htmlFor={this.props.id}>{this.props.label}</label>
{postfixContainer}
{fieldTooltip}
</div>;
}

View file

@ -0,0 +1,125 @@
/*
Copyright 2019 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 { _t } from '../../../languageHandler';
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import withValidation from './Validation';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default class RoomAliasField extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
domain: PropTypes.string.isRequired,
onChange: PropTypes.func,
};
constructor(props) {
super(props);
this.state = {isValid: true};
}
_asFullAlias(localpart) {
return `#${localpart}:${this.props.domain}`;
}
render() {
const Field = sdk.getComponent('views.elements.Field');
const poundSign = (<span>#</span>);
const aliasPostfix = ":" + this.props.domain;
const domain = (<span title={aliasPostfix}>{aliasPostfix}</span>);
const maxlength = 255 - this.props.domain.length - 2; // 2 for # and :
return (
<Field
label={_t("Room alias")}
className="mx_RoomAliasField"
prefix={poundSign}
postfix={domain}
id={this.props.id}
ref={ref => this._fieldRef = ref}
onValidate={this._onValidate}
placeholder={_t("e.g. my-room")}
onChange={this._onChange}
maxLength={maxlength} />
);
}
_onChange = (ev) => {
if (this.props.onChange) {
this.props.onChange(this._asFullAlias(ev.target.value));
}
}
_onValidate = async (fieldState) => {
const result = await this._validationRules(fieldState);
this.setState({isValid: result.valid});
return result;
};
_validationRules = withValidation({
rules: [
{
key: "safeLocalpart",
test: async ({ value }) => {
if (!value) {
return true;
}
const fullAlias = this._asFullAlias(value);
// XXX: FIXME https://github.com/matrix-org/matrix-doc/issues/668
return !value.includes("#") && !value.includes(":") && !value.includes(",") &&
encodeURI(fullAlias) === fullAlias;
},
invalid: () => _t("Some characters not allowed"),
}, {
key: "required",
test: async ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Please provide a room alias"),
}, {
key: "taken",
test: async ({value}) => {
if (!value) {
return true;
}
const client = MatrixClientPeg.get();
try {
await client.getRoomIdForAlias(this._asFullAlias(value));
// we got a room id, so the alias is taken
return false;
} catch (err) {
// any server error code will do,
// either it M_NOT_FOUND or the alias is invalid somehow,
// in which case we don't want to show the invalid message
return !!err.errcode;
}
},
valid: () => _t("This alias is available to use"),
invalid: () => _t("This alias is already in use"),
},
],
});
get isValid() {
return this.state.isValid;
}
validate(options) {
return this._fieldRef.validate(options);
}
focus() {
this._fieldRef.focus();
}
}

View file

@ -169,9 +169,32 @@ export default class BasicMessageEditor extends React.Component {
_onCompositionEnd = (event) => {
this._isIMEComposing = false;
// some browsers (chromium) don't fire an input event after ending a composition
// so trigger a model update after the composition is done by calling the input handler
this._onInput({inputType: "insertCompositionText"});
// some browsers (Chrome) don't fire an input event after ending a composition,
// so trigger a model update after the composition is done by calling the input handler.
// however, modifying the DOM (caused by the editor model update) from the compositionend handler seems
// to confuse the IME in Chrome, likely causing https://github.com/vector-im/riot-web/issues/10913 ,
// so we do it async
// however, doing this async seems to break things in Safari for some reason, so browser sniff.
const ua = navigator.userAgent.toLowerCase();
const isSafari = ua.includes('safari/') && !ua.includes('chrome/');
if (isSafari) {
this._onInput({inputType: "insertCompositionText"});
} else {
setTimeout(() => {
this._onInput({inputType: "insertCompositionText"});
}, 0);
}
}
isComposing(event) {
// checking the event.isComposing flag just in case any browser out there
// emits events related to the composition after compositionend
// has been fired
return !!(this._isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing));
}
_onPaste = (event) => {

View file

@ -127,6 +127,10 @@ export default class EditMessageComposer extends React.Component {
}
_onKeyDown = (event) => {
// ignore any keypress while doing IME compositions
if (this._editorRef.isComposing(event)) {
return;
}
if (event.metaKey || event.altKey || event.shiftKey) {
return;
}

View file

@ -104,6 +104,10 @@ export default class SendMessageComposer extends React.Component {
};
_onKeyDown = (event) => {
// ignore any keypress while doing IME compositions
if (this._editorRef.isComposing(event)) {
return;
}
const hasModifier = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
if (event.key === "Enter" && !hasModifier) {
this._sendMessage();

View file

@ -100,19 +100,21 @@ export default class AutocompleteWrapperModel {
_partForCompletion(completion) {
const {completionId} = completion;
const text = completion.completion;
const firstChr = completionId && completionId[0];
switch (firstChr) {
case "@": {
if (completionId === "@room") {
return [this._partCreator.atRoomPill(completionId)];
} else {
return this._partCreator.createMentionParts(this._partIndex, text, completionId);
}
}
case "#":
return [this._partCreator.roomPill(completionId)];
// used for emoji and command completion replacement
switch (completion.type) {
case "room":
return [this._partCreator.roomPill(completionId), this._partCreator.plain(completion.suffix)];
case "at-room":
return [this._partCreator.atRoomPill(completionId), this._partCreator.plain(completion.suffix)];
case "user":
// not using suffix here, because we also need to calculate
// the suffix when clicking a display name to insert a mention,
// which happens in createMentionParts
return this._partCreator.createMentionParts(this._partIndex, text, completionId);
case "command":
// command needs special handling for auto complete, but also renders as plain texts
return [this._partCreator.command(text)];
default:
// used for emoji and other plain text completion replacement
return [this._partCreator.plain(text)];
}
}

View file

@ -76,7 +76,7 @@ function parseHeader(el, partCreator) {
return partCreator.plain("#".repeat(depth) + " ");
}
function parseElement(n, partCreator, state) {
function parseElement(n, partCreator, lastNode, state) {
switch (n.nodeName) {
case "H1":
case "H2":
@ -90,7 +90,7 @@ function parseElement(n, partCreator, state) {
case "BR":
return partCreator.newline();
case "EM":
return partCreator.plain(`*${n.textContent}*`);
return partCreator.plain(`_${n.textContent}_`);
case "STRONG":
return partCreator.plain(`**${n.textContent}**`);
case "PRE":
@ -107,6 +107,12 @@ function parseElement(n, partCreator, state) {
return partCreator.plain(`${indent}- `);
}
}
case "P": {
if (lastNode) {
return partCreator.newline();
}
break;
}
case "OL":
case "UL":
state.listDepth = (state.listDepth || 0) + 1;
@ -183,7 +189,7 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) {
if (n.nodeType === Node.TEXT_NODE) {
newParts.push(...parseAtRoomMentions(n.nodeValue, partCreator));
} else if (n.nodeType === Node.ELEMENT_NODE) {
const parseResult = parseElement(n, partCreator, state);
const parseResult = parseElement(n, partCreator, lastNode, state);
if (parseResult) {
if (Array.isArray(parseResult)) {
newParts.push(...parseResult);
@ -200,10 +206,6 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) {
parts.push(...newParts);
// extra newline after quote, only if there something behind it...
if (lastNode && lastNode.nodeName === "BLOCKQUOTE") {
parts.push(partCreator.newline());
}
const decend = checkDecendInto(n);
// when not decending (like for PRE), onNodeLeave won't be called to set lastNode
// so do that here.

View file

@ -456,15 +456,20 @@ export class CommandPartCreator extends PartCreator {
createPartForInput(text, partIndex) {
// at beginning and starts with /? create
if (partIndex === 0 && text[0] === "/") {
return new CommandPart("", this._autoCompleteCreator);
// text will be inserted by model, so pass empty string
return this.command("");
} else {
return super.createPartForInput(text, partIndex);
}
}
command(text) {
return new CommandPart(text, this._autoCompleteCreator);
}
deserializePart(part) {
if (part.type === "command") {
return new CommandPart(part.text, this._autoCompleteCreator);
return this.command(part.text);
} else {
return super.deserializePart(part);
}

View file

@ -1189,6 +1189,12 @@
"Custom level": "Custom level",
"Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.",
"<a>In reply to</a> <pill>": "<a>In reply to</a> <pill>",
"Room alias": "Room alias",
"e.g. my-room": "e.g. my-room",
"Some characters not allowed": "Some characters not allowed",
"Please provide a room alias": "Please provide a room alias",
"This alias is available to use": "This alias is available to use",
"This alias is already in use": "This alias is already in use",
"Room directory": "Room directory",
"And %(count)s more...|other": "And %(count)s more...",
"ex. @bob:example.com": "ex. @bob:example.com",
@ -1236,11 +1242,18 @@
"Community ID": "Community ID",
"example": "example",
"Create": "Create",
"Please enter a name for the room": "Please enter a name for the room",
"Set a room alias to easily share your room with other people.": "Set a room alias to easily share your room with other people.",
"This room is private, and can only be joined by invitation.": "This room is private, and can only be joined by invitation.",
"Create a public room": "Create a public room",
"Create a private room": "Create a private room",
"Name": "Name",
"Topic (optional)": "Topic (optional)",
"Make this room public": "Make this room public",
"Hide advanced": "Hide advanced",
"Show advanced": "Show advanced",
"Block users on other matrix homeservers from joining this room (This setting cannot be changed later!)": "Block users on other matrix homeservers from joining this room (This setting cannot be changed later!)",
"Create Room": "Create Room",
"Room name (optional)": "Room name (optional)",
"Advanced options": "Advanced options",
"Block users on other matrix homeservers from joining this room": "Block users on other matrix homeservers from joining this room",
"This setting cannot be changed later!": "This setting cannot be changed later!",
"Sign out": "Sign out",
"To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of Riot to do this": "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of Riot to do this",
"You've previously used a newer version of Riot on %(host)s. To use this version again with end to end encryption, you will need to sign out and back in again. ": "You've previously used a newer version of Riot on %(host)s. To use this version again with end to end encryption, you will need to sign out and back in again. ",
@ -1497,7 +1510,6 @@
"Doesn't look like a valid phone number": "Doesn't look like a valid phone number",
"Use lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only",
"Enter username": "Enter username",
"Some characters not allowed": "Some characters not allowed",
"Email (optional)": "Email (optional)",
"Confirm": "Confirm",
"Phone (optional)": "Phone (optional)",
@ -1723,7 +1735,6 @@
"NOT verified": "NOT verified",
"Blacklisted": "Blacklisted",
"verified": "verified",
"Name": "Name",
"Verification": "Verification",
"Ed25519 fingerprint": "Ed25519 fingerprint",
"User ID": "User ID",

View file

@ -94,7 +94,7 @@ describe('editor/deserialize', function() {
const html = "<strong>bold</strong> and <em>emphasized</em> text";
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
expect(parts.length).toBe(1);
expect(parts[0]).toStrictEqual({type: "plain", text: "**bold** and *emphasized* text"});
expect(parts[0]).toStrictEqual({type: "plain", text: "**bold** and _emphasized_ text"});
});
it('hyperlink', function() {
const html = 'click <a href="http://example.com/">this</a>!';
@ -105,10 +105,11 @@ describe('editor/deserialize', function() {
it('multiple lines with paragraphs', function() {
const html = '<p>hello</p><p>world</p>';
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
expect(parts.length).toBe(3);
expect(parts.length).toBe(4);
expect(parts[0]).toStrictEqual({type: "plain", text: "hello"});
expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
expect(parts[2]).toStrictEqual({type: "plain", text: "world"});
expect(parts[2]).toStrictEqual({type: "newline", text: "\n"});
expect(parts[3]).toStrictEqual({type: "plain", text: "world"});
});
it('multiple lines with line breaks', function() {
const html = 'hello<br>world';
@ -121,18 +122,19 @@ describe('editor/deserialize', function() {
it('multiple lines mixing paragraphs and line breaks', function() {
const html = '<p>hello<br>warm</p><p>world</p>';
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
expect(parts.length).toBe(5);
expect(parts.length).toBe(6);
expect(parts[0]).toStrictEqual({type: "plain", text: "hello"});
expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
expect(parts[2]).toStrictEqual({type: "plain", text: "warm"});
expect(parts[3]).toStrictEqual({type: "newline", text: "\n"});
expect(parts[4]).toStrictEqual({type: "plain", text: "world"});
expect(parts[4]).toStrictEqual({type: "newline", text: "\n"});
expect(parts[5]).toStrictEqual({type: "plain", text: "world"});
});
it('quote', function() {
const html = '<blockquote><p><em>wise</em><br><strong>words</strong></p></blockquote><p>indeed</p>';
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
expect(parts.length).toBe(6);
expect(parts[0]).toStrictEqual({type: "plain", text: "> *wise*"});
expect(parts[0]).toStrictEqual({type: "plain", text: "> _wise_"});
expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
expect(parts[2]).toStrictEqual({type: "plain", text: "> **words**"});
expect(parts[3]).toStrictEqual({type: "newline", text: "\n"});
@ -159,7 +161,7 @@ describe('editor/deserialize', function() {
const html = "<em>formatted</em> message for @room";
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
expect(parts.length).toBe(2);
expect(parts[0]).toStrictEqual({type: "plain", text: "*formatted* message for "});
expect(parts[0]).toStrictEqual({type: "plain", text: "_formatted_ message for "});
expect(parts[1]).toStrictEqual({type: "at-room-pill", text: "@room"});
});
it('inline code', function() {
@ -220,7 +222,7 @@ describe('editor/deserialize', function() {
const html = "says <em>DON'T SHOUT</em>!";
const parts = normalize(parseEvent(htmlMessage(html, "m.emote"), createPartCreator()));
expect(parts.length).toBe(1);
expect(parts[0]).toStrictEqual({type: "plain", text: "/me says *DON'T SHOUT*!"});
expect(parts[0]).toStrictEqual({type: "plain", text: "/me says _DON'T SHOUT_!"});
});
});
});