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:
commit
8967871b23
37 changed files with 695 additions and 193 deletions
|
@ -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 {
|
||||||
|
|
|
@ -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],
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -67,3 +67,6 @@ limitations under the License.
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_AddressPickerDialog_identityServer {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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());
|
||||||
},
|
},
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
51
src/components/views/elements/Spoiler.js
Normal file
51
src/components/views/elements/Spoiler.js
Normal 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 }
|
||||||
|
|
||||||
|
<span className="mx_EventTile_spoiler_content" dangerouslySetInnerHTML={{ __html: this.props.contentHtml }} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 = [];
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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},
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
26
src/editor/offset.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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);
|
||||||
|
|
30
src/utils/IdentityServerUtils.js
Normal file
30
src/utils/IdentityServerUtils.js
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue