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

 Conflicts:
	src/components/views/settings/tabs/room/RolesRoomSettingsTab.js
	src/i18n/strings/en_EN.json
This commit is contained in:
Michael Telatynski 2019-08-30 10:56:47 +01:00
commit 8967871b23
37 changed files with 695 additions and 193 deletions

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018, 2019 New Vector 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.
@ -208,6 +209,7 @@ module.exports = React.createClass({
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={0}
showIdentityServerIfRequiredByHomeserver={true}
onAfterSubmit={this.onServerDetailsNextPhaseClick}
submitText={_t("Next")}
submitClass="mx_Login_submit"

View file

@ -499,6 +499,7 @@ module.exports = React.createClass({
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
showIdentityServerIfRequiredByHomeserver={true}
{...serverDetailsProps}
/>;
break;

View file

@ -444,6 +444,15 @@ module.exports = React.createClass({
return true;
},
_showPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (!threePidLogin || !haveIs || !this._authStepIsUsed('m.login.msisdn')) {
return false;
}
return true;
},
renderEmail() {
if (!this._showEmail()) {
return null;
@ -490,9 +499,7 @@ module.exports = React.createClass({
},
renderPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (!threePidLogin || !haveIs || !this._authStepIsUsed('m.login.msisdn')) {
if (!this._showPhoneNumber()) {
return null;
}
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
@ -564,11 +571,24 @@ module.exports = React.createClass({
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
);
const emailHelperText = this._showEmail() ? <div>
{_t("Use an email address to recover your account.") + " "}
{_t("Other users can invite you to rooms using your contact details.")}
</div> : null;
let emailHelperText = null;
if (this._showEmail()) {
if (this._showPhoneNumber()) {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
"Use email or phone to optionally be discoverable by existing contacts.",
)}
</div>;
} else {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
"Use email to optionally be discoverable by existing contacts.",
)}
</div>;
}
}
const haveIs = Boolean(this.props.serverConfig.isUrl);
const noIsText = haveIs ? null : <div>
{_t(

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 New Vector 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.
@ -23,6 +24,8 @@ import { _t } from '../../../languageHandler';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
import { createClient } from 'matrix-js-sdk/lib/matrix';
import classNames from 'classnames';
/*
* A pure UI component which displays the HS and IS to use.
@ -46,6 +49,10 @@ export default class ServerConfig extends React.PureComponent {
// Optional class for the submit button. Only applies if the submit button
// is to be rendered.
submitClass: PropTypes.string,
// Whether the flow this component is embedded in requires an identity
// server when the homeserver says it will need one. Default false.
showIdentityServerIfRequiredByHomeserver: PropTypes.bool,
};
static defaultProps = {
@ -61,6 +68,7 @@ export default class ServerConfig extends React.PureComponent {
errorText: "",
hsUrl: props.serverConfig.hsUrl,
isUrl: props.serverConfig.isUrl,
showIdentityServer: false,
};
}
@ -75,14 +83,41 @@ export default class ServerConfig extends React.PureComponent {
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
return this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
if (!result) {
return result;
}
// If the UI flow this component is embedded in requires an identity
// server when the homeserver says it will need one, check first and
// reveal this field if not already shown.
// XXX: This a backward compatibility path for homeservers that require
// an identity server to be passed during certain flows.
// See also https://github.com/matrix-org/synapse/pull/5868.
if (
this.props.showIdentityServerIfRequiredByHomeserver &&
!this.state.showIdentityServer &&
await this.isIdentityServerRequiredByHomeserver()
) {
this.setState({
showIdentityServer: true,
});
return null;
}
return result;
}
async validateAndApplyServer(hsUrl, isUrl) {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
this.setState({busy: false, errorText: ""});
this.setState({
hsUrl: defaultConfig.hsUrl,
isUrl: defaultConfig.isUrl,
busy: false,
errorText: "",
});
this.props.onServerConfigChange(defaultConfig);
return defaultConfig;
}
@ -126,6 +161,15 @@ export default class ServerConfig extends React.PureComponent {
}
}
async isIdentityServerRequiredByHomeserver() {
// XXX: We shouldn't have to create a whole new MatrixClient just to
// check if the homeserver requires an identity server... Should it be
// extracted to a static utils function...?
return createClient({
baseUrl: this.state.hsUrl,
}).doesServerRequireIdServerParam();
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.validateServer();
@ -171,8 +215,49 @@ export default class ServerConfig extends React.PureComponent {
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
};
render() {
_renderHomeserverSection() {
const Field = sdk.getComponent('elements.Field');
return <div>
{_t("Enter your custom homeserver URL <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{sub}
</a>,
})}
<Field id="mx_ServerConfig_hsUrl"
label={_t("Homeserver URL")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
disabled={this.state.busy}
/>
</div>;
}
_renderIdentityServerSection() {
const Field = sdk.getComponent('elements.Field');
const classes = classNames({
"mx_ServerConfig_identityServer": true,
"mx_ServerConfig_identityServer_shown": this.state.showIdentityServer,
});
return <div className={classes}>
{_t("Enter your custom identity server URL <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{sub}
</a>,
})}
<Field id="mx_ServerConfig_isUrl"
label={_t("Identity Server URL")}
placeholder={this.props.serverConfig.isUrl}
value={this.state.isUrl || ''}
onBlur={this.onIdentityServerBlur}
onChange={this.onIdentityServerChange}
disabled={this.state.busy}
/>
</div>;
}
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const errorText = this.state.errorText
@ -191,31 +276,10 @@ export default class ServerConfig extends React.PureComponent {
return (
<div className="mx_ServerConfig">
<h3>{_t("Other servers")}</h3>
{_t("Enter custom server URLs <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{ sub }
</a>,
})}
{errorText}
{this._renderHomeserverSection()}
{this._renderIdentityServerSection()}
<form onSubmit={this.onSubmit} autoComplete={false} action={null}>
<div className="mx_ServerConfig_fields">
<Field id="mx_ServerConfig_hsUrl"
label={_t("Homeserver URL")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
disabled={this.state.busy}
/>
<Field id="mx_ServerConfig_isUrl"
label={_t("Identity Server URL")}
placeholder={this.props.serverConfig.isUrl}
value={this.state.isUrl || ''}
onBlur={this.onIdentityServerBlur}
onChange={this.onIdentityServerChange}
disabled={this.state.busy}
/>
</div>
{submitButton}
</form>
</div>

View file

@ -24,11 +24,14 @@ import createReactClass from 'create-react-class';
import { _t, _td } from '../../../languageHandler';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import Promise from 'bluebird';
import { addressTypes, getAddressType } from '../../../UserAddress.js';
import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient';
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils';
import { abbreviateUrl } from '../../../utils/UrlUtils';
const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
@ -49,7 +52,7 @@ module.exports = createReactClass({
// Extra node inserted after picker input, dropdown and errors
extraNode: PropTypes.node,
value: PropTypes.string,
placeholder: PropTypes.string,
placeholder: PropTypes.oneOfType(PropTypes.string, PropTypes.func),
roomId: PropTypes.string,
button: PropTypes.string,
focus: PropTypes.bool,
@ -91,6 +94,9 @@ module.exports = createReactClass({
// List of UserAddressType objects representing the set of
// auto-completion results for the current search query.
suggestedList: [],
// List of address types initialised from props, but may change while the
// dialog is open.
validAddressTypes: this.props.validAddressTypes,
};
},
@ -101,6 +107,15 @@ module.exports = createReactClass({
}
},
getPlaceholder() {
const { placeholder } = this.props;
if (typeof placeholder === "string") {
return placeholder;
}
// Otherwise it's a function, as checked by prop types.
return placeholder(this.state.validAddressTypes);
},
onButtonClick: function() {
let selectedList = this.state.selectedList.slice();
// Check the text input field to see if user has an unconverted address
@ -434,7 +449,7 @@ module.exports = createReactClass({
// This is important, otherwise there's no way to invite
// a perfectly valid address if there are close matches.
const addrType = getAddressType(query);
if (this.props.validAddressTypes.includes(addrType)) {
if (this.state.validAddressTypes.includes(addrType)) {
if (addrType === 'email' && !Email.looksValid(query)) {
this.setState({searchError: _t("That doesn't look like a valid email address")});
return;
@ -470,7 +485,7 @@ module.exports = createReactClass({
isKnown: false,
};
if (!this.props.validAddressTypes.includes(addrType)) {
if (!this.state.validAddressTypes.includes(addrType)) {
hasError = true;
} else if (addrType === 'mx-user-id') {
const user = MatrixClientPeg.get().getUser(addrObj.address);
@ -571,12 +586,37 @@ module.exports = createReactClass({
this._addAddressesToList(text.split(/[\s,]+/));
},
onUseDefaultIdentityServerClick(e) {
e.preventDefault();
// Update the IS in account data. Actually using it may trigger terms.
useDefaultIdentityServer();
// Add email as a valid address type.
const { validAddressTypes } = this.state;
validAddressTypes.push('email');
this.setState({ validAddressTypes });
},
onManageSettingsClick(e) {
e.preventDefault();
dis.dispatch({ action: 'view_user_settings' });
this.onCancel();
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
let inputLabel;
if (this.props.description) {
inputLabel = <div className="mx_AddressPickerDialog_label">
<label htmlFor="textinput">{this.props.description}</label>
</div>;
}
const query = [];
// create the invite list
if (this.state.selectedList.length > 0) {
@ -603,7 +643,7 @@ module.exports = createReactClass({
ref="textinput"
className="mx_AddressPickerDialog_input"
onChange={this.onQueryChanged}
placeholder={this.props.placeholder}
placeholder={this.getPlaceholder()}
defaultValue={this.props.value}
autoFocus={this.props.focus}>
</textarea>,
@ -614,7 +654,7 @@ module.exports = createReactClass({
let error;
let addressSelector;
if (this.state.invalidAddressError) {
const validTypeDescriptions = this.props.validAddressTypes.map((t) => _t(addressTypeName[t]));
const validTypeDescriptions = this.state.validAddressTypes.map((t) => _t(addressTypeName[t]));
error = <div className="mx_AddressPickerDialog_error">
{ _t("You have entered an invalid address.") }
<br />
@ -637,17 +677,43 @@ module.exports = createReactClass({
);
}
let identityServer;
if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes('email')) {
const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
if (defaultIdentityServerUrl) {
identityServer = <div className="mx_AddressPickerDialog_identityServer">{_t(
"Use an identity server to invite by email. " +
"<default>Use the default (%(defaultIdentityServerName)s)</default> " +
"or manage in <settings>Settings</settings>.",
{
defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl),
},
{
default: sub => <a href="#" onClick={this.onUseDefaultIdentityServerClick}>{sub}</a>,
settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
},
)}</div>;
} else {
identityServer = <div className="mx_AddressPickerDialog_identityServer">{_t(
"Use an identity server to invite by email. " +
"Manage in <settings>Settings</settings>.",
{}, {
settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
},
)}</div>;
}
}
return (
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished} title={this.props.title}>
<div className="mx_AddressPickerDialog_label">
<label htmlFor="textinput">{ this.props.description }</label>
</div>
{inputLabel}
<div className="mx_Dialog_content">
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div>
{ error }
{ addressSelector }
{ this.props.extraNode }
{ identityServer }
</div>
<DialogButtons primaryButton={this.props.button}
onPrimaryButtonClick={this.onButtonClick}

View file

@ -154,10 +154,9 @@ export default class AppTile extends React.Component {
// Widget action listeners
dis.unregister(this.dispatcherRef);
const canPersist = this.props.whitelistCapabilities.includes('m.always_on_screen');
// if it's not remaining on screen, get rid of the PersistedElement container
if (canPersist && !ActiveWidgetStore.getWidgetPersistence(this.props.id)) {
ActiveWidgetStore.destroyPersistentWidget();
if (!ActiveWidgetStore.getWidgetPersistence(this.props.id)) {
ActiveWidgetStore.destroyPersistentWidget(this.props.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey);
}
@ -451,7 +450,7 @@ export default class AppTile extends React.Component {
this.setState({hasPermissionToLoad: false});
// Force the widget to be non-persistent
ActiveWidgetStore.destroyPersistentWidget();
ActiveWidgetStore.destroyPersistentWidget(this.props.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey);
}

View file

@ -0,0 +1,51 @@
/*
Copyright 2019 Sorunome
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';
export default class Spoiler extends React.Component {
constructor(props) {
super(props);
this.state = {
visible: false,
};
}
toggleVisible(e) {
if (!this.state.visible) {
// we are un-blurring, we don't want this click to propagate to potential child pills
e.preventDefault();
e.stopPropagation();
}
this.setState({ visible: !this.state.visible });
}
render() {
const reason = this.props.reason ? (
<span className="mx_EventTile_spoiler_reason">{"(" + this.props.reason + ")"}</span>
) : null;
// react doesn't allow appending a DOM node as child.
// as such, we pass the this.props.contentHtml instead and then set the raw
// HTML content. This is secure as the contents have already been parsed previously
return (
<span className={"mx_EventTile_spoiler" + (this.state.visible ? " visible" : "")} onClick={this.toggleVisible.bind(this)}>
{ reason }
&nbsp;
<span className="mx_EventTile_spoiler_content" dangerouslySetInnerHTML={{ __html: this.props.contentHtml }} />
</span>
);
}
}

View file

@ -95,6 +95,8 @@ module.exports = React.createClass({
},
_applyFormatting() {
this.activateSpoilers(this.refs.content.children);
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
// are still sent as plaintext URLs. If these are ever pillified in the composer,
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
@ -183,6 +185,34 @@ module.exports = React.createClass({
}
},
activateSpoilers: function(nodes) {
let node = nodes[0];
while (node) {
if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
const spoilerContainer = document.createElement('span');
const reason = node.getAttribute("data-mx-spoiler");
const Spoiler = sdk.getComponent('elements.Spoiler');
node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
const spoiler = <Spoiler
reason={reason}
contentHtml={node.outerHTML}
/>;
ReactDOM.render(spoiler, spoilerContainer);
node.parentNode.replaceChild(spoilerContainer, node);
node = spoilerContainer;
}
if (node.childNodes && node.childNodes.length) {
this.activateSpoilers(node.childNodes);
}
node = node.nextSibling;
}
},
findLinks: function(nodes) {
let links = [];

View file

@ -14,6 +14,8 @@ 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 classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import EditorModel from '../../../editor/model';
@ -73,12 +75,13 @@ export default class BasicMessageEditor extends React.Component {
this._editorRef = null;
this._autocompleteRef = null;
this._modifiedFlag = false;
this._isIMEComposing = false;
}
_replaceEmoticon = (caret, inputType, diff) => {
_replaceEmoticon = (caretPosition, inputType, diff) => {
const {model} = this.props;
const range = model.startRange(caret);
// expand range max 8 characters backwards from caret,
const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caretPosition,
// as a space to look for an emoticon
let n = 8;
range.expandBackwardsWhile((index, offset) => {
@ -91,6 +94,7 @@ export default class BasicMessageEditor extends React.Component {
const query = emoticonMatch[1].toLowerCase().replace("-", "");
const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false);
if (data) {
const {partCreator} = model;
const hasPrecedingSpace = emoticonMatch[0][0] === " ";
// we need the range to only comprise of the emoticon
// because we'll replace the whole range with an emoji,
@ -99,7 +103,7 @@ export default class BasicMessageEditor extends React.Component {
range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0));
// this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted.
return range.replace([this.props.model.partCreator.plain(data.unicode + " ")]);
return range.replace([partCreator.plain(data.unicode + " ")]);
}
}
}
@ -116,11 +120,9 @@ export default class BasicMessageEditor extends React.Component {
if (this.props.placeholder) {
const {isEmpty} = this.props.model;
if (isEmpty) {
this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`);
this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty");
this._showPlaceholder();
} else {
this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty");
this._editorRef.style.removeProperty("--placeholder");
this._hidePlaceholder();
}
}
this.setState({autoComplete: this.props.model.autoComplete});
@ -132,7 +134,31 @@ export default class BasicMessageEditor extends React.Component {
}
}
_showPlaceholder() {
this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`);
this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty");
}
_hidePlaceholder() {
this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty");
this._editorRef.style.removeProperty("--placeholder");
}
_onCompositionStart = (event) => {
this._isIMEComposing = true;
// even if the model is empty, the composition text shouldn't be mixed with the placeholder
this._hidePlaceholder();
}
_onCompositionEnd = (event) => {
this._isIMEComposing = false;
}
_onInput = (event) => {
// ignore any input while doing IME compositions
if (this._isIMEComposing) {
return;
}
this._modifiedFlag = true;
const sel = document.getSelection();
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
@ -160,7 +186,7 @@ export default class BasicMessageEditor extends React.Component {
}
_refreshLastCaretIfNeeded() {
// TODO: needed when going up and down in editing messages ... not sure why yet
// XXX: needed when going up and down in editing messages ... not sure why yet
// because the editors should stop doing this when when blurred ...
// maybe it's on focus and the _editorRef isn't available yet or something.
if (!this._editorRef) {
@ -242,14 +268,6 @@ export default class BasicMessageEditor extends React.Component {
if (model.autoComplete) {
const autoComplete = model.autoComplete;
switch (event.key) {
case "Enter":
// only capture enter when something is selected in the list,
// otherwise don't handle so the contents of the composer gets sent
if (autoComplete.hasSelection()) {
autoComplete.onEnter(event);
handled = true;
}
break;
case "ArrowUp":
autoComplete.onUpArrow(event);
handled = true;
@ -269,6 +287,9 @@ export default class BasicMessageEditor extends React.Component {
default:
return; // don't preventDefault on anything else
}
} else if (event.key === "Tab") {
this._tabCompleteName();
handled = true;
}
}
if (handled) {
@ -277,6 +298,36 @@ export default class BasicMessageEditor extends React.Component {
}
}
async _tabCompleteName() {
try {
await new Promise(resolve => this.setState({showVisualBell: false}, resolve));
const {model} = this.props;
const caret = this.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && (part.type === "plain" || part.type === "pill-candidate");
});
const {partCreator} = model;
// await for auto-complete to be open
await model.transform(() => {
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();
}
} catch (err) {
console.error(err);
}
}
getEditableRootNode() {
return this._editorRef;
}
isModified() {
return this._modifiedFlag;
}
@ -291,6 +342,8 @@ export default class BasicMessageEditor extends React.Component {
componentWillUnmount() {
this._editorRef.removeEventListener("input", this._onInput, true);
this._editorRef.removeEventListener("compositionstart", this._onCompositionStart, true);
this._editorRef.removeEventListener("compositionend", this._onCompositionEnd, true);
}
componentDidMount() {
@ -304,7 +357,7 @@ export default class BasicMessageEditor extends React.Component {
// not really, but we could not serialize the parts, and just change the autoCompleter
partCreator.setAutoCompleteCreator(autoCompleteCreator(
() => this._autocompleteRef,
query => this.setState({query}),
query => new Promise(resolve => this.setState({query}, resolve)),
));
this.historyManager = new HistoryManager(partCreator);
// initial render of model
@ -312,6 +365,8 @@ export default class BasicMessageEditor extends React.Component {
// attach input listener by hand so React doesn't proxy the events,
// as the proxied event doesn't support inputType, which we need.
this._editorRef.addEventListener("input", this._onInput, true);
this._editorRef.addEventListener("compositionstart", this._onCompositionStart, true);
this._editorRef.addEventListener("compositionend", this._onCompositionEnd, true);
this._editorRef.focus();
}
@ -345,7 +400,10 @@ export default class BasicMessageEditor extends React.Component {
/>
</div>);
}
return (<div className="mx_BasicMessageComposer">
const classes = classNames("mx_BasicMessageComposer", {
"mx_BasicMessageComposer_input_error": this.state.showVisualBell,
});
return (<div className={classes}>
{ autoComplete }
<div
className="mx_BasicMessageComposer_input"

View file

@ -32,6 +32,7 @@ import {processCommandInput} from '../../../SlashCommands';
import sdk from '../../../index';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages';
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -226,8 +227,13 @@ export default class SendMessageComposer extends React.Component {
this._clearStoredEditorState();
}
componentDidMount() {
this._editorRef.getEditableRootNode().addEventListener("paste", this._onPaste, true);
}
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
this._editorRef.getEditableRootNode().removeEventListener("paste", this._onPaste, true);
}
componentWillMount() {
@ -279,26 +285,50 @@ export default class SendMessageComposer extends React.Component {
};
_insertMention(userId) {
const {model} = this;
const {partCreator} = model;
const member = this.props.room.getMember(userId);
const displayName = member ?
member.rawDisplayName : userId;
const userPillPart = this.model.partCreator.userPill(displayName, userId);
this.model.insertPartsAt([userPillPart], this._editorRef.getCaret());
const userPillPart = partCreator.userPill(displayName, userId);
const caret = this._editorRef.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
model.transform(() => {
const addedLen = model.insert([userPillPart], position);
return model.positionForOffset(caret.offset + addedLen, true);
});
// refocus on composer, as we just clicked "Mention"
this._editorRef && this._editorRef.focus();
}
_insertQuotedMessage(event) {
const {partCreator} = this.model;
const {model} = this;
const {partCreator} = model;
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
// add two newlines
quoteParts.push(partCreator.newline());
quoteParts.push(partCreator.newline());
this.model.insertPartsAt(quoteParts, {offset: 0});
model.transform(() => {
const addedLen = model.insert(quoteParts, model.positionForOffset(0));
return model.positionForOffset(addedLen, true);
});
// refocus on composer, as we just clicked "Quote"
this._editorRef && this._editorRef.focus();
}
_onPaste = (event) => {
const {clipboardData} = event;
if (clipboardData.files.length) {
// This actually not so much for 'files' as such (at time of writing
// neither chrome nor firefox let you paste a plain file copied
// from Finder) but more images copied from a different website
// / word processor etc.
ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(clipboardData.files), this.props.room.roomId, this.context.matrixClient,
);
}
}
render() {
return (
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}>

View file

@ -20,13 +20,13 @@ import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import sdk from '../../../index';
import MatrixClientPeg from "../../../MatrixClientPeg";
import SdkConfig from "../../../SdkConfig";
import Modal from '../../../Modal';
import dis from "../../../dispatcher";
import { getThreepidBindStatus } from '../../../boundThreepids';
import IdentityAuthClient from "../../../IdentityAuthClient";
import {SERVICE_TYPES} from "matrix-js-sdk";
import {abbreviateUrl, unabbreviateUrl} from "../../../utils/UrlUtils";
import { getDefaultIdentityServerUrl } from '../../../utils/IdentityServerUtils';
/**
* Check an IS URL is valid, including liveness check
@ -66,10 +66,10 @@ export default class SetIdServer extends React.Component {
super();
let defaultIdServer = '';
if (!MatrixClientPeg.get().getIdentityServerUrl() && SdkConfig.get()['validated_server_config']['isUrl']) {
if (!MatrixClientPeg.get().getIdentityServerUrl() && getDefaultIdentityServerUrl()) {
// If no ID server is configured but there's one in the config, prepopulate
// the field to help the user.
defaultIdServer = abbreviateUrl(SdkConfig.get()['validated_server_config']['isUrl']);
defaultIdServer = abbreviateUrl(getDefaultIdentityServerUrl());
}
this.state = {
@ -253,10 +253,10 @@ export default class SetIdServer extends React.Component {
});
let newFieldVal = '';
if (SdkConfig.get()['validated_server_config']['isUrl']) {
if (getDefaultIdentityServerUrl()) {
// Prepopulate the client's default so the user at least has some idea of
// a valid value they might enter
newFieldVal = abbreviateUrl(SdkConfig.get()['validated_server_config']['isUrl']);
newFieldVal = abbreviateUrl(getDefaultIdentityServerUrl());
}
this.setState({

View file

@ -30,6 +30,7 @@ const plEventsToLabels = {
"m.room.history_visibility": _td("Change history visibility"),
"m.room.power_levels": _td("Change permissions"),
"m.room.topic": _td("Change topic"),
"m.room.tombstone": _td("Upgrade the room"),
"m.room.encryption": _td("Enable room encryption"),
"im.vector.modular.widgets": _td("Modify widgets"),
@ -43,6 +44,7 @@ const plEventsToShow = {
"m.room.history_visibility": {isState: true},
"m.room.power_levels": {isState: true},
"m.room.topic": {isState: true},
"m.room.tombstone": {isState: true},
"m.room.encryption": {isState: true},
"im.vector.modular.widgets": {isState: true},