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.jsonpull/21833/head
commit
8967871b23
res
themes
dark/css
light/css
src
components
structures/auth
views
dialogs
elements
messages
settings
i18n/strings
stores
test/editor
|
@ -281,6 +281,12 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
|
|||
box-shadow: 2px 15px 30px 0 $dialog-shadow-color;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
|
||||
a:link,
|
||||
a:hover,
|
||||
a:visited {
|
||||
@mixin mx_Dialog_link;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_Dialog_fixedWidth {
|
||||
|
|
|
@ -39,8 +39,7 @@ limitations under the License.
|
|||
a:link,
|
||||
a:hover,
|
||||
a:visited {
|
||||
color: $accent-color;
|
||||
text-decoration: none;
|
||||
@mixin mx_Dialog_link;
|
||||
}
|
||||
|
||||
input[type=text],
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,23 +15,6 @@ See the License for the specific language governing permissions and
|
|||
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 {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
@ -39,3 +23,13 @@ limitations under the License.
|
|||
display: block;
|
||||
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;
|
||||
}
|
||||
|
||||
.mx_AddressPickerDialog_identityServer {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
|
|
@ -27,6 +27,15 @@ limitations under the License.
|
|||
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 {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
|
|
|
@ -296,6 +296,25 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
|
|||
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 {
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
|
@ -129,7 +129,7 @@ limitations under the License.
|
|||
}
|
||||
|
||||
@keyframes visualbell {
|
||||
from { background-color: #faa; }
|
||||
from { background-color: $visual-bell-bg-color; }
|
||||
to { background-color: $primary-bg-color; }
|
||||
}
|
||||
|
||||
|
|
|
@ -43,7 +43,6 @@ limitations under the License.
|
|||
height: 88px;
|
||||
margin-left: 13px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mx_ProfileSettings_avatar > * {
|
||||
|
@ -71,6 +70,7 @@ limitations under the License.
|
|||
text-align: center;
|
||||
vertical-align: middle;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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-bg-color: transparent;
|
||||
|
||||
$visual-bell-bg-color: #800;
|
||||
|
||||
$room-warning-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;
|
||||
}
|
||||
|
||||
@define-mixin mx_Dialog_link {
|
||||
color: $accent-color;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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-bg-color: transparent;
|
||||
|
||||
$visual-bell-bg-color: #faa;
|
||||
|
||||
// Toggle switch
|
||||
$togglesw-off-color: #c1c9d6;
|
||||
$togglesw-on-color: $accent-color;
|
||||
|
@ -326,3 +328,8 @@ $interactive-tooltip-fg-color: #ffffff;
|
|||
color: $accent-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: {
|
||||
// custom ones first:
|
||||
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
|
||||
img: ['src', 'width', 'height', 'alt', 'title'],
|
||||
ol: ['start'],
|
||||
|
|
|
@ -51,7 +51,14 @@ export function showStartChatInviteDialog() {
|
|||
Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, {
|
||||
title: _t('Start a chat'),
|
||||
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,
|
||||
button: _t("Start Chat"),
|
||||
onFinished: _onStartDmFinished,
|
||||
|
@ -68,9 +75,15 @@ export function showRoomInviteDialog(roomId) {
|
|||
|
||||
Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, {
|
||||
title: _t('Invite new room members'),
|
||||
description: _t('Who would you like to add to this room?'),
|
||||
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,
|
||||
onFinished: (shouldInvite, addrs) => {
|
||||
_onRoomInviteFinished(roomId, shouldInvite, addrs);
|
||||
|
|
|
@ -139,8 +139,13 @@ export const CommandMap = {
|
|||
description: _td('Upgrades a room to a new version'),
|
||||
runFn: function(roomId, args) {
|
||||
if (args) {
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation',
|
||||
const cli = MatrixClientPeg.get();
|
||||
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, {
|
||||
title: _t('Room upgrade confirmation'),
|
||||
description: (
|
||||
|
@ -198,13 +203,13 @@ export const CommandMap = {
|
|||
</div>
|
||||
),
|
||||
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());
|
||||
},
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -208,6 +209,7 @@ module.exports = React.createClass({
|
|||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={0}
|
||||
showIdentityServerIfRequiredByHomeserver={true}
|
||||
onAfterSubmit={this.onServerDetailsNextPhaseClick}
|
||||
submitText={_t("Next")}
|
||||
submitClass="mx_Login_submit"
|
||||
|
|
|
@ -499,6 +499,7 @@ module.exports = React.createClass({
|
|||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
delayTimeMs={250}
|
||||
showIdentityServerIfRequiredByHomeserver={true}
|
||||
{...serverDetailsProps}
|
||||
/>;
|
||||
break;
|
||||
|
|
|
@ -444,6 +444,15 @@ module.exports = React.createClass({
|
|||
return true;
|
||||
},
|
||||
|
||||
_showPhoneNumber() {
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
if (!threePidLogin || !haveIs || !this._authStepIsUsed('m.login.msisdn')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
renderEmail() {
|
||||
if (!this._showEmail()) {
|
||||
return null;
|
||||
|
@ -490,9 +499,7 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
renderPhoneNumber() {
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
if (!threePidLogin || !haveIs || !this._authStepIsUsed('m.login.msisdn')) {
|
||||
if (!this._showPhoneNumber()) {
|
||||
return null;
|
||||
}
|
||||
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
|
||||
|
@ -564,11 +571,24 @@ module.exports = React.createClass({
|
|||
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
|
||||
);
|
||||
|
||||
const emailHelperText = this._showEmail() ? <div>
|
||||
{_t("Use an email address to recover your account.") + " "}
|
||||
{_t("Other users can invite you to rooms using your contact details.")}
|
||||
</div> : null;
|
||||
|
||||
let emailHelperText = null;
|
||||
if (this._showEmail()) {
|
||||
if (this._showPhoneNumber()) {
|
||||
emailHelperText = <div>
|
||||
{_t(
|
||||
"Set an email for account recovery. " +
|
||||
"Use email or phone to optionally be discoverable by existing contacts.",
|
||||
)}
|
||||
</div>;
|
||||
} else {
|
||||
emailHelperText = <div>
|
||||
{_t(
|
||||
"Set an email for account recovery. " +
|
||||
"Use email to optionally be discoverable by existing contacts.",
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
const noIsText = haveIs ? null : <div>
|
||||
{_t(
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -23,6 +24,8 @@ import { _t } from '../../../languageHandler';
|
|||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { createClient } from 'matrix-js-sdk/lib/matrix';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/*
|
||||
* A pure UI component which displays the HS and IS to use.
|
||||
|
@ -46,6 +49,10 @@ export default class ServerConfig extends React.PureComponent {
|
|||
// Optional class for the submit button. Only applies if the submit button
|
||||
// is to be rendered.
|
||||
submitClass: PropTypes.string,
|
||||
|
||||
// Whether the flow this component is embedded in requires an identity
|
||||
// server when the homeserver says it will need one. Default false.
|
||||
showIdentityServerIfRequiredByHomeserver: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -61,6 +68,7 @@ export default class ServerConfig extends React.PureComponent {
|
|||
errorText: "",
|
||||
hsUrl: props.serverConfig.hsUrl,
|
||||
isUrl: props.serverConfig.isUrl,
|
||||
showIdentityServer: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -75,14 +83,41 @@ export default class ServerConfig extends React.PureComponent {
|
|||
// TODO: Do we want to support .well-known lookups here?
|
||||
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
|
||||
// find their homeserver without demanding they use "https://matrix.org"
|
||||
return this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
|
||||
const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
|
||||
if (!result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If the UI flow this component is embedded in requires an identity
|
||||
// server when the homeserver says it will need one, check first and
|
||||
// reveal this field if not already shown.
|
||||
// XXX: This a backward compatibility path for homeservers that require
|
||||
// an identity server to be passed during certain flows.
|
||||
// See also https://github.com/matrix-org/synapse/pull/5868.
|
||||
if (
|
||||
this.props.showIdentityServerIfRequiredByHomeserver &&
|
||||
!this.state.showIdentityServer &&
|
||||
await this.isIdentityServerRequiredByHomeserver()
|
||||
) {
|
||||
this.setState({
|
||||
showIdentityServer: true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async validateAndApplyServer(hsUrl, isUrl) {
|
||||
// Always try and use the defaults first
|
||||
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
|
||||
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
|
||||
this.setState({busy: false, errorText: ""});
|
||||
this.setState({
|
||||
hsUrl: defaultConfig.hsUrl,
|
||||
isUrl: defaultConfig.isUrl,
|
||||
busy: false,
|
||||
errorText: "",
|
||||
});
|
||||
this.props.onServerConfigChange(defaultConfig);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
@ -126,6 +161,15 @@ export default class ServerConfig extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
async isIdentityServerRequiredByHomeserver() {
|
||||
// XXX: We shouldn't have to create a whole new MatrixClient just to
|
||||
// check if the homeserver requires an identity server... Should it be
|
||||
// extracted to a static utils function...?
|
||||
return createClient({
|
||||
baseUrl: this.state.hsUrl,
|
||||
}).doesServerRequireIdServerParam();
|
||||
}
|
||||
|
||||
onHomeserverBlur = (ev) => {
|
||||
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
|
||||
this.validateServer();
|
||||
|
@ -171,8 +215,49 @@ export default class ServerConfig extends React.PureComponent {
|
|||
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
|
||||
};
|
||||
|
||||
render() {
|
||||
_renderHomeserverSection() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
return <div>
|
||||
{_t("Enter your custom homeserver URL <a>What does this mean?</a>", {}, {
|
||||
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
|
||||
{sub}
|
||||
</a>,
|
||||
})}
|
||||
<Field id="mx_ServerConfig_hsUrl"
|
||||
label={_t("Homeserver URL")}
|
||||
placeholder={this.props.serverConfig.hsUrl}
|
||||
value={this.state.hsUrl}
|
||||
onBlur={this.onHomeserverBlur}
|
||||
onChange={this.onHomeserverChange}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
_renderIdentityServerSection() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const classes = classNames({
|
||||
"mx_ServerConfig_identityServer": true,
|
||||
"mx_ServerConfig_identityServer_shown": this.state.showIdentityServer,
|
||||
});
|
||||
return <div className={classes}>
|
||||
{_t("Enter your custom identity server URL <a>What does this mean?</a>", {}, {
|
||||
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
|
||||
{sub}
|
||||
</a>,
|
||||
})}
|
||||
<Field id="mx_ServerConfig_isUrl"
|
||||
label={_t("Identity Server URL")}
|
||||
placeholder={this.props.serverConfig.isUrl}
|
||||
value={this.state.isUrl || ''}
|
||||
onBlur={this.onIdentityServerBlur}
|
||||
onChange={this.onIdentityServerChange}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
const errorText = this.state.errorText
|
||||
|
@ -191,31 +276,10 @@ export default class ServerConfig extends React.PureComponent {
|
|||
return (
|
||||
<div className="mx_ServerConfig">
|
||||
<h3>{_t("Other servers")}</h3>
|
||||
{_t("Enter custom server URLs <a>What does this mean?</a>", {}, {
|
||||
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
|
||||
{ sub }
|
||||
</a>,
|
||||
})}
|
||||
{errorText}
|
||||
{this._renderHomeserverSection()}
|
||||
{this._renderIdentityServerSection()}
|
||||
<form onSubmit={this.onSubmit} autoComplete={false} action={null}>
|
||||
<div className="mx_ServerConfig_fields">
|
||||
<Field id="mx_ServerConfig_hsUrl"
|
||||
label={_t("Homeserver URL")}
|
||||
placeholder={this.props.serverConfig.hsUrl}
|
||||
value={this.state.hsUrl}
|
||||
onBlur={this.onHomeserverBlur}
|
||||
onChange={this.onHomeserverChange}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
<Field id="mx_ServerConfig_isUrl"
|
||||
label={_t("Identity Server URL")}
|
||||
placeholder={this.props.serverConfig.isUrl}
|
||||
value={this.state.isUrl || ''}
|
||||
onBlur={this.onIdentityServerBlur}
|
||||
onChange={this.onIdentityServerChange}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
</div>
|
||||
{submitButton}
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -24,11 +24,14 @@ import createReactClass from 'create-react-class';
|
|||
import { _t, _td } from '../../../languageHandler';
|
||||
import sdk from '../../../index';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import dis from '../../../dispatcher';
|
||||
import Promise from 'bluebird';
|
||||
import { addressTypes, getAddressType } from '../../../UserAddress.js';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
import * as Email from '../../../email';
|
||||
import IdentityAuthClient from '../../../IdentityAuthClient';
|
||||
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils';
|
||||
import { abbreviateUrl } from '../../../utils/UrlUtils';
|
||||
|
||||
const TRUNCATE_QUERY_LIST = 40;
|
||||
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
|
||||
|
@ -49,7 +52,7 @@ module.exports = createReactClass({
|
|||
// Extra node inserted after picker input, dropdown and errors
|
||||
extraNode: PropTypes.node,
|
||||
value: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
placeholder: PropTypes.oneOfType(PropTypes.string, PropTypes.func),
|
||||
roomId: PropTypes.string,
|
||||
button: PropTypes.string,
|
||||
focus: PropTypes.bool,
|
||||
|
@ -91,6 +94,9 @@ module.exports = createReactClass({
|
|||
// List of UserAddressType objects representing the set of
|
||||
// auto-completion results for the current search query.
|
||||
suggestedList: [],
|
||||
// List of address types initialised from props, but may change while the
|
||||
// dialog is open.
|
||||
validAddressTypes: this.props.validAddressTypes,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -101,6 +107,15 @@ module.exports = createReactClass({
|
|||
}
|
||||
},
|
||||
|
||||
getPlaceholder() {
|
||||
const { placeholder } = this.props;
|
||||
if (typeof placeholder === "string") {
|
||||
return placeholder;
|
||||
}
|
||||
// Otherwise it's a function, as checked by prop types.
|
||||
return placeholder(this.state.validAddressTypes);
|
||||
},
|
||||
|
||||
onButtonClick: function() {
|
||||
let selectedList = this.state.selectedList.slice();
|
||||
// Check the text input field to see if user has an unconverted address
|
||||
|
@ -434,7 +449,7 @@ module.exports = createReactClass({
|
|||
// This is important, otherwise there's no way to invite
|
||||
// a perfectly valid address if there are close matches.
|
||||
const addrType = getAddressType(query);
|
||||
if (this.props.validAddressTypes.includes(addrType)) {
|
||||
if (this.state.validAddressTypes.includes(addrType)) {
|
||||
if (addrType === 'email' && !Email.looksValid(query)) {
|
||||
this.setState({searchError: _t("That doesn't look like a valid email address")});
|
||||
return;
|
||||
|
@ -470,7 +485,7 @@ module.exports = createReactClass({
|
|||
isKnown: false,
|
||||
};
|
||||
|
||||
if (!this.props.validAddressTypes.includes(addrType)) {
|
||||
if (!this.state.validAddressTypes.includes(addrType)) {
|
||||
hasError = true;
|
||||
} else if (addrType === 'mx-user-id') {
|
||||
const user = MatrixClientPeg.get().getUser(addrObj.address);
|
||||
|
@ -571,12 +586,37 @@ module.exports = createReactClass({
|
|||
this._addAddressesToList(text.split(/[\s,]+/));
|
||||
},
|
||||
|
||||
onUseDefaultIdentityServerClick(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Update the IS in account data. Actually using it may trigger terms.
|
||||
useDefaultIdentityServer();
|
||||
|
||||
// Add email as a valid address type.
|
||||
const { validAddressTypes } = this.state;
|
||||
validAddressTypes.push('email');
|
||||
this.setState({ validAddressTypes });
|
||||
},
|
||||
|
||||
onManageSettingsClick(e) {
|
||||
e.preventDefault();
|
||||
dis.dispatch({ action: 'view_user_settings' });
|
||||
this.onCancel();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const AddressSelector = sdk.getComponent("elements.AddressSelector");
|
||||
this.scrollElement = null;
|
||||
|
||||
let inputLabel;
|
||||
if (this.props.description) {
|
||||
inputLabel = <div className="mx_AddressPickerDialog_label">
|
||||
<label htmlFor="textinput">{this.props.description}</label>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const query = [];
|
||||
// create the invite list
|
||||
if (this.state.selectedList.length > 0) {
|
||||
|
@ -603,7 +643,7 @@ module.exports = createReactClass({
|
|||
ref="textinput"
|
||||
className="mx_AddressPickerDialog_input"
|
||||
onChange={this.onQueryChanged}
|
||||
placeholder={this.props.placeholder}
|
||||
placeholder={this.getPlaceholder()}
|
||||
defaultValue={this.props.value}
|
||||
autoFocus={this.props.focus}>
|
||||
</textarea>,
|
||||
|
@ -614,7 +654,7 @@ module.exports = createReactClass({
|
|||
let error;
|
||||
let addressSelector;
|
||||
if (this.state.invalidAddressError) {
|
||||
const validTypeDescriptions = this.props.validAddressTypes.map((t) => _t(addressTypeName[t]));
|
||||
const validTypeDescriptions = this.state.validAddressTypes.map((t) => _t(addressTypeName[t]));
|
||||
error = <div className="mx_AddressPickerDialog_error">
|
||||
{ _t("You have entered an invalid address.") }
|
||||
<br />
|
||||
|
@ -637,17 +677,43 @@ module.exports = createReactClass({
|
|||
);
|
||||
}
|
||||
|
||||
let identityServer;
|
||||
if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes('email')) {
|
||||
const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
|
||||
if (defaultIdentityServerUrl) {
|
||||
identityServer = <div className="mx_AddressPickerDialog_identityServer">{_t(
|
||||
"Use an identity server to invite by email. " +
|
||||
"<default>Use the default (%(defaultIdentityServerName)s)</default> " +
|
||||
"or manage in <settings>Settings</settings>.",
|
||||
{
|
||||
defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl),
|
||||
},
|
||||
{
|
||||
default: sub => <a href="#" onClick={this.onUseDefaultIdentityServerClick}>{sub}</a>,
|
||||
settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
|
||||
},
|
||||
)}</div>;
|
||||
} else {
|
||||
identityServer = <div className="mx_AddressPickerDialog_identityServer">{_t(
|
||||
"Use an identity server to invite by email. " +
|
||||
"Manage in <settings>Settings</settings>.",
|
||||
{}, {
|
||||
settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
|
||||
},
|
||||
)}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
|
||||
onFinished={this.props.onFinished} title={this.props.title}>
|
||||
<div className="mx_AddressPickerDialog_label">
|
||||
<label htmlFor="textinput">{ this.props.description }</label>
|
||||
</div>
|
||||
{inputLabel}
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div>
|
||||
{ error }
|
||||
{ addressSelector }
|
||||
{ this.props.extraNode }
|
||||
{ identityServer }
|
||||
</div>
|
||||
<DialogButtons primaryButton={this.props.button}
|
||||
onPrimaryButtonClick={this.onButtonClick}
|
||||
|
|
|
@ -154,10 +154,9 @@ export default class AppTile extends React.Component {
|
|||
// Widget action listeners
|
||||
dis.unregister(this.dispatcherRef);
|
||||
|
||||
const canPersist = this.props.whitelistCapabilities.includes('m.always_on_screen');
|
||||
// if it's not remaining on screen, get rid of the PersistedElement container
|
||||
if (canPersist && !ActiveWidgetStore.getWidgetPersistence(this.props.id)) {
|
||||
ActiveWidgetStore.destroyPersistentWidget();
|
||||
if (!ActiveWidgetStore.getWidgetPersistence(this.props.id)) {
|
||||
ActiveWidgetStore.destroyPersistentWidget(this.props.id);
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
}
|
||||
|
@ -451,7 +450,7 @@ export default class AppTile extends React.Component {
|
|||
this.setState({hasPermissionToLoad: false});
|
||||
|
||||
// Force the widget to be non-persistent
|
||||
ActiveWidgetStore.destroyPersistentWidget();
|
||||
ActiveWidgetStore.destroyPersistentWidget(this.props.id);
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
this.activateSpoilers(this.refs.content.children);
|
||||
|
||||
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
|
||||
// are still sent as plaintext URLs. If these are ever pillified in the composer,
|
||||
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
|
||||
|
@ -183,6 +185,34 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
activateSpoilers: function(nodes) {
|
||||
let node = nodes[0];
|
||||
while (node) {
|
||||
if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
|
||||
const spoilerContainer = document.createElement('span');
|
||||
|
||||
const reason = node.getAttribute("data-mx-spoiler");
|
||||
const Spoiler = sdk.getComponent('elements.Spoiler');
|
||||
node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
|
||||
const spoiler = <Spoiler
|
||||
reason={reason}
|
||||
contentHtml={node.outerHTML}
|
||||
/>;
|
||||
|
||||
ReactDOM.render(spoiler, spoilerContainer);
|
||||
node.parentNode.replaceChild(spoilerContainer, node);
|
||||
|
||||
node = spoilerContainer;
|
||||
}
|
||||
|
||||
if (node.childNodes && node.childNodes.length) {
|
||||
this.activateSpoilers(node.childNodes);
|
||||
}
|
||||
|
||||
node = node.nextSibling;
|
||||
}
|
||||
},
|
||||
|
||||
findLinks: function(nodes) {
|
||||
let links = [];
|
||||
|
||||
|
|
|
@ -14,6 +14,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import EditorModel from '../../../editor/model';
|
||||
|
@ -73,12 +75,13 @@ export default class BasicMessageEditor extends React.Component {
|
|||
this._editorRef = null;
|
||||
this._autocompleteRef = null;
|
||||
this._modifiedFlag = false;
|
||||
this._isIMEComposing = false;
|
||||
}
|
||||
|
||||
_replaceEmoticon = (caret, inputType, diff) => {
|
||||
_replaceEmoticon = (caretPosition, inputType, diff) => {
|
||||
const {model} = this.props;
|
||||
const range = model.startRange(caret);
|
||||
// expand range max 8 characters backwards from caret,
|
||||
const range = model.startRange(caretPosition);
|
||||
// expand range max 8 characters backwards from caretPosition,
|
||||
// as a space to look for an emoticon
|
||||
let n = 8;
|
||||
range.expandBackwardsWhile((index, offset) => {
|
||||
|
@ -91,6 +94,7 @@ export default class BasicMessageEditor extends React.Component {
|
|||
const query = emoticonMatch[1].toLowerCase().replace("-", "");
|
||||
const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false);
|
||||
if (data) {
|
||||
const {partCreator} = model;
|
||||
const hasPrecedingSpace = emoticonMatch[0][0] === " ";
|
||||
// we need the range to only comprise of the emoticon
|
||||
// because we'll replace the whole range with an emoji,
|
||||
|
@ -99,7 +103,7 @@ export default class BasicMessageEditor extends React.Component {
|
|||
range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0));
|
||||
// this returns the amount of added/removed characters during the replace
|
||||
// so the caret position can be adjusted.
|
||||
return range.replace([this.props.model.partCreator.plain(data.unicode + " ")]);
|
||||
return range.replace([partCreator.plain(data.unicode + " ")]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -116,11 +120,9 @@ export default class BasicMessageEditor extends React.Component {
|
|||
if (this.props.placeholder) {
|
||||
const {isEmpty} = this.props.model;
|
||||
if (isEmpty) {
|
||||
this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`);
|
||||
this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty");
|
||||
this._showPlaceholder();
|
||||
} else {
|
||||
this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty");
|
||||
this._editorRef.style.removeProperty("--placeholder");
|
||||
this._hidePlaceholder();
|
||||
}
|
||||
}
|
||||
this.setState({autoComplete: this.props.model.autoComplete});
|
||||
|
@ -132,7 +134,31 @@ export default class BasicMessageEditor extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_showPlaceholder() {
|
||||
this._editorRef.style.setProperty("--placeholder", `'${this.props.placeholder}'`);
|
||||
this._editorRef.classList.add("mx_BasicMessageComposer_inputEmpty");
|
||||
}
|
||||
|
||||
_hidePlaceholder() {
|
||||
this._editorRef.classList.remove("mx_BasicMessageComposer_inputEmpty");
|
||||
this._editorRef.style.removeProperty("--placeholder");
|
||||
}
|
||||
|
||||
_onCompositionStart = (event) => {
|
||||
this._isIMEComposing = true;
|
||||
// even if the model is empty, the composition text shouldn't be mixed with the placeholder
|
||||
this._hidePlaceholder();
|
||||
}
|
||||
|
||||
_onCompositionEnd = (event) => {
|
||||
this._isIMEComposing = false;
|
||||
}
|
||||
|
||||
_onInput = (event) => {
|
||||
// ignore any input while doing IME compositions
|
||||
if (this._isIMEComposing) {
|
||||
return;
|
||||
}
|
||||
this._modifiedFlag = true;
|
||||
const sel = document.getSelection();
|
||||
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
|
||||
|
@ -160,7 +186,7 @@ export default class BasicMessageEditor extends React.Component {
|
|||
}
|
||||
|
||||
_refreshLastCaretIfNeeded() {
|
||||
// TODO: needed when going up and down in editing messages ... not sure why yet
|
||||
// XXX: needed when going up and down in editing messages ... not sure why yet
|
||||
// because the editors should stop doing this when when blurred ...
|
||||
// maybe it's on focus and the _editorRef isn't available yet or something.
|
||||
if (!this._editorRef) {
|
||||
|
@ -242,14 +268,6 @@ export default class BasicMessageEditor extends React.Component {
|
|||
if (model.autoComplete) {
|
||||
const autoComplete = model.autoComplete;
|
||||
switch (event.key) {
|
||||
case "Enter":
|
||||
// only capture enter when something is selected in the list,
|
||||
// otherwise don't handle so the contents of the composer gets sent
|
||||
if (autoComplete.hasSelection()) {
|
||||
autoComplete.onEnter(event);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
autoComplete.onUpArrow(event);
|
||||
handled = true;
|
||||
|
@ -269,6 +287,9 @@ export default class BasicMessageEditor extends React.Component {
|
|||
default:
|
||||
return; // don't preventDefault on anything else
|
||||
}
|
||||
} else if (event.key === "Tab") {
|
||||
this._tabCompleteName();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
if (handled) {
|
||||
|
@ -277,6 +298,36 @@ export default class BasicMessageEditor extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
async _tabCompleteName() {
|
||||
try {
|
||||
await new Promise(resolve => this.setState({showVisualBell: false}, resolve));
|
||||
const {model} = this.props;
|
||||
const caret = this.getCaret();
|
||||
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
|
||||
const range = model.startRange(position);
|
||||
range.expandBackwardsWhile((index, offset, part) => {
|
||||
return part.text[offset] !== " " && (part.type === "plain" || part.type === "pill-candidate");
|
||||
});
|
||||
const {partCreator} = model;
|
||||
// await for auto-complete to be open
|
||||
await model.transform(() => {
|
||||
const addedLen = range.replace([partCreator.pillCandidate(range.text)]);
|
||||
return model.positionForOffset(caret.offset + addedLen, true);
|
||||
});
|
||||
await model.autoComplete.onTab();
|
||||
if (!model.autoComplete.hasSelection()) {
|
||||
this.setState({showVisualBell: true});
|
||||
model.autoComplete.close();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
getEditableRootNode() {
|
||||
return this._editorRef;
|
||||
}
|
||||
|
||||
isModified() {
|
||||
return this._modifiedFlag;
|
||||
}
|
||||
|
@ -291,6 +342,8 @@ export default class BasicMessageEditor extends React.Component {
|
|||
|
||||
componentWillUnmount() {
|
||||
this._editorRef.removeEventListener("input", this._onInput, true);
|
||||
this._editorRef.removeEventListener("compositionstart", this._onCompositionStart, true);
|
||||
this._editorRef.removeEventListener("compositionend", this._onCompositionEnd, true);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -304,7 +357,7 @@ export default class BasicMessageEditor extends React.Component {
|
|||
// not really, but we could not serialize the parts, and just change the autoCompleter
|
||||
partCreator.setAutoCompleteCreator(autoCompleteCreator(
|
||||
() => this._autocompleteRef,
|
||||
query => this.setState({query}),
|
||||
query => new Promise(resolve => this.setState({query}, resolve)),
|
||||
));
|
||||
this.historyManager = new HistoryManager(partCreator);
|
||||
// initial render of model
|
||||
|
@ -312,6 +365,8 @@ export default class BasicMessageEditor extends React.Component {
|
|||
// attach input listener by hand so React doesn't proxy the events,
|
||||
// as the proxied event doesn't support inputType, which we need.
|
||||
this._editorRef.addEventListener("input", this._onInput, true);
|
||||
this._editorRef.addEventListener("compositionstart", this._onCompositionStart, true);
|
||||
this._editorRef.addEventListener("compositionend", this._onCompositionEnd, true);
|
||||
this._editorRef.focus();
|
||||
}
|
||||
|
||||
|
@ -345,7 +400,10 @@ export default class BasicMessageEditor extends React.Component {
|
|||
/>
|
||||
</div>);
|
||||
}
|
||||
return (<div className="mx_BasicMessageComposer">
|
||||
const classes = classNames("mx_BasicMessageComposer", {
|
||||
"mx_BasicMessageComposer_input_error": this.state.showVisualBell,
|
||||
});
|
||||
return (<div className={classes}>
|
||||
{ autoComplete }
|
||||
<div
|
||||
className="mx_BasicMessageComposer_input"
|
||||
|
|
|
@ -32,6 +32,7 @@ import {processCommandInput} from '../../../SlashCommands';
|
|||
import sdk from '../../../index';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import ContentMessages from '../../../ContentMessages';
|
||||
|
||||
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
|
||||
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
|
||||
|
@ -226,8 +227,13 @@ export default class SendMessageComposer extends React.Component {
|
|||
this._clearStoredEditorState();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._editorRef.getEditableRootNode().addEventListener("paste", this._onPaste, true);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this._editorRef.getEditableRootNode().removeEventListener("paste", this._onPaste, true);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
|
@ -279,26 +285,50 @@ export default class SendMessageComposer extends React.Component {
|
|||
};
|
||||
|
||||
_insertMention(userId) {
|
||||
const {model} = this;
|
||||
const {partCreator} = model;
|
||||
const member = this.props.room.getMember(userId);
|
||||
const displayName = member ?
|
||||
member.rawDisplayName : userId;
|
||||
const userPillPart = this.model.partCreator.userPill(displayName, userId);
|
||||
this.model.insertPartsAt([userPillPart], this._editorRef.getCaret());
|
||||
const userPillPart = partCreator.userPill(displayName, userId);
|
||||
const caret = this._editorRef.getCaret();
|
||||
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
|
||||
model.transform(() => {
|
||||
const addedLen = model.insert([userPillPart], position);
|
||||
return model.positionForOffset(caret.offset + addedLen, true);
|
||||
});
|
||||
// refocus on composer, as we just clicked "Mention"
|
||||
this._editorRef && this._editorRef.focus();
|
||||
}
|
||||
|
||||
_insertQuotedMessage(event) {
|
||||
const {partCreator} = this.model;
|
||||
const {model} = this;
|
||||
const {partCreator} = model;
|
||||
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
|
||||
// add two newlines
|
||||
quoteParts.push(partCreator.newline());
|
||||
quoteParts.push(partCreator.newline());
|
||||
this.model.insertPartsAt(quoteParts, {offset: 0});
|
||||
model.transform(() => {
|
||||
const addedLen = model.insert(quoteParts, model.positionForOffset(0));
|
||||
return model.positionForOffset(addedLen, true);
|
||||
});
|
||||
// refocus on composer, as we just clicked "Quote"
|
||||
this._editorRef && this._editorRef.focus();
|
||||
}
|
||||
|
||||
_onPaste = (event) => {
|
||||
const {clipboardData} = event;
|
||||
if (clipboardData.files.length) {
|
||||
// This actually not so much for 'files' as such (at time of writing
|
||||
// neither chrome nor firefox let you paste a plain file copied
|
||||
// from Finder) but more images copied from a different website
|
||||
// / word processor etc.
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(clipboardData.files), this.props.room.roomId, this.context.matrixClient,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}>
|
||||
|
|
|
@ -20,13 +20,13 @@ import PropTypes from 'prop-types';
|
|||
import {_t} from "../../../languageHandler";
|
||||
import sdk from '../../../index';
|
||||
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import Modal from '../../../Modal';
|
||||
import dis from "../../../dispatcher";
|
||||
import { getThreepidBindStatus } from '../../../boundThreepids';
|
||||
import IdentityAuthClient from "../../../IdentityAuthClient";
|
||||
import {SERVICE_TYPES} from "matrix-js-sdk";
|
||||
import {abbreviateUrl, unabbreviateUrl} from "../../../utils/UrlUtils";
|
||||
import { getDefaultIdentityServerUrl } from '../../../utils/IdentityServerUtils';
|
||||
|
||||
/**
|
||||
* Check an IS URL is valid, including liveness check
|
||||
|
@ -66,10 +66,10 @@ export default class SetIdServer extends React.Component {
|
|||
super();
|
||||
|
||||
let defaultIdServer = '';
|
||||
if (!MatrixClientPeg.get().getIdentityServerUrl() && SdkConfig.get()['validated_server_config']['isUrl']) {
|
||||
if (!MatrixClientPeg.get().getIdentityServerUrl() && getDefaultIdentityServerUrl()) {
|
||||
// If no ID server is configured but there's one in the config, prepopulate
|
||||
// the field to help the user.
|
||||
defaultIdServer = abbreviateUrl(SdkConfig.get()['validated_server_config']['isUrl']);
|
||||
defaultIdServer = abbreviateUrl(getDefaultIdentityServerUrl());
|
||||
}
|
||||
|
||||
this.state = {
|
||||
|
@ -253,10 +253,10 @@ export default class SetIdServer extends React.Component {
|
|||
});
|
||||
|
||||
let newFieldVal = '';
|
||||
if (SdkConfig.get()['validated_server_config']['isUrl']) {
|
||||
if (getDefaultIdentityServerUrl()) {
|
||||
// Prepopulate the client's default so the user at least has some idea of
|
||||
// a valid value they might enter
|
||||
newFieldVal = abbreviateUrl(SdkConfig.get()['validated_server_config']['isUrl']);
|
||||
newFieldVal = abbreviateUrl(getDefaultIdentityServerUrl());
|
||||
}
|
||||
|
||||
this.setState({
|
||||
|
|
|
@ -30,6 +30,7 @@ const plEventsToLabels = {
|
|||
"m.room.history_visibility": _td("Change history visibility"),
|
||||
"m.room.power_levels": _td("Change permissions"),
|
||||
"m.room.topic": _td("Change topic"),
|
||||
"m.room.tombstone": _td("Upgrade the room"),
|
||||
"m.room.encryption": _td("Enable room encryption"),
|
||||
|
||||
"im.vector.modular.widgets": _td("Modify widgets"),
|
||||
|
@ -43,6 +44,7 @@ const plEventsToShow = {
|
|||
"m.room.history_visibility": {isState: true},
|
||||
"m.room.power_levels": {isState: true},
|
||||
"m.room.topic": {isState: true},
|
||||
"m.room.tombstone": {isState: true},
|
||||
"m.room.encryption": {isState: true},
|
||||
|
||||
"im.vector.modular.widgets": {isState: true},
|
||||
|
|
|
@ -33,6 +33,10 @@ export default class AutocompleteWrapperModel {
|
|||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this._updateCallback({close: true});
|
||||
}
|
||||
|
||||
hasSelection() {
|
||||
return this._getAutocompleterComponent().hasSelection();
|
||||
}
|
||||
|
@ -52,9 +56,6 @@ export default class AutocompleteWrapperModel {
|
|||
} else {
|
||||
await acComponent.moveSelection(e.shiftKey ? -1 : +1);
|
||||
}
|
||||
this._updateCallback({
|
||||
close: true,
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
this._queryPart = part;
|
||||
this._queryOffset = offset;
|
||||
this._updateQuery(part.text);
|
||||
return this._updateQuery(part.text);
|
||||
}
|
||||
|
||||
onComponentSelectionChange(completion) {
|
||||
|
|
|
@ -84,6 +84,14 @@ function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) {
|
|||
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);
|
||||
if (nodeText) {
|
||||
if (!foundCaret) {
|
||||
|
|
|
@ -35,6 +35,11 @@ import Range from "./range";
|
|||
* This is used to adjust the caret position.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback ManualTransformCallback
|
||||
* @return the caret position
|
||||
*/
|
||||
|
||||
export default class EditorModel {
|
||||
constructor(parts, partCreator, updateCallback = null) {
|
||||
this._parts = parts;
|
||||
|
@ -44,7 +49,6 @@ export default class EditorModel {
|
|||
this._autoCompletePartIdx = null;
|
||||
this._transformCallback = null;
|
||||
this.setUpdateCallback(updateCallback);
|
||||
this._updateInProgress = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -90,10 +94,14 @@ export default class EditorModel {
|
|||
|
||||
_removePart(index) {
|
||||
this._parts.splice(index, 1);
|
||||
if (this._activePartIdx >= index) {
|
||||
if (index === this._activePartIdx) {
|
||||
this._activePartIdx = null;
|
||||
} else if (this._activePartIdx > index) {
|
||||
--this._activePartIdx;
|
||||
}
|
||||
if (this._autoCompletePartIdx >= index) {
|
||||
if (index === this._autoCompletePartIdx) {
|
||||
this._autoCompletePartIdx = null;
|
||||
} else if (this._autoCompletePartIdx > index) {
|
||||
--this._autoCompletePartIdx;
|
||||
}
|
||||
}
|
||||
|
@ -150,8 +158,14 @@ export default class EditorModel {
|
|||
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);
|
||||
let newTextLength = 0;
|
||||
for (let i = 0; i < parts.length; ++i) {
|
||||
|
@ -159,36 +173,31 @@ export default class EditorModel {
|
|||
newTextLength += part.text.length;
|
||||
this._insertPart(insertIndex + i, part);
|
||||
}
|
||||
// put caret after new part
|
||||
const lastPartIndex = insertIndex + parts.length - 1;
|
||||
const newPosition = new DocumentPosition(lastPartIndex, newTextLength);
|
||||
this._updateCallback(newPosition);
|
||||
return newTextLength;
|
||||
}
|
||||
|
||||
update(newValue, inputType, caret) {
|
||||
this._updateInProgress = true;
|
||||
const diff = this._diff(newValue, inputType, caret);
|
||||
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
|
||||
let removedOffsetDecrease = 0;
|
||||
if (diff.removed) {
|
||||
removedOffsetDecrease = this.removeText(position, diff.removed.length);
|
||||
}
|
||||
const canOpenAutoComplete = inputType !== "insertFromPaste" && inputType !== "insertFromDrop";
|
||||
let addedLen = 0;
|
||||
if (diff.added) {
|
||||
// these shouldn't trigger auto-complete, you just want to append a piece of text
|
||||
addedLen = this._addText(position, diff.added, {validate: canOpenAutoComplete});
|
||||
addedLen = this._addText(position, diff.added, inputType);
|
||||
}
|
||||
this._mergeAdjacentParts();
|
||||
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
|
||||
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) {
|
||||
const transformAddedLen = this._transform(newPosition, inputType, diff);
|
||||
newPosition = this.positionForOffset(caretOffset + transformAddedLen, true);
|
||||
}
|
||||
this._updateInProgress = false;
|
||||
this._updateCallback(newPosition, inputType, diff);
|
||||
return acPromise;
|
||||
}
|
||||
|
||||
_transform(newPosition, inputType, diff) {
|
||||
|
@ -214,13 +223,14 @@ export default class EditorModel {
|
|||
}
|
||||
// not _autoComplete, only there if active part is autocomplete part
|
||||
if (this.autoComplete) {
|
||||
this.autoComplete.onPartUpdate(part, pos.offset);
|
||||
return this.autoComplete.onPartUpdate(part, pos.offset);
|
||||
}
|
||||
} else {
|
||||
this._activePartIdx = null;
|
||||
this._autoComplete = null;
|
||||
this._autoCompletePartIdx = null;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
_onAutoComplete = ({replacePart, caretOffset, close}) => {
|
||||
|
@ -322,22 +332,20 @@ export default class EditorModel {
|
|||
* inserts `str` into the model at `pos`.
|
||||
* @param {Object} pos
|
||||
* @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.
|
||||
* Validating allows the inserted text to be parsed according to the part rules.
|
||||
* @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.
|
||||
*/
|
||||
_addText(pos, str, {validate=true}) {
|
||||
_addText(pos, str, inputType) {
|
||||
let {index} = pos;
|
||||
const {offset} = pos;
|
||||
let addLen = str.length;
|
||||
const part = this._parts[index];
|
||||
if (part) {
|
||||
if (part.canEdit) {
|
||||
if (validate && part.validateAndInsert(offset, str)) {
|
||||
str = null;
|
||||
} else if (!validate && part.insert(offset, str)) {
|
||||
if (part.validateAndInsert(offset, str, inputType)) {
|
||||
str = null;
|
||||
} else {
|
||||
const splitPart = part.split(offset);
|
||||
|
@ -356,13 +364,8 @@ export default class EditorModel {
|
|||
index = 0;
|
||||
}
|
||||
while (str) {
|
||||
const newPart = this._partCreator.createPartForInput(str, index);
|
||||
if (validate) {
|
||||
str = newPart.appendUntilRejected(str);
|
||||
} else {
|
||||
newPart.insert(0, str);
|
||||
str = null;
|
||||
}
|
||||
const newPart = this._partCreator.createPartForInput(str, index, inputType);
|
||||
str = newPart.appendUntilRejected(str, inputType);
|
||||
this._insertPart(index, newPart);
|
||||
index += 1;
|
||||
}
|
||||
|
@ -395,18 +398,15 @@ export default class EditorModel {
|
|||
return new Range(this, position);
|
||||
}
|
||||
|
||||
// called from Range.replace
|
||||
//mostly internal, called from Range.replace
|
||||
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 idxDiff = newStartPartIndex - startPosition.index;
|
||||
// if both position are in the same part, and we split it at start position,
|
||||
// the offset of the end position needs to be decreased by the offset of the start position
|
||||
const removedOffset = startPosition.index === endPosition.index ? startPosition.offset : 0;
|
||||
const adjustedEndPosition = new DocumentPosition(
|
||||
endPosition.index + idxDiff,
|
||||
endPosition.offset - removedOffset,
|
||||
);
|
||||
const newEndPartIndex = this._splitAt(adjustedEndPosition);
|
||||
// convert it back to position once split at start
|
||||
endPosition = endOffset.asPosition(this);
|
||||
const newEndPartIndex = this._splitAt(endPosition);
|
||||
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
|
||||
this._removePart(i);
|
||||
}
|
||||
|
@ -416,8 +416,18 @@ export default class EditorModel {
|
|||
insertIdx += 1;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
acceptsInsertion(chr) {
|
||||
acceptsInsertion(chr, offset, inputType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -56,10 +56,11 @@ class BasePart {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
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);
|
||||
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
|
||||
// return whether the str was accepted or not.
|
||||
validateAndInsert(offset, str) {
|
||||
validateAndInsert(offset, str, inputType) {
|
||||
for (let i = 0; i < str.length; ++i) {
|
||||
const chr = str.charAt(i);
|
||||
if (!this.acceptsInsertion(chr)) {
|
||||
if (!this.acceptsInsertion(chr, offset + i, inputType)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -82,16 +83,6 @@ class BasePart {
|
|||
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() {}
|
||||
|
||||
trim(len) {
|
||||
|
@ -119,8 +110,15 @@ class BasePart {
|
|||
|
||||
// exported for unit tests, should otherwise only be used through PartCreator
|
||||
export class PlainPart extends BasePart {
|
||||
acceptsInsertion(chr) {
|
||||
return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n";
|
||||
acceptsInsertion(chr, offset, inputType) {
|
||||
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() {
|
||||
|
@ -141,7 +139,6 @@ export class PlainPart extends BasePart {
|
|||
|
||||
updateDOMNode(node) {
|
||||
if (node.textContent !== this.text) {
|
||||
// console.log("changing plain text from", node.textContent, "to", this.text);
|
||||
node.textContent = this.text;
|
||||
}
|
||||
}
|
||||
|
@ -211,8 +208,8 @@ class PillPart extends BasePart {
|
|||
}
|
||||
|
||||
class NewlinePart extends BasePart {
|
||||
acceptsInsertion(chr, i) {
|
||||
return (this.text.length + i) === 0 && chr === "\n";
|
||||
acceptsInsertion(chr, offset) {
|
||||
return offset === 0 && chr === "\n";
|
||||
}
|
||||
|
||||
acceptsRemoval(position, chr) {
|
||||
|
@ -284,6 +281,9 @@ class UserPillPart extends PillPart {
|
|||
}
|
||||
|
||||
setAvatar(node) {
|
||||
if (!this._member) {
|
||||
return;
|
||||
}
|
||||
const name = this._member.name || this._member.userId;
|
||||
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId);
|
||||
let avatarUrl = Avatar.avatarUrlForMember(
|
||||
|
@ -328,11 +328,11 @@ class PillCandidatePart extends PlainPart {
|
|||
return this._autoCompleteCreator.create(updateCallback);
|
||||
}
|
||||
|
||||
acceptsInsertion(chr, i) {
|
||||
if ((this.text.length + i) === 0) {
|
||||
acceptsInsertion(chr, offset, inputType) {
|
||||
if (offset === 0) {
|
||||
return true;
|
||||
} else {
|
||||
return super.acceptsInsertion(chr, i);
|
||||
return super.acceptsInsertion(chr, offset, inputType);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -366,6 +366,8 @@ export class PartCreator {
|
|||
constructor(room, client, autoCompleteCreator = null) {
|
||||
this._room = room;
|
||||
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)};
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import DocumentOffset from "./offset";
|
||||
|
||||
export default class DocumentPosition {
|
||||
constructor(index, offset) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
|
||||
let oldLength = 0;
|
||||
|
|
|
@ -117,7 +117,6 @@
|
|||
"Email, name or Matrix ID": "Email, name or Matrix ID",
|
||||
"Start Chat": "Start Chat",
|
||||
"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",
|
||||
"Failed to start chat": "Failed to start chat",
|
||||
"Operation failed": "Operation failed",
|
||||
|
@ -146,6 +145,7 @@
|
|||
"/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.",
|
||||
"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",
|
||||
"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.",
|
||||
|
@ -680,7 +680,7 @@
|
|||
"Change history visibility": "Change history visibility",
|
||||
"Change permissions": "Change permissions",
|
||||
"Change topic": "Change topic",
|
||||
"Enable room encryption": "Enable room encryption",
|
||||
"Upgrade the room": "Upgrade the room",
|
||||
"Modify widgets": "Modify widgets",
|
||||
"Failed to unban": "Failed to 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",
|
||||
"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.",
|
||||
"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",
|
||||
"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",
|
||||
|
@ -1462,13 +1464,14 @@
|
|||
"Phone (optional)": "Phone (optional)",
|
||||
"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 />",
|
||||
"Use an email address to recover your account.": "Use an email address to recover your account.",
|
||||
"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 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.",
|
||||
"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.",
|
||||
"Other servers": "Other servers",
|
||||
"Enter custom server URLs <a>What does this mean?</a>": "Enter custom server URLs <a>What does this mean?</a>",
|
||||
"Enter your custom homeserver URL <a>What does this mean?</a>": "Enter your custom homeserver URL <a>What does this mean?</a>",
|
||||
"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",
|
||||
"Other servers": "Other servers",
|
||||
"Free": "Free",
|
||||
"Join millions for free on the largest public server": "Join millions for free on the largest public server",
|
||||
"Premium": "Premium",
|
||||
|
|
|
@ -67,11 +67,12 @@ class ActiveWidgetStore extends EventEmitter {
|
|||
if (ev.getType() !== 'im.vector.modular.widgets') return;
|
||||
|
||||
if (ev.getStateKey() === this._persistentWidgetId) {
|
||||
this.destroyPersistentWidget();
|
||||
this.destroyPersistentWidget(this._persistentWidgetId);
|
||||
}
|
||||
}
|
||||
|
||||
destroyPersistentWidget() {
|
||||
destroyPersistentWidget(id) {
|
||||
if (id !== this._persistentWidgetId) return;
|
||||
const toDeleteId = this._persistentWidgetId;
|
||||
|
||||
this.setWidgetPersistence(toDeleteId, false);
|
||||
|
|
|
@ -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] !== " ");
|
||||
expect(range.text).toBe("world");
|
||||
range.replace([pc.roomPill(pillChannel)]);
|
||||
console.log({parts: JSON.stringify(model.serializeParts())});
|
||||
expect(model.parts[0].type).toBe("plain");
|
||||
expect(model.parts[0].text).toBe("hello ");
|
||||
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].text).toBe("!!!!");
|
||||
expect(model.parts.length).toBe(3);
|
||||
expect(renderer.count).toBe(1);
|
||||
});
|
||||
it('range replace across parts', function() {
|
||||
const renderer = createRenderer();
|
||||
|
@ -74,7 +72,6 @@ describe('editor/range', function() {
|
|||
const range = model.startRange(model.positionForOffset(14)); // after "replace"
|
||||
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
|
||||
expect(range.text).toBe("replace");
|
||||
console.log("range.text", {text: range.text});
|
||||
range.replace([pc.roomPill(pillChannel)]);
|
||||
expect(model.parts[0].type).toBe("plain");
|
||||
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].text).toBe(" me");
|
||||
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"
|
||||
|
||||
eslint-utils@^1.3.1:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.0.tgz#e2c3c8dba768425f897cf0f9e51fe2e241485d4c"
|
||||
integrity sha512-7ehnzPaP5IIEh1r1tkjuIrxqhNkzUJa9z3R92tLJdZIVdWaczEhr3EbhGtsMrVxi1KeR8qA7Off6SWc5WNQqyQ==
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.2.tgz#166a5180ef6ab7eb462f162fd0e6f2463d7309ab"
|
||||
integrity sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q==
|
||||
dependencies:
|
||||
eslint-visitor-keys "^1.0.0"
|
||||
|
||||
eslint-visitor-keys@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d"
|
||||
integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
|
||||
integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
|
||||
|
||||
eslint@^5.12.0:
|
||||
version "5.16.0"
|
||||
|
|
Loading…
Reference in New Issue