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

@ -281,6 +281,12 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
box-shadow: 2px 15px 30px 0 $dialog-shadow-color; box-shadow: 2px 15px 30px 0 $dialog-shadow-color;
border-radius: 4px; border-radius: 4px;
overflow-y: auto; overflow-y: auto;
a:link,
a:hover,
a:visited {
@mixin mx_Dialog_link;
}
} }
.mx_Dialog_fixedWidth { .mx_Dialog_fixedWidth {

View file

@ -39,8 +39,7 @@ limitations under the License.
a:link, a:link,
a:hover, a:hover,
a:visited { a:visited {
color: $accent-color; @mixin mx_Dialog_link;
text-decoration: none;
} }
input[type=text], input[type=text],

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,23 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_ServerConfig_fields {
display: flex;
margin: 1em 0;
}
.mx_ServerConfig_fields .mx_Field {
margin: 0 5px;
}
.mx_ServerConfig_fields .mx_Field:first-child {
margin-left: 0;
}
.mx_ServerConfig_fields .mx_Field:last-child {
margin-right: 0;
}
.mx_ServerConfig_help:link { .mx_ServerConfig_help:link {
opacity: 0.8; opacity: 0.8;
} }
@ -39,3 +23,13 @@ limitations under the License.
display: block; display: block;
color: $warning-color; color: $warning-color;
} }
.mx_ServerConfig_identityServer {
transform: scaleY(0);
transform-origin: top;
transition: transform 0.25s;
&.mx_ServerConfig_identityServer_shown {
transform: scaleY(1);
}
}

View file

@ -67,3 +67,6 @@ limitations under the License.
pointer-events: none; pointer-events: none;
} }
.mx_AddressPickerDialog_identityServer {
margin-top: 1em;
}

View file

@ -27,6 +27,15 @@ limitations under the License.
white-space: nowrap; white-space: nowrap;
} }
@keyframes visualbell {
from { background-color: $visual-bell-bg-color; }
to { background-color: $primary-bg-color; }
}
&.mx_BasicMessageComposer_input_error {
animation: 0.2s visualbell;
}
.mx_BasicMessageComposer_input { .mx_BasicMessageComposer_input {
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;

View file

@ -296,6 +296,25 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
overflow-y: hidden; overflow-y: hidden;
} }
/* Spoiler stuff */
.mx_EventTile_spoiler {
cursor: pointer;
}
.mx_EventTile_spoiler_reason {
color: $event-timestamp-color;
font-size: 11px;
}
.mx_EventTile_spoiler_content {
filter: blur(5px) saturate(0.1) sepia(1);
transition-duration: 0.5s;
}
.mx_EventTile_spoiler.visible > .mx_EventTile_spoiler_content {
filter: none;
}
.mx_EventTile_e2eIcon { .mx_EventTile_e2eIcon {
display: block; display: block;
position: absolute; position: absolute;

View file

@ -129,7 +129,7 @@ limitations under the License.
} }
@keyframes visualbell { @keyframes visualbell {
from { background-color: #faa; } from { background-color: $visual-bell-bg-color; }
to { background-color: $primary-bg-color; } to { background-color: $primary-bg-color; }
} }

View file

@ -43,7 +43,6 @@ limitations under the License.
height: 88px; height: 88px;
margin-left: 13px; margin-left: 13px;
position: relative; position: relative;
cursor: pointer;
} }
.mx_ProfileSettings_avatar > * { .mx_ProfileSettings_avatar > * {
@ -71,6 +70,7 @@ limitations under the License.
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
font-size: 10px; font-size: 10px;
cursor: pointer;
} }
.mx_ProfileSettings_avatar:hover .mx_ProfileSettings_avatarOverlay:not(.mx_ProfileSettings_avatarOverlay_disabled) { .mx_ProfileSettings_avatar:hover .mx_ProfileSettings_avatarOverlay:not(.mx_ProfileSettings_avatarOverlay_disabled) {

View file

@ -146,6 +146,8 @@ $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
$button-link-fg-color: $accent-color; $button-link-fg-color: $accent-color;
$button-link-bg-color: transparent; $button-link-bg-color: transparent;
$visual-bell-bg-color: #800;
$room-warning-bg-color: $header-panel-bg-color; $room-warning-bg-color: $header-panel-bg-color;
$dark-panel-bg-color: $header-panel-bg-color; $dark-panel-bg-color: $header-panel-bg-color;
@ -200,6 +202,11 @@ $interactive-tooltip-fg-color: #ffffff;
background-color: $button-secondary-bg-color; background-color: $button-secondary-bg-color;
} }
@define-mixin mx_Dialog_link {
color: $accent-color;
text-decoration: none;
}
// Nasty hacks to apply a filter to arbitrary monochrome artwork to make it // Nasty hacks to apply a filter to arbitrary monochrome artwork to make it
// better match the theme. Typically applied to dark grey 'off' buttons or // better match the theme. Typically applied to dark grey 'off' buttons or
// light grey 'on' buttons. // light grey 'on' buttons.

View file

@ -247,6 +247,8 @@ $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
$button-link-fg-color: $accent-color; $button-link-fg-color: $accent-color;
$button-link-bg-color: transparent; $button-link-bg-color: transparent;
$visual-bell-bg-color: #faa;
// Toggle switch // Toggle switch
$togglesw-off-color: #c1c9d6; $togglesw-off-color: #c1c9d6;
$togglesw-on-color: $accent-color; $togglesw-on-color: $accent-color;
@ -326,3 +328,8 @@ $interactive-tooltip-fg-color: #ffffff;
color: $accent-color; color: $accent-color;
background-color: $button-secondary-bg-color; background-color: $button-secondary-bg-color;
} }
@define-mixin mx_Dialog_link {
color: $accent-color;
text-decoration: none;
}

View file

@ -256,7 +256,7 @@ const sanitizeHtmlParams = {
allowedAttributes: { allowedAttributes: {
// custom ones first: // custom ones first:
font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
img: ['src', 'width', 'height', 'alt', 'title'], img: ['src', 'width', 'height', 'alt', 'title'],
ol: ['start'], ol: ['start'],

View file

@ -51,7 +51,14 @@ export function showStartChatInviteDialog() {
Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, { Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, {
title: _t('Start a chat'), title: _t('Start a chat'),
description: _t("Who would you like to communicate with?"), description: _t("Who would you like to communicate with?"),
placeholder: _t("Email, name or Matrix ID"), placeholder: (validAddressTypes) => {
// The set of valid address type can be mutated inside the dialog
// when you first have no IS but agree to use one in the dialog.
if (validAddressTypes.includes('email')) {
return _t("Email, name or Matrix ID");
}
return _t("Name or Matrix ID");
},
validAddressTypes, validAddressTypes,
button: _t("Start Chat"), button: _t("Start Chat"),
onFinished: _onStartDmFinished, onFinished: _onStartDmFinished,
@ -68,9 +75,15 @@ export function showRoomInviteDialog(roomId) {
Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, { Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, {
title: _t('Invite new room members'), title: _t('Invite new room members'),
description: _t('Who would you like to add to this room?'),
button: _t('Send Invites'), button: _t('Send Invites'),
placeholder: _t("Email, name or Matrix ID"), placeholder: (validAddressTypes) => {
// The set of valid address type can be mutated inside the dialog
// when you first have no IS but agree to use one in the dialog.
if (validAddressTypes.includes('email')) {
return _t("Email, name or Matrix ID");
}
return _t("Name or Matrix ID");
},
validAddressTypes, validAddressTypes,
onFinished: (shouldInvite, addrs) => { onFinished: (shouldInvite, addrs) => {
_onRoomInviteFinished(roomId, shouldInvite, addrs); _onRoomInviteFinished(roomId, shouldInvite, addrs);

View file

@ -139,8 +139,13 @@ export const CommandMap = {
description: _td('Upgrades a room to a new version'), description: _td('Upgrades a room to a new version'),
runFn: function(roomId, args) { runFn: function(roomId, args) {
if (args) { if (args) {
const room = MatrixClientPeg.get().getRoom(roomId); const cli = MatrixClientPeg.get();
Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', const room = cli.getRoom(roomId);
if (!room.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) {
return reject(_t("You do not have the required permissions to use this command."));
}
const {finished} = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation',
QuestionDialog, { QuestionDialog, {
title: _t('Room upgrade confirmation'), title: _t('Room upgrade confirmation'),
description: ( description: (
@ -198,13 +203,13 @@ export const CommandMap = {
</div> </div>
), ),
button: _t("Upgrade"), button: _t("Upgrade"),
onFinished: (confirm) => {
if (!confirm) return;
MatrixClientPeg.get().upgradeRoom(roomId, args);
},
}); });
return success();
return success(finished.then((confirm) => {
if (!confirm) return;
return cli.upgradeRoom(roomId, args);
}));
} }
return reject(this.getUsage()); return reject(this.getUsage());
}, },

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018, 2019 New Vector 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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} serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange} onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={0} delayTimeMs={0}
showIdentityServerIfRequiredByHomeserver={true}
onAfterSubmit={this.onServerDetailsNextPhaseClick} onAfterSubmit={this.onServerDetailsNextPhaseClick}
submitText={_t("Next")} submitText={_t("Next")}
submitClass="mx_Login_submit" submitClass="mx_Login_submit"

View file

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

View file

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

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 New Vector 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig"; 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. * 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 // Optional class for the submit button. Only applies if the submit button
// is to be rendered. // is to be rendered.
submitClass: PropTypes.string, 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 = { static defaultProps = {
@ -61,6 +68,7 @@ export default class ServerConfig extends React.PureComponent {
errorText: "", errorText: "",
hsUrl: props.serverConfig.hsUrl, hsUrl: props.serverConfig.hsUrl,
isUrl: props.serverConfig.isUrl, 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? // 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 // 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" // 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) { async validateAndApplyServer(hsUrl, isUrl) {
// Always try and use the defaults first // Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) { 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); this.props.onServerConfigChange(defaultConfig);
return 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) => { onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => { this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.validateServer(); this.validateServer();
@ -171,8 +215,49 @@ export default class ServerConfig extends React.PureComponent {
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog); Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
}; };
render() { _renderHomeserverSection() {
const Field = sdk.getComponent('elements.Field'); 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 AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const errorText = this.state.errorText const errorText = this.state.errorText
@ -191,31 +276,10 @@ export default class ServerConfig extends React.PureComponent {
return ( return (
<div className="mx_ServerConfig"> <div className="mx_ServerConfig">
<h3>{_t("Other servers")}</h3> <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} {errorText}
{this._renderHomeserverSection()}
{this._renderIdentityServerSection()}
<form onSubmit={this.onSubmit} autoComplete={false} action={null}> <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} {submitButton}
</form> </form>
</div> </div>

View file

@ -24,11 +24,14 @@ import createReactClass from 'create-react-class';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import Promise from 'bluebird'; import Promise from 'bluebird';
import { addressTypes, getAddressType } from '../../../UserAddress.js'; import { addressTypes, getAddressType } from '../../../UserAddress.js';
import GroupStore from '../../../stores/GroupStore'; import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email'; import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient'; import IdentityAuthClient from '../../../IdentityAuthClient';
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils';
import { abbreviateUrl } from '../../../utils/UrlUtils';
const TRUNCATE_QUERY_LIST = 40; const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
@ -49,7 +52,7 @@ module.exports = createReactClass({
// Extra node inserted after picker input, dropdown and errors // Extra node inserted after picker input, dropdown and errors
extraNode: PropTypes.node, extraNode: PropTypes.node,
value: PropTypes.string, value: PropTypes.string,
placeholder: PropTypes.string, placeholder: PropTypes.oneOfType(PropTypes.string, PropTypes.func),
roomId: PropTypes.string, roomId: PropTypes.string,
button: PropTypes.string, button: PropTypes.string,
focus: PropTypes.bool, focus: PropTypes.bool,
@ -91,6 +94,9 @@ module.exports = createReactClass({
// List of UserAddressType objects representing the set of // List of UserAddressType objects representing the set of
// auto-completion results for the current search query. // auto-completion results for the current search query.
suggestedList: [], 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() { onButtonClick: function() {
let selectedList = this.state.selectedList.slice(); let selectedList = this.state.selectedList.slice();
// Check the text input field to see if user has an unconverted address // 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 // This is important, otherwise there's no way to invite
// a perfectly valid address if there are close matches. // a perfectly valid address if there are close matches.
const addrType = getAddressType(query); const addrType = getAddressType(query);
if (this.props.validAddressTypes.includes(addrType)) { if (this.state.validAddressTypes.includes(addrType)) {
if (addrType === 'email' && !Email.looksValid(query)) { if (addrType === 'email' && !Email.looksValid(query)) {
this.setState({searchError: _t("That doesn't look like a valid email address")}); this.setState({searchError: _t("That doesn't look like a valid email address")});
return; return;
@ -470,7 +485,7 @@ module.exports = createReactClass({
isKnown: false, isKnown: false,
}; };
if (!this.props.validAddressTypes.includes(addrType)) { if (!this.state.validAddressTypes.includes(addrType)) {
hasError = true; hasError = true;
} else if (addrType === 'mx-user-id') { } else if (addrType === 'mx-user-id') {
const user = MatrixClientPeg.get().getUser(addrObj.address); const user = MatrixClientPeg.get().getUser(addrObj.address);
@ -571,12 +586,37 @@ module.exports = createReactClass({
this._addAddressesToList(text.split(/[\s,]+/)); 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() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AddressSelector = sdk.getComponent("elements.AddressSelector"); const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null; 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 = []; const query = [];
// create the invite list // create the invite list
if (this.state.selectedList.length > 0) { if (this.state.selectedList.length > 0) {
@ -603,7 +643,7 @@ module.exports = createReactClass({
ref="textinput" ref="textinput"
className="mx_AddressPickerDialog_input" className="mx_AddressPickerDialog_input"
onChange={this.onQueryChanged} onChange={this.onQueryChanged}
placeholder={this.props.placeholder} placeholder={this.getPlaceholder()}
defaultValue={this.props.value} defaultValue={this.props.value}
autoFocus={this.props.focus}> autoFocus={this.props.focus}>
</textarea>, </textarea>,
@ -614,7 +654,7 @@ module.exports = createReactClass({
let error; let error;
let addressSelector; let addressSelector;
if (this.state.invalidAddressError) { 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"> error = <div className="mx_AddressPickerDialog_error">
{ _t("You have entered an invalid address.") } { _t("You have entered an invalid address.") }
<br /> <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 ( return (
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown} <BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished} title={this.props.title}> onFinished={this.props.onFinished} title={this.props.title}>
<div className="mx_AddressPickerDialog_label"> {inputLabel}
<label htmlFor="textinput">{ this.props.description }</label>
</div>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div> <div className="mx_AddressPickerDialog_inputContainer">{ query }</div>
{ error } { error }
{ addressSelector } { addressSelector }
{ this.props.extraNode } { this.props.extraNode }
{ identityServer }
</div> </div>
<DialogButtons primaryButton={this.props.button} <DialogButtons primaryButton={this.props.button}
onPrimaryButtonClick={this.onButtonClick} onPrimaryButtonClick={this.onButtonClick}

View file

@ -154,10 +154,9 @@ export default class AppTile extends React.Component {
// Widget action listeners // Widget action listeners
dis.unregister(this.dispatcherRef); 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 it's not remaining on screen, get rid of the PersistedElement container
if (canPersist && !ActiveWidgetStore.getWidgetPersistence(this.props.id)) { if (!ActiveWidgetStore.getWidgetPersistence(this.props.id)) {
ActiveWidgetStore.destroyPersistentWidget(); ActiveWidgetStore.destroyPersistentWidget(this.props.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement"); const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
} }
@ -451,7 +450,7 @@ export default class AppTile extends React.Component {
this.setState({hasPermissionToLoad: false}); this.setState({hasPermissionToLoad: false});
// Force the widget to be non-persistent // Force the widget to be non-persistent
ActiveWidgetStore.destroyPersistentWidget(); ActiveWidgetStore.destroyPersistentWidget(this.props.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement"); const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey); 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() { _applyFormatting() {
this.activateSpoilers(this.refs.content.children);
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer // 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, // 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. // 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) { findLinks: function(nodes) {
let links = []; 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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import classNames from 'classnames';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import EditorModel from '../../../editor/model'; import EditorModel from '../../../editor/model';
@ -73,12 +75,13 @@ export default class BasicMessageEditor extends React.Component {
this._editorRef = null; this._editorRef = null;
this._autocompleteRef = null; this._autocompleteRef = null;
this._modifiedFlag = false; this._modifiedFlag = false;
this._isIMEComposing = false;
} }
_replaceEmoticon = (caret, inputType, diff) => { _replaceEmoticon = (caretPosition, inputType, diff) => {
const {model} = this.props; const {model} = this.props;
const range = model.startRange(caret); const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caret, // expand range max 8 characters backwards from caretPosition,
// as a space to look for an emoticon // as a space to look for an emoticon
let n = 8; let n = 8;
range.expandBackwardsWhile((index, offset) => { range.expandBackwardsWhile((index, offset) => {
@ -91,6 +94,7 @@ export default class BasicMessageEditor extends React.Component {
const query = emoticonMatch[1].toLowerCase().replace("-", ""); const query = emoticonMatch[1].toLowerCase().replace("-", "");
const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false); const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false);
if (data) { if (data) {
const {partCreator} = model;
const hasPrecedingSpace = emoticonMatch[0][0] === " "; const hasPrecedingSpace = emoticonMatch[0][0] === " ";
// we need the range to only comprise of the emoticon // we need the range to only comprise of the emoticon
// because we'll replace the whole range with an emoji, // 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)); range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0));
// this returns the amount of added/removed characters during the replace // this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted. // 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) { if (this.props.placeholder) {
const {isEmpty} = this.props.model; const {isEmpty} = this.props.model;
if (isEmpty) { if (isEmpty) {
this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`); this._showPlaceholder();
this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty");
} else { } else {
this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty"); this._hidePlaceholder();
this._editorRef.style.removeProperty("--placeholder");
} }
} }
this.setState({autoComplete: this.props.model.autoComplete}); 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) => { _onInput = (event) => {
// ignore any input while doing IME compositions
if (this._isIMEComposing) {
return;
}
this._modifiedFlag = true; this._modifiedFlag = true;
const sel = document.getSelection(); const sel = document.getSelection();
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
@ -160,7 +186,7 @@ export default class BasicMessageEditor extends React.Component {
} }
_refreshLastCaretIfNeeded() { _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 ... // because the editors should stop doing this when when blurred ...
// maybe it's on focus and the _editorRef isn't available yet or something. // maybe it's on focus and the _editorRef isn't available yet or something.
if (!this._editorRef) { if (!this._editorRef) {
@ -242,14 +268,6 @@ export default class BasicMessageEditor extends React.Component {
if (model.autoComplete) { if (model.autoComplete) {
const autoComplete = model.autoComplete; const autoComplete = model.autoComplete;
switch (event.key) { 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": case "ArrowUp":
autoComplete.onUpArrow(event); autoComplete.onUpArrow(event);
handled = true; handled = true;
@ -269,6 +287,9 @@ export default class BasicMessageEditor extends React.Component {
default: default:
return; // don't preventDefault on anything else return; // don't preventDefault on anything else
} }
} else if (event.key === "Tab") {
this._tabCompleteName();
handled = true;
} }
} }
if (handled) { 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() { isModified() {
return this._modifiedFlag; return this._modifiedFlag;
} }
@ -291,6 +342,8 @@ export default class BasicMessageEditor extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this._editorRef.removeEventListener("input", this._onInput, true); this._editorRef.removeEventListener("input", this._onInput, true);
this._editorRef.removeEventListener("compositionstart", this._onCompositionStart, true);
this._editorRef.removeEventListener("compositionend", this._onCompositionEnd, true);
} }
componentDidMount() { 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 // not really, but we could not serialize the parts, and just change the autoCompleter
partCreator.setAutoCompleteCreator(autoCompleteCreator( partCreator.setAutoCompleteCreator(autoCompleteCreator(
() => this._autocompleteRef, () => this._autocompleteRef,
query => this.setState({query}), query => new Promise(resolve => this.setState({query}, resolve)),
)); ));
this.historyManager = new HistoryManager(partCreator); this.historyManager = new HistoryManager(partCreator);
// initial render of model // 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, // attach input listener by hand so React doesn't proxy the events,
// as the proxied event doesn't support inputType, which we need. // as the proxied event doesn't support inputType, which we need.
this._editorRef.addEventListener("input", this._onInput, true); this._editorRef.addEventListener("input", this._onInput, true);
this._editorRef.addEventListener("compositionstart", this._onCompositionStart, true);
this._editorRef.addEventListener("compositionend", this._onCompositionEnd, true);
this._editorRef.focus(); this._editorRef.focus();
} }
@ -345,7 +400,10 @@ export default class BasicMessageEditor extends React.Component {
/> />
</div>); </div>);
} }
return (<div className="mx_BasicMessageComposer"> const classes = classNames("mx_BasicMessageComposer", {
"mx_BasicMessageComposer_input_error": this.state.showVisualBell,
});
return (<div className={classes}>
{ autoComplete } { autoComplete }
<div <div
className="mx_BasicMessageComposer_input" className="mx_BasicMessageComposer_input"

View file

@ -32,6 +32,7 @@ import {processCommandInput} from '../../../SlashCommands';
import sdk from '../../../index'; import sdk from '../../../index';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages';
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -226,8 +227,13 @@ export default class SendMessageComposer extends React.Component {
this._clearStoredEditorState(); this._clearStoredEditorState();
} }
componentDidMount() {
this._editorRef.getEditableRootNode().addEventListener("paste", this._onPaste, true);
}
componentWillUnmount() { componentWillUnmount() {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
this._editorRef.getEditableRootNode().removeEventListener("paste", this._onPaste, true);
} }
componentWillMount() { componentWillMount() {
@ -279,26 +285,50 @@ export default class SendMessageComposer extends React.Component {
}; };
_insertMention(userId) { _insertMention(userId) {
const {model} = this;
const {partCreator} = model;
const member = this.props.room.getMember(userId); const member = this.props.room.getMember(userId);
const displayName = member ? const displayName = member ?
member.rawDisplayName : userId; member.rawDisplayName : userId;
const userPillPart = this.model.partCreator.userPill(displayName, userId); const userPillPart = partCreator.userPill(displayName, userId);
this.model.insertPartsAt([userPillPart], this._editorRef.getCaret()); 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" // refocus on composer, as we just clicked "Mention"
this._editorRef && this._editorRef.focus(); this._editorRef && this._editorRef.focus();
} }
_insertQuotedMessage(event) { _insertQuotedMessage(event) {
const {partCreator} = this.model; const {model} = this;
const {partCreator} = model;
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true }); const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
// add two newlines // add two newlines
quoteParts.push(partCreator.newline()); quoteParts.push(partCreator.newline());
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" // refocus on composer, as we just clicked "Quote"
this._editorRef && this._editorRef.focus(); 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() { render() {
return ( return (
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}> <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 {_t} from "../../../languageHandler";
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from "../../../MatrixClientPeg"; import MatrixClientPeg from "../../../MatrixClientPeg";
import SdkConfig from "../../../SdkConfig";
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import dis from "../../../dispatcher"; import dis from "../../../dispatcher";
import { getThreepidBindStatus } from '../../../boundThreepids'; import { getThreepidBindStatus } from '../../../boundThreepids';
import IdentityAuthClient from "../../../IdentityAuthClient"; import IdentityAuthClient from "../../../IdentityAuthClient";
import {SERVICE_TYPES} from "matrix-js-sdk"; import {SERVICE_TYPES} from "matrix-js-sdk";
import {abbreviateUrl, unabbreviateUrl} from "../../../utils/UrlUtils"; import {abbreviateUrl, unabbreviateUrl} from "../../../utils/UrlUtils";
import { getDefaultIdentityServerUrl } from '../../../utils/IdentityServerUtils';
/** /**
* Check an IS URL is valid, including liveness check * Check an IS URL is valid, including liveness check
@ -66,10 +66,10 @@ export default class SetIdServer extends React.Component {
super(); super();
let defaultIdServer = ''; 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 // If no ID server is configured but there's one in the config, prepopulate
// the field to help the user. // the field to help the user.
defaultIdServer = abbreviateUrl(SdkConfig.get()['validated_server_config']['isUrl']); defaultIdServer = abbreviateUrl(getDefaultIdentityServerUrl());
} }
this.state = { this.state = {
@ -253,10 +253,10 @@ export default class SetIdServer extends React.Component {
}); });
let newFieldVal = ''; 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 // Prepopulate the client's default so the user at least has some idea of
// a valid value they might enter // a valid value they might enter
newFieldVal = abbreviateUrl(SdkConfig.get()['validated_server_config']['isUrl']); newFieldVal = abbreviateUrl(getDefaultIdentityServerUrl());
} }
this.setState({ this.setState({

View file

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

View file

@ -33,6 +33,10 @@ export default class AutocompleteWrapperModel {
}); });
} }
close() {
this._updateCallback({close: true});
}
hasSelection() { hasSelection() {
return this._getAutocompleterComponent().hasSelection(); return this._getAutocompleterComponent().hasSelection();
} }
@ -52,9 +56,6 @@ export default class AutocompleteWrapperModel {
} else { } else {
await acComponent.moveSelection(e.shiftKey ? -1 : +1); await acComponent.moveSelection(e.shiftKey ? -1 : +1);
} }
this._updateCallback({
close: true,
});
} }
onUpArrow() { onUpArrow() {
@ -70,7 +71,7 @@ export default class AutocompleteWrapperModel {
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text) // so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
this._queryPart = part; this._queryPart = part;
this._queryOffset = offset; this._queryOffset = offset;
this._updateQuery(part.text); return this._updateQuery(part.text);
} }
onComponentSelectionChange(completion) { onComponentSelectionChange(completion) {

View file

@ -84,6 +84,14 @@ function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) {
foundCaret = true; foundCaret = true;
} }
} }
// usually newlines are entered as new DIV elements,
// but for example while pasting in some browsers, they are still
// converted to BRs, so also take these into account when they
// are not the last element in the DIV.
if (node.tagName === "BR" && node.nextSibling) {
text += "\n";
focusNodeOffset += 1;
}
const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node); const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node);
if (nodeText) { if (nodeText) {
if (!foundCaret) { if (!foundCaret) {

View file

@ -35,6 +35,11 @@ import Range from "./range";
* This is used to adjust the caret position. * This is used to adjust the caret position.
*/ */
/**
* @callback ManualTransformCallback
* @return the caret position
*/
export default class EditorModel { export default class EditorModel {
constructor(parts, partCreator, updateCallback = null) { constructor(parts, partCreator, updateCallback = null) {
this._parts = parts; this._parts = parts;
@ -44,7 +49,6 @@ export default class EditorModel {
this._autoCompletePartIdx = null; this._autoCompletePartIdx = null;
this._transformCallback = null; this._transformCallback = null;
this.setUpdateCallback(updateCallback); this.setUpdateCallback(updateCallback);
this._updateInProgress = false;
} }
/** /**
@ -90,10 +94,14 @@ export default class EditorModel {
_removePart(index) { _removePart(index) {
this._parts.splice(index, 1); this._parts.splice(index, 1);
if (this._activePartIdx >= index) { if (index === this._activePartIdx) {
this._activePartIdx = null;
} else if (this._activePartIdx > index) {
--this._activePartIdx; --this._activePartIdx;
} }
if (this._autoCompletePartIdx >= index) { if (index === this._autoCompletePartIdx) {
this._autoCompletePartIdx = null;
} else if (this._autoCompletePartIdx > index) {
--this._autoCompletePartIdx; --this._autoCompletePartIdx;
} }
} }
@ -150,8 +158,14 @@ export default class EditorModel {
this._updateCallback(caret, inputType); this._updateCallback(caret, inputType);
} }
insertPartsAt(parts, caret) { /**
const position = this.positionForOffset(caret.offset, caret.atNodeEnd); * Inserts the given parts at the given position.
* Should be run inside a `model.transform()` callback.
* @param {Part[]} parts the parts to replace the range with
* @param {DocumentPosition} position the position to start inserting at
* @return {Number} the amount of characters added
*/
insert(parts, position) {
const insertIndex = this._splitAt(position); const insertIndex = this._splitAt(position);
let newTextLength = 0; let newTextLength = 0;
for (let i = 0; i < parts.length; ++i) { for (let i = 0; i < parts.length; ++i) {
@ -159,36 +173,31 @@ export default class EditorModel {
newTextLength += part.text.length; newTextLength += part.text.length;
this._insertPart(insertIndex + i, part); this._insertPart(insertIndex + i, part);
} }
// put caret after new part return newTextLength;
const lastPartIndex = insertIndex + parts.length - 1;
const newPosition = new DocumentPosition(lastPartIndex, newTextLength);
this._updateCallback(newPosition);
} }
update(newValue, inputType, caret) { update(newValue, inputType, caret) {
this._updateInProgress = true;
const diff = this._diff(newValue, inputType, caret); const diff = this._diff(newValue, inputType, caret);
const position = this.positionForOffset(diff.at, caret.atNodeEnd); const position = this.positionForOffset(diff.at, caret.atNodeEnd);
let removedOffsetDecrease = 0; let removedOffsetDecrease = 0;
if (diff.removed) { if (diff.removed) {
removedOffsetDecrease = this.removeText(position, diff.removed.length); removedOffsetDecrease = this.removeText(position, diff.removed.length);
} }
const canOpenAutoComplete = inputType !== "insertFromPaste" && inputType !== "insertFromDrop";
let addedLen = 0; let addedLen = 0;
if (diff.added) { if (diff.added) {
// these shouldn't trigger auto-complete, you just want to append a piece of text addedLen = this._addText(position, diff.added, inputType);
addedLen = this._addText(position, diff.added, {validate: canOpenAutoComplete});
} }
this._mergeAdjacentParts(); this._mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen; const caretOffset = diff.at - removedOffsetDecrease + addedLen;
let newPosition = this.positionForOffset(caretOffset, true); let newPosition = this.positionForOffset(caretOffset, true);
this._setActivePart(newPosition, canOpenAutoComplete); const canOpenAutoComplete = inputType !== "insertFromPaste" && inputType !== "insertFromDrop";
const acPromise = this._setActivePart(newPosition, canOpenAutoComplete);
if (this._transformCallback) { if (this._transformCallback) {
const transformAddedLen = this._transform(newPosition, inputType, diff); const transformAddedLen = this._transform(newPosition, inputType, diff);
newPosition = this.positionForOffset(caretOffset + transformAddedLen, true); newPosition = this.positionForOffset(caretOffset + transformAddedLen, true);
} }
this._updateInProgress = false;
this._updateCallback(newPosition, inputType, diff); this._updateCallback(newPosition, inputType, diff);
return acPromise;
} }
_transform(newPosition, inputType, diff) { _transform(newPosition, inputType, diff) {
@ -214,13 +223,14 @@ export default class EditorModel {
} }
// not _autoComplete, only there if active part is autocomplete part // not _autoComplete, only there if active part is autocomplete part
if (this.autoComplete) { if (this.autoComplete) {
this.autoComplete.onPartUpdate(part, pos.offset); return this.autoComplete.onPartUpdate(part, pos.offset);
} }
} else { } else {
this._activePartIdx = null; this._activePartIdx = null;
this._autoComplete = null; this._autoComplete = null;
this._autoCompletePartIdx = null; this._autoCompletePartIdx = null;
} }
return Promise.resolve();
} }
_onAutoComplete = ({replacePart, caretOffset, close}) => { _onAutoComplete = ({replacePart, caretOffset, close}) => {
@ -322,22 +332,20 @@ export default class EditorModel {
* inserts `str` into the model at `pos`. * inserts `str` into the model at `pos`.
* @param {Object} pos * @param {Object} pos
* @param {string} str * @param {string} str
* @param {Object} options * @param {string} inputType the source of the input, see html InputEvent.inputType
* @param {bool} options.validate Whether characters will be validated by the part. * @param {bool} options.validate Whether characters will be validated by the part.
* Validating allows the inserted text to be parsed according to the part rules. * Validating allows the inserted text to be parsed according to the part rules.
* @return {Number} how far from position (in characters) the insertion ended. * @return {Number} how far from position (in characters) the insertion ended.
* This can be more than the length of `str` when crossing non-editable parts, which are skipped. * This can be more than the length of `str` when crossing non-editable parts, which are skipped.
*/ */
_addText(pos, str, {validate=true}) { _addText(pos, str, inputType) {
let {index} = pos; let {index} = pos;
const {offset} = pos; const {offset} = pos;
let addLen = str.length; let addLen = str.length;
const part = this._parts[index]; const part = this._parts[index];
if (part) { if (part) {
if (part.canEdit) { if (part.canEdit) {
if (validate && part.validateAndInsert(offset, str)) { if (part.validateAndInsert(offset, str, inputType)) {
str = null;
} else if (!validate && part.insert(offset, str)) {
str = null; str = null;
} else { } else {
const splitPart = part.split(offset); const splitPart = part.split(offset);
@ -356,13 +364,8 @@ export default class EditorModel {
index = 0; index = 0;
} }
while (str) { while (str) {
const newPart = this._partCreator.createPartForInput(str, index); const newPart = this._partCreator.createPartForInput(str, index, inputType);
if (validate) { str = newPart.appendUntilRejected(str, inputType);
str = newPart.appendUntilRejected(str);
} else {
newPart.insert(0, str);
str = null;
}
this._insertPart(index, newPart); this._insertPart(index, newPart);
index += 1; index += 1;
} }
@ -395,18 +398,15 @@ export default class EditorModel {
return new Range(this, position); return new Range(this, position);
} }
// called from Range.replace //mostly internal, called from Range.replace
replaceRange(startPosition, endPosition, parts) { replaceRange(startPosition, endPosition, parts) {
// convert end position to offset, so it is independent of how the document is split into parts
// which we'll change when splitting up at the start position
const endOffset = endPosition.asOffset(this);
const newStartPartIndex = this._splitAt(startPosition); const newStartPartIndex = this._splitAt(startPosition);
const idxDiff = newStartPartIndex - startPosition.index; // convert it back to position once split at start
// if both position are in the same part, and we split it at start position, endPosition = endOffset.asPosition(this);
// the offset of the end position needs to be decreased by the offset of the start position const newEndPartIndex = this._splitAt(endPosition);
const removedOffset = startPosition.index === endPosition.index ? startPosition.offset : 0;
const adjustedEndPosition = new DocumentPosition(
endPosition.index + idxDiff,
endPosition.offset - removedOffset,
);
const newEndPartIndex = this._splitAt(adjustedEndPosition);
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) { for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
this._removePart(i); this._removePart(i);
} }
@ -416,8 +416,18 @@ export default class EditorModel {
insertIdx += 1; insertIdx += 1;
} }
this._mergeAdjacentParts(); this._mergeAdjacentParts();
if (!this._updateInProgress) { }
this._updateCallback();
} /**
* Performs a transformation not part of an update cycle.
* Modifying the model should only happen inside a transform call if not part of an update call.
* @param {ManualTransformCallback} callback to run the transformations in
* @return {Promise} a promise when auto-complete (if applicable) is done updating
*/
transform(callback) {
const pos = callback();
const acPromise = this._setActivePart(pos, true);
this._updateCallback(pos);
return acPromise;
} }
} }

26
src/editor/offset.js Normal file
View file

@ -0,0 +1,26 @@
/*
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.
*/
export default class DocumentOffset {
constructor(offset, atEnd) {
this.offset = offset;
this.atEnd = atEnd;
}
asPosition(model) {
return model.positionForOffset(this.offset, this.atEnd);
}
}

View file

@ -23,7 +23,7 @@ class BasePart {
this._text = text; this._text = text;
} }
acceptsInsertion(chr) { acceptsInsertion(chr, offset, inputType) {
return true; return true;
} }
@ -56,10 +56,11 @@ class BasePart {
} }
// append str, returns the remaining string if a character was rejected. // append str, returns the remaining string if a character was rejected.
appendUntilRejected(str) { appendUntilRejected(str, inputType) {
const offset = this.text.length;
for (let i = 0; i < str.length; ++i) { for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i); const chr = str.charAt(i);
if (!this.acceptsInsertion(chr, i)) { if (!this.acceptsInsertion(chr, offset + i, inputType)) {
this._text = this._text + str.substr(0, i); this._text = this._text + str.substr(0, i);
return str.substr(i); return str.substr(i);
} }
@ -69,10 +70,10 @@ class BasePart {
// inserts str at offset if all the characters in str were accepted, otherwise don't do anything // inserts str at offset if all the characters in str were accepted, otherwise don't do anything
// return whether the str was accepted or not. // return whether the str was accepted or not.
validateAndInsert(offset, str) { validateAndInsert(offset, str, inputType) {
for (let i = 0; i < str.length; ++i) { for (let i = 0; i < str.length; ++i) {
const chr = str.charAt(i); const chr = str.charAt(i);
if (!this.acceptsInsertion(chr)) { if (!this.acceptsInsertion(chr, offset + i, inputType)) {
return false; return false;
} }
} }
@ -82,16 +83,6 @@ class BasePart {
return true; return true;
} }
insert(offset, str) {
if (this.canEdit) {
const beforeInsert = this._text.substr(0, offset);
const afterInsert = this._text.substr(offset);
this._text = beforeInsert + str + afterInsert;
return true;
}
return false;
}
createAutoComplete() {} createAutoComplete() {}
trim(len) { trim(len) {
@ -119,8 +110,15 @@ class BasePart {
// exported for unit tests, should otherwise only be used through PartCreator // exported for unit tests, should otherwise only be used through PartCreator
export class PlainPart extends BasePart { export class PlainPart extends BasePart {
acceptsInsertion(chr) { acceptsInsertion(chr, offset, inputType) {
return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n"; if (chr === "\n") {
return false;
}
// when not pasting or dropping text, reject characters that should start a pill candidate
if (inputType !== "insertFromPaste" && inputType !== "insertFromDrop") {
return chr !== "@" && chr !== "#" && chr !== ":";
}
return true;
} }
toDOMNode() { toDOMNode() {
@ -141,7 +139,6 @@ export class PlainPart extends BasePart {
updateDOMNode(node) { updateDOMNode(node) {
if (node.textContent !== this.text) { if (node.textContent !== this.text) {
// console.log("changing plain text from", node.textContent, "to", this.text);
node.textContent = this.text; node.textContent = this.text;
} }
} }
@ -211,8 +208,8 @@ class PillPart extends BasePart {
} }
class NewlinePart extends BasePart { class NewlinePart extends BasePart {
acceptsInsertion(chr, i) { acceptsInsertion(chr, offset) {
return (this.text.length + i) === 0 && chr === "\n"; return offset === 0 && chr === "\n";
} }
acceptsRemoval(position, chr) { acceptsRemoval(position, chr) {
@ -284,6 +281,9 @@ class UserPillPart extends PillPart {
} }
setAvatar(node) { setAvatar(node) {
if (!this._member) {
return;
}
const name = this._member.name || this._member.userId; const name = this._member.name || this._member.userId;
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId); const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId);
let avatarUrl = Avatar.avatarUrlForMember( let avatarUrl = Avatar.avatarUrlForMember(
@ -328,11 +328,11 @@ class PillCandidatePart extends PlainPart {
return this._autoCompleteCreator.create(updateCallback); return this._autoCompleteCreator.create(updateCallback);
} }
acceptsInsertion(chr, i) { acceptsInsertion(chr, offset, inputType) {
if ((this.text.length + i) === 0) { if (offset === 0) {
return true; return true;
} else { } else {
return super.acceptsInsertion(chr, i); return super.acceptsInsertion(chr, offset, inputType);
} }
} }
@ -366,6 +366,8 @@ export class PartCreator {
constructor(room, client, autoCompleteCreator = null) { constructor(room, client, autoCompleteCreator = null) {
this._room = room; this._room = room;
this._client = client; this._client = client;
// pre-create the creator as an object even without callback so it can already be passed
// to PillCandidatePart (e.g. while deserializing) and set later on
this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)}; this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)};
} }

View file

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import DocumentOffset from "./offset";
export default class DocumentPosition { export default class DocumentPosition {
constructor(index, offset) { constructor(index, offset) {
this._index = index; this._index = index;
@ -104,4 +106,18 @@ export default class DocumentPosition {
} }
} }
} }
asOffset(model) {
if (this.index === -1) {
return new DocumentOffset(0, true);
}
let offset = 0;
for (let i = 0; i < this.index; ++i) {
offset += model.parts[i].text.length;
}
offset += this.offset;
const lastPart = model.parts[this.index];
const atEnd = offset >= lastPart.text.length;
return new DocumentOffset(offset, atEnd);
}
} }

View file

@ -41,6 +41,12 @@ export default class Range {
return text; return text;
} }
/**
* Splits the model at the range boundaries and replaces with the given parts.
* Should be run inside a `model.transform()` callback.
* @param {Part[]} parts the parts to replace the range with
* @return {Number} the net amount of characters added, can be negative.
*/
replace(parts) { replace(parts) {
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0); const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
let oldLength = 0; let oldLength = 0;

View file

@ -117,7 +117,6 @@
"Email, name or Matrix ID": "Email, name or Matrix ID", "Email, name or Matrix ID": "Email, name or Matrix ID",
"Start Chat": "Start Chat", "Start Chat": "Start Chat",
"Invite new room members": "Invite new room members", "Invite new room members": "Invite new room members",
"Who would you like to add to this room?": "Who would you like to add to this room?",
"Send Invites": "Send Invites", "Send Invites": "Send Invites",
"Failed to start chat": "Failed to start chat", "Failed to start chat": "Failed to start chat",
"Operation failed": "Operation failed", "Operation failed": "Operation failed",
@ -146,6 +145,7 @@
"/ddg is not a command": "/ddg is not a command", "/ddg is not a command": "/ddg is not a command",
"To use it, just wait for autocomplete results to load and tab through them.": "To use it, just wait for autocomplete results to load and tab through them.", "To use it, just wait for autocomplete results to load and tab through them.": "To use it, just wait for autocomplete results to load and tab through them.",
"Upgrades a room to a new version": "Upgrades a room to a new version", "Upgrades a room to a new version": "Upgrades a room to a new version",
"You do not have the required permissions to use this command.": "You do not have the required permissions to use this command.",
"Room upgrade confirmation": "Room upgrade confirmation", "Room upgrade confirmation": "Room upgrade confirmation",
"Upgrading a room can be destructive and isn't always necessary.": "Upgrading a room can be destructive and isn't always necessary.", "Upgrading a room can be destructive and isn't always necessary.": "Upgrading a room can be destructive and isn't always necessary.",
"Room upgrades are usually recommended when a room version is considered <i>unstable</i>. Unstable room versions might have bugs, missing features, or security vulnerabilities.": "Room upgrades are usually recommended when a room version is considered <i>unstable</i>. Unstable room versions might have bugs, missing features, or security vulnerabilities.", "Room upgrades are usually recommended when a room version is considered <i>unstable</i>. Unstable room versions might have bugs, missing features, or security vulnerabilities.": "Room upgrades are usually recommended when a room version is considered <i>unstable</i>. Unstable room versions might have bugs, missing features, or security vulnerabilities.",
@ -680,7 +680,7 @@
"Change history visibility": "Change history visibility", "Change history visibility": "Change history visibility",
"Change permissions": "Change permissions", "Change permissions": "Change permissions",
"Change topic": "Change topic", "Change topic": "Change topic",
"Enable room encryption": "Enable room encryption", "Upgrade the room": "Upgrade the room",
"Modify widgets": "Modify widgets", "Modify widgets": "Modify widgets",
"Failed to unban": "Failed to unban", "Failed to unban": "Failed to unban",
"Unban": "Unban", "Unban": "Unban",
@ -1161,6 +1161,8 @@
"That doesn't look like a valid email address": "That doesn't look like a valid email address", "That doesn't look like a valid email address": "That doesn't look like a valid email address",
"You have entered an invalid address.": "You have entered an invalid address.", "You have entered an invalid address.": "You have entered an invalid address.",
"Try using one of the following valid address types: %(validTypesList)s.": "Try using one of the following valid address types: %(validTypesList)s.", "Try using one of the following valid address types: %(validTypesList)s.": "Try using one of the following valid address types: %(validTypesList)s.",
"Use an identity server to invite by email. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.": "Use an identity server to invite by email. <default>Use the default (%(defaultIdentityServerName)s)</default> or manage in <settings>Settings</settings>.",
"Use an identity server to invite by email. Manage in <settings>Settings</settings>.": "Use an identity server to invite by email. Manage in <settings>Settings</settings>.",
"The following users may not exist": "The following users may not exist", "The following users may not exist": "The following users may not exist",
"Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?", "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?",
"Invite anyway and never warn me again": "Invite anyway and never warn me again", "Invite anyway and never warn me again": "Invite anyway and never warn me again",
@ -1462,13 +1464,14 @@
"Phone (optional)": "Phone (optional)", "Phone (optional)": "Phone (optional)",
"Create your Matrix account on %(serverName)s": "Create your Matrix account on %(serverName)s", "Create your Matrix account on %(serverName)s": "Create your Matrix account on %(serverName)s",
"Create your Matrix account on <underlinedServerName />": "Create your Matrix account on <underlinedServerName />", "Create your Matrix account on <underlinedServerName />": "Create your Matrix account on <underlinedServerName />",
"Use an email address to recover your account.": "Use an email address to recover your account.", "Set an email for account recovery. Use email or phone to optionally be discoverable by existing contacts.": "Set an email for account recovery. Use email or phone to optionally be discoverable by existing contacts.",
"Other users can invite you to rooms using your contact details.": "Other users can invite you to rooms using your contact details.", "Set an email for account recovery. Use email to optionally be discoverable by existing contacts.": "Set an email for account recovery. Use email to optionally be discoverable by existing contacts.",
"No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.": "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.", "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.": "No Identity Server is configured: no email addreses can be added. You will be unable to reset your password.",
"Other servers": "Other servers", "Enter your custom homeserver URL <a>What does this mean?</a>": "Enter your custom homeserver URL <a>What does this mean?</a>",
"Enter custom server URLs <a>What does this mean?</a>": "Enter custom server URLs <a>What does this mean?</a>",
"Homeserver URL": "Homeserver URL", "Homeserver URL": "Homeserver URL",
"Enter your custom identity server URL <a>What does this mean?</a>": "Enter your custom identity server URL <a>What does this mean?</a>",
"Identity Server URL": "Identity Server URL", "Identity Server URL": "Identity Server URL",
"Other servers": "Other servers",
"Free": "Free", "Free": "Free",
"Join millions for free on the largest public server": "Join millions for free on the largest public server", "Join millions for free on the largest public server": "Join millions for free on the largest public server",
"Premium": "Premium", "Premium": "Premium",

View file

@ -67,11 +67,12 @@ class ActiveWidgetStore extends EventEmitter {
if (ev.getType() !== 'im.vector.modular.widgets') return; if (ev.getType() !== 'im.vector.modular.widgets') return;
if (ev.getStateKey() === this._persistentWidgetId) { if (ev.getStateKey() === this._persistentWidgetId) {
this.destroyPersistentWidget(); this.destroyPersistentWidget(this._persistentWidgetId);
} }
} }
destroyPersistentWidget() { destroyPersistentWidget(id) {
if (id !== this._persistentWidgetId) return;
const toDeleteId = this._persistentWidgetId; const toDeleteId = this._persistentWidgetId;
this.setWidgetPersistence(toDeleteId, false); this.setWidgetPersistence(toDeleteId, false);

View file

@ -0,0 +1,30 @@
/*
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 SdkConfig from '../SdkConfig';
import MatrixClientPeg from '../MatrixClientPeg';
export function getDefaultIdentityServerUrl() {
return SdkConfig.get()['validated_server_config']['isUrl'];
}
export function useDefaultIdentityServer() {
const url = getDefaultIdentityServerUrl();
// Account data change will update localstorage, client, etc through dispatcher
MatrixClientPeg.get().setAccountData("m.identity_server", {
base_url: url,
});
}

View file

@ -52,7 +52,6 @@ describe('editor/range', function() {
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " "); range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
expect(range.text).toBe("world"); expect(range.text).toBe("world");
range.replace([pc.roomPill(pillChannel)]); range.replace([pc.roomPill(pillChannel)]);
console.log({parts: JSON.stringify(model.serializeParts())});
expect(model.parts[0].type).toBe("plain"); expect(model.parts[0].type).toBe("plain");
expect(model.parts[0].text).toBe("hello "); expect(model.parts[0].text).toBe("hello ");
expect(model.parts[1].type).toBe("room-pill"); expect(model.parts[1].type).toBe("room-pill");
@ -60,7 +59,6 @@ describe('editor/range', function() {
expect(model.parts[2].type).toBe("plain"); expect(model.parts[2].type).toBe("plain");
expect(model.parts[2].text).toBe("!!!!"); expect(model.parts[2].text).toBe("!!!!");
expect(model.parts.length).toBe(3); expect(model.parts.length).toBe(3);
expect(renderer.count).toBe(1);
}); });
it('range replace across parts', function() { it('range replace across parts', function() {
const renderer = createRenderer(); const renderer = createRenderer();
@ -74,7 +72,6 @@ describe('editor/range', function() {
const range = model.startRange(model.positionForOffset(14)); // after "replace" const range = model.startRange(model.positionForOffset(14)); // after "replace"
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " "); range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
expect(range.text).toBe("replace"); expect(range.text).toBe("replace");
console.log("range.text", {text: range.text});
range.replace([pc.roomPill(pillChannel)]); range.replace([pc.roomPill(pillChannel)]);
expect(model.parts[0].type).toBe("plain"); expect(model.parts[0].type).toBe("plain");
expect(model.parts[0].text).toBe("try to "); expect(model.parts[0].text).toBe("try to ");
@ -83,6 +80,23 @@ describe('editor/range', function() {
expect(model.parts[2].type).toBe("plain"); expect(model.parts[2].type).toBe("plain");
expect(model.parts[2].text).toBe(" me"); expect(model.parts[2].text).toBe(" me");
expect(model.parts.length).toBe(3); expect(model.parts.length).toBe(3);
expect(renderer.count).toBe(1); });
// bug found while implementing tab completion
it('replace a part with an identical part with start position at end of previous part', function() {
const renderer = createRenderer();
const pc = createPartCreator();
const model = new EditorModel([
pc.plain("hello "),
pc.pillCandidate("man"),
], pc, renderer);
const range = model.startRange(model.positionForOffset(9, true)); // before "man"
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
expect(range.text).toBe("man");
range.replace([pc.pillCandidate(range.text)]);
expect(model.parts[0].type).toBe("plain");
expect(model.parts[0].text).toBe("hello ");
expect(model.parts[1].type).toBe("pill-candidate");
expect(model.parts[1].text).toBe("man");
expect(model.parts.length).toBe(2);
}); });
}); });

View file

@ -2852,16 +2852,16 @@ eslint-scope@^4.0.0, eslint-scope@^4.0.3:
estraverse "^4.1.1" estraverse "^4.1.1"
eslint-utils@^1.3.1: eslint-utils@^1.3.1:
version "1.4.0" version "1.4.2"
resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.0.tgz#e2c3c8dba768425f897cf0f9e51fe2e241485d4c" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.2.tgz#166a5180ef6ab7eb462f162fd0e6f2463d7309ab"
integrity sha512-7ehnzPaP5IIEh1r1tkjuIrxqhNkzUJa9z3R92tLJdZIVdWaczEhr3EbhGtsMrVxi1KeR8qA7Off6SWc5WNQqyQ== integrity sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q==
dependencies: dependencies:
eslint-visitor-keys "^1.0.0" eslint-visitor-keys "^1.0.0"
eslint-visitor-keys@^1.0.0: eslint-visitor-keys@^1.0.0:
version "1.0.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ== integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
eslint@^5.12.0: eslint@^5.12.0:
version "5.16.0" version "5.16.0"