Merge remote-tracking branch 'origin/develop' into release-v1.2.2

This commit is contained in:
David Baker 2019-06-18 15:25:44 +01:00
commit ab4142ab5a
58 changed files with 906 additions and 558 deletions

View file

@ -47,7 +47,6 @@ src/components/views/rooms/UserTile.js
src/components/views/settings/ChangeAvatar.js src/components/views/settings/ChangeAvatar.js
src/components/views/settings/ChangePassword.js src/components/views/settings/ChangePassword.js
src/components/views/settings/DevicesPanel.js src/components/views/settings/DevicesPanel.js
src/components/views/settings/IntegrationsManager.js
src/components/views/settings/Notifications.js src/components/views/settings/Notifications.js
src/GroupAddressPicker.js src/GroupAddressPicker.js
src/HtmlUtils.js src/HtmlUtils.js

View file

@ -50,7 +50,6 @@
@import "./views/context_menus/_TopLeftMenu.scss"; @import "./views/context_menus/_TopLeftMenu.scss";
@import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss";
@import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_Analytics.scss";
@import "./views/dialogs/_BugReportDialog.scss";
@import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss";
@import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss";
@import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss";

View file

@ -30,6 +30,7 @@ limitations under the License.
.mx_Login_submit:disabled { .mx_Login_submit:disabled {
opacity: 0.3; opacity: 0.3;
cursor: default;
} }
.mx_AuthBody a.mx_Login_sso_link:link, .mx_AuthBody a.mx_Login_sso_link:link,

View file

@ -72,7 +72,6 @@ limitations under the License.
} }
.mx_Field input { .mx_Field input {
width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@ -110,7 +109,6 @@ limitations under the License.
.mx_AuthBody_fieldRow > .mx_Field { .mx_AuthBody_fieldRow > .mx_Field {
margin: 0 5px; margin: 0 5px;
flex: 1;
} }
.mx_AuthBody_fieldRow > .mx_Field:first-child { .mx_AuthBody_fieldRow > .mx_Field:first-child {

View file

@ -20,7 +20,6 @@ limitations under the License.
} }
.mx_ServerConfig_fields .mx_Field { .mx_ServerConfig_fields .mx_Field {
flex: 1;
margin: 0 5px; margin: 0 5px;
} }

View file

@ -1,25 +0,0 @@
/*
Copyright 2017 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_BugReportDialog .mx_Field {
flex: 1;
}
.mx_BugReportDialog_field_input {
// TODO: We should really apply this to all .mx_Field inputs.
// See https://github.com/vector-im/riot-web/issues/9344.
flex: 1;
}

View file

@ -23,7 +23,11 @@ limitations under the License.
cursor: default !important; cursor: default !important;
} }
.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button, .mx_DevTools_RoomStateExplorer_query { .mx_DevTools_RoomStateExplorer_query {
margin-bottom: 10px;
}
.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button {
margin-bottom: 10px; margin-bottom: 10px;
width: 100%; width: 100%;
} }
@ -75,7 +79,6 @@ limitations under the License.
max-width: 684px; max-width: 684px;
min-height: 250px; min-height: 250px;
padding: 10px; padding: 10px;
width: 100%;
} }
.mx_DevTools_content .mx_Field_input { .mx_DevTools_content .mx_Field_input {

View file

@ -21,7 +21,6 @@ limitations under the License.
color: $primary-fg-color; color: $primary-fg-color;
background-color: $primary-bg-color; background-color: $primary-bg-color;
font-size: 15px; font-size: 15px;
width: 100%;
max-width: 280px; max-width: 280px;
margin-bottom: 10px; margin-bottom: 10px;
} }

View file

@ -42,12 +42,6 @@ limitations under the License.
margin-right: 5px; margin-right: 5px;
} }
.mx_EditableItemList_newItem .mx_Field input {
// Use 100% of the space available for the input, but don't let the 10px
// padding on either side of the input to push it out of alignment.
width: calc(100% - 20px);
}
.mx_EditableItemList_label { .mx_EditableItemList_label {
margin-bottom: 5px; margin-bottom: 5px;
} }

View file

@ -18,6 +18,8 @@ limitations under the License.
.mx_Field { .mx_Field {
display: flex; display: flex;
flex: 1;
min-width: 0;
position: relative; position: relative;
margin: 1em 0; margin: 1em 0;
border-radius: 4px; border-radius: 4px;
@ -42,6 +44,7 @@ limitations under the License.
padding: 8px 9px; padding: 8px 9px;
color: $primary-fg-color; color: $primary-fg-color;
background-color: $primary-bg-color; background-color: $primary-bg-color;
flex: 1;
} }
.mx_Field select { .mx_Field select {

View file

@ -20,6 +20,5 @@ limitations under the License.
.mx_PowerSelector .mx_Field select, .mx_PowerSelector .mx_Field select,
.mx_PowerSelector .mx_Field input { .mx_PowerSelector .mx_Field input {
width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }

View file

@ -26,6 +26,8 @@ limitations under the License.
top: -18px; top: -18px;
right: 8px; right: 8px;
user-select: none; user-select: none;
// Ensure the action bar appears above over things, like the read marker.
z-index: 1;
> * { > * {
display: inline-block; display: inline-block;

View file

@ -169,6 +169,9 @@ limitations under the License.
.mx_EventTile_sending .mx_RoomPill { .mx_EventTile_sending .mx_RoomPill {
opacity: 0.5; opacity: 0.5;
} }
.mx_EventTile_sending.mx_EventTile_redacted .mx_UnknownBody {
opacity: 0.4;
}
.mx_EventTile_notSent { .mx_EventTile_notSent {
color: $event-notsent-color; color: $event-notsent-color;

View file

@ -43,6 +43,8 @@ limitations under the License.
.mx_MemberInfo_name h2 { .mx_MemberInfo_name h2 {
flex: 1; flex: 1;
overflow-x: auto;
max-height: 50px;
} }
.mx_MemberInfo h2 { .mx_MemberInfo h2 {

View file

@ -35,9 +35,3 @@ limitations under the License.
.mx_ExistingEmailAddress_confirmBtn { .mx_ExistingEmailAddress_confirmBtn {
margin-right: 5px; margin-right: 5px;
} }
.mx_EmailAddresses_new .mx_Field input {
// Use 100% of the space available for the input, but don't let the 10px
// padding on either side of the input to push it out of alignment.
width: calc(100% - 20px);
}

View file

@ -29,3 +29,16 @@ limitations under the License.
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.mx_IntegrationsManager_loading h3 {
text-align: center;
}
.mx_IntegrationsManager_error {
text-align: center;
padding-top: 20px;
}
.mx_IntegrationsManager_error h3 {
color: $warning-color;
}

View file

@ -36,12 +36,6 @@ limitations under the License.
margin-right: 5px; margin-right: 5px;
} }
.mx_PhoneNumbers_new .mx_Field input {
// Use 100% of the space available for the input, but don't let the 10px
// padding on either side of the input to push it out of alignment.
width: calc(100% - 20px);
}
.mx_PhoneNumbers_input { .mx_PhoneNumbers_input {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -22,11 +22,6 @@ limitations under the License.
flex-grow: 1; flex-grow: 1;
} }
.mx_ProfileSettings_controls .mx_Field #profileDisplayName,
.mx_ProfileSettings_controls .mx_Field #profileTopic {
width: calc(100% - 20px); // subtract 10px padding on left and right
}
.mx_ProfileSettings_controls .mx_Field #profileTopic { .mx_ProfileSettings_controls .mx_Field #profileTopic {
height: 4em; height: 4em;
} }

View file

@ -17,7 +17,3 @@ limitations under the License.
.mx_GeneralRoomSettingsTab_profileSection { .mx_GeneralRoomSettingsTab_profileSection {
margin-top: 10px; margin-top: 10px;
} }
.mx_GeneralRoomSettingsTab .mx_AliasSettings .mx_Field select {
width: 100%;
}

View file

@ -14,33 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_GeneralUserSettingsTab_changePassword,
.mx_GeneralUserSettingsTab_themeSection {
display: block;
}
.mx_GeneralUserSettingsTab_changePassword .mx_Field, .mx_GeneralUserSettingsTab_changePassword .mx_Field,
.mx_GeneralUserSettingsTab_themeSection .mx_Field { .mx_GeneralUserSettingsTab_themeSection .mx_Field {
display: block;
margin-right: 100px; // Align with the other fields on the page margin-right: 100px; // Align with the other fields on the page
} }
.mx_GeneralUserSettingsTab_changePassword .mx_Field input {
display: block;
width: calc(100% - 20px); // subtract 10px padding on left and right
}
.mx_GeneralUserSettingsTab_changePassword .mx_Field:first-child { .mx_GeneralUserSettingsTab_changePassword .mx_Field:first-child {
margin-top: 0; margin-top: 0;
} }
.mx_GeneralUserSettingsTab_themeSection .mx_Field select {
display: block;
width: 100%;
}
.mx_GeneralUserSettingsTab_accountSection > .mx_EmailAddresses, .mx_GeneralUserSettingsTab_accountSection > .mx_EmailAddresses,
.mx_GeneralUserSettingsTab_accountSection > .mx_PhoneNumbers, .mx_GeneralUserSettingsTab_accountSection > .mx_PhoneNumbers,
.mx_GeneralUserSettingsTab_languageInput { .mx_GeneralUserSettingsTab_languageInput {
margin-right: 100px; // Align with the other fields on the page margin-right: 100px; // Align with the other fields on the page
} }

View file

@ -17,11 +17,3 @@ limitations under the License.
.mx_PreferencesUserSettingsTab .mx_Field { .mx_PreferencesUserSettingsTab .mx_Field {
margin-right: 100px; // Align with the rest of the controls margin-right: 100px; // Align with the rest of the controls
} }
.mx_PreferencesUserSettingsTab .mx_Field input {
display: block;
// Subtract 10px padding on left and right
// This is to keep the input aligned with the rest of the tab's controls.
width: calc(100% - 20px);
}

View file

@ -14,11 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_VoiceUserSettingsTab .mx_Field select {
width: 100%;
max-width: 100%;
}
.mx_VoiceUserSettingsTab .mx_Field { .mx_VoiceUserSettingsTab .mx_Field {
margin-right: 100px; // align with the rest of the fields margin-right: 100px; // align with the rest of the fields
} }

View file

@ -344,7 +344,7 @@ function _onAction(payload) {
} }
async function _startCallApp(roomId, type) { async function _startCallApp(roomId, type) {
// check for a working intgrations manager. Technically we could put // check for a working integrations manager. Technically we could put
// the state event in anyway, but the resulting widget would then not // the state event in anyway, but the resulting widget would then not
// work for us. Better that the user knows before everyone else in the // work for us. Better that the user knows before everyone else in the
// room sees it. // room sees it.

View file

@ -0,0 +1,86 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
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 {Value} from 'slate';
import _clamp from 'lodash/clamp';
type MessageFormat = 'rich' | 'markdown';
class HistoryItem {
// We store history items in their native format to ensure history is accurate
// and then convert them if our RTE has subsequently changed format.
value: Value;
format: MessageFormat = 'rich';
constructor(value: ?Value, format: ?MessageFormat) {
this.value = value;
this.format = format;
}
static fromJSON(obj: Object): HistoryItem {
return new HistoryItem(
Value.fromJSON(obj.value),
obj.format,
);
}
toJSON(): Object {
return {
value: this.value.toJSON(),
format: this.format,
};
}
}
export default class ComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;
// TODO: Performance issues?
let item;
for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
try {
this.history.push(
HistoryItem.fromJSON(JSON.parse(item)),
);
} catch (e) {
console.warn("Throwing away unserialisable history", e);
}
}
this.lastIndex = this.currentIndex;
// reset currentIndex to account for any unserialisable history
this.currentIndex = this.history.length;
}
save(value: Value, format: MessageFormat) {
const item = new HistoryItem(value, format);
this.history.push(item);
this.currentIndex = this.history.length;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
}
getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}
}

View file

@ -425,19 +425,25 @@ export default class ContentMessages {
} }
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
let uploadAll = false;
for (let i = 0; i < okFiles.length; ++i) { for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i]; const file = okFiles[i];
const shouldContinue = await new Promise((resolve) => { if (!uploadAll) {
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { const shouldContinue = await new Promise((resolve) => {
file, Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
currentIndex: i, file,
totalFiles: okFiles.length, currentIndex: i,
onFinished: (shouldContinue) => { totalFiles: okFiles.length,
resolve(shouldContinue); onFinished: (shouldContinue, shouldUploadAll) => {
}, if (shouldUploadAll) {
uploadAll = true;
}
resolve(shouldContinue);
},
});
}); });
}); if (!shouldContinue) break;
if (!shouldContinue) break; }
this._sendContentToRoom(file, roomId, matrixClient); this._sendContentToRoom(file, roomId, matrixClient);
} }
} }

View file

@ -17,9 +17,12 @@ limitations under the License.
import URL from 'url'; import URL from 'url';
import dis from './dispatcher'; import dis from './dispatcher';
import IntegrationManager from './IntegrationManager';
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
import ActiveWidgetStore from './stores/ActiveWidgetStore'; import ActiveWidgetStore from './stores/ActiveWidgetStore';
import sdk from "./index";
import Modal from "./Modal";
import MatrixClientPeg from "./MatrixClientPeg";
import RoomViewStore from "./stores/RoomViewStore";
const WIDGET_API_VERSION = '0.0.2'; // Current API version const WIDGET_API_VERSION = '0.0.2'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [ const SUPPORTED_WIDGET_API_VERSIONS = [
@ -189,7 +192,14 @@ export default class FromWidgetPostMessageApi {
const data = event.data.data || event.data.widgetData; const data = event.data.data || event.data.widgetData;
const integType = (data && data.integType) ? data.integType : null; const integType = (data && data.integType) ? data.integType : null;
const integId = (data && data.integId) ? data.integId : null; const integId = (data && data.integId) ? data.integId : null;
IntegrationManager.open(integType, integId);
// The dialog will take care of scalar auth for us
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
room: MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
screen: 'type_' + integType,
integrationId: integId,
}, "mx_IntegrationsManager");
} else if (action === 'set_always_on_screen') { } else if (action === 'set_always_on_screen') {
// This is a new message: there is no reason to support the deprecated widgetData here // This is a new message: there is no reason to support the deprecated widgetData here
const data = event.data.data; const data = event.data.data;

View file

@ -1,78 +0,0 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Modal from './Modal';
import sdk from './index';
import SdkConfig from './SdkConfig';
import ScalarMessaging from './ScalarMessaging';
import ScalarAuthClient from './ScalarAuthClient';
import RoomViewStore from './stores/RoomViewStore';
if (!global.mxIntegrationManager) {
global.mxIntegrationManager = {};
}
export default class IntegrationManager {
static _init() {
if (!global.mxIntegrationManager.client || !global.mxIntegrationManager.connected) {
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
ScalarMessaging.startListening();
global.mxIntegrationManager.client = new ScalarAuthClient();
return global.mxIntegrationManager.client.connect().then(() => {
global.mxIntegrationManager.connected = true;
}).catch((e) => {
console.error("Failed to connect to integrations server", e);
global.mxIntegrationManager.error = e;
});
} else {
console.error('Invalid integration manager config', SdkConfig.get());
}
}
}
/**
* Launch the integrations manager on the stickers integration page
* @param {string} integName integration / widget type
* @param {string} integId integration / widget ID
* @param {function} onFinished Callback to invoke on integration manager close
*/
static async open(integName, integId, onFinished) {
await IntegrationManager._init();
if (global.mxIntegrationManager.client) {
await global.mxIntegrationManager.client.connect();
} else {
return;
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
if (global.mxIntegrationManager.error ||
!(global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials())) {
console.error("Scalar error", global.mxIntegrationManager);
return;
}
const integType = 'type_' + integName;
const src = (global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials()) ?
global.mxIntegrationManager.client.getScalarInterfaceUrlForRoom(
{roomId: RoomViewStore.getRoomId()},
integType,
integId,
) :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
onFinished: onFinished,
}, "mx_IntegrationsManager");
}
}

View file

@ -233,7 +233,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
guest: true, guest: true,
}, true).then(() => true); }, true).then(() => true);
}, (err) => { }, (err) => {
console.error("Failed to register as guest: " + err + " " + err.data); console.error("Failed to register as guest", err);
return false; return false;
}); });
} }

View file

@ -51,6 +51,7 @@ interface MatrixClientCreds {
class MatrixClientPeg { class MatrixClientPeg {
constructor() { constructor() {
this.matrixClient = null; this.matrixClient = null;
this._justRegisteredUserId = null;
// These are the default options used when when the // These are the default options used when when the
// client is started in 'start'. These can be altered // client is started in 'start'. These can be altered
@ -85,6 +86,31 @@ class MatrixClientPeg {
MatrixActionCreators.stop(); MatrixActionCreators.stop();
} }
/*
* If we've registered a user ID we set this to the ID of the
* user we've just registered. If they then go & log in, we
* can send them to the welcome user (obviously this doesn't
* guarentee they'll get a chat with the welcome user).
*
* @param {string} uid The user ID of the user we've just registered
*/
setJustRegisteredUserId(uid) {
this._justRegisteredUserId = uid;
}
/*
* Returns true if the current user has just been registered by this
* client as determined by setJustRegisteredUserId()
*
* @returns {bool} True if user has just been registered
*/
currentUserIsJustRegistered() {
return (
this.matrixClient &&
this.matrixClient.credentials.userId === this._justRegisteredUserId
);
}
/** /**
* Replace this MatrixClientPeg's client with a client instance that has * Replace this MatrixClientPeg's client with a client instance that has
* homeserver / identity server URLs and active credentials * homeserver / identity server URLs and active credentials

View file

@ -29,6 +29,14 @@ class ScalarAuthClient {
this.scalarToken = null; this.scalarToken = null;
} }
/**
* Determines if setting up a ScalarAuthClient is even possible
* @returns {boolean} true if possible, false otherwise.
*/
static isPossible() {
return SdkConfig.get()['integrations_rest_url'] && SdkConfig.get()['integrations_ui_url'];
}
connect() { connect() {
return this.getScalarToken().then((tok) => { return this.getScalarToken().then((tok) => {
this.scalarToken = tok; this.scalarToken = tok;
@ -41,7 +49,8 @@ class ScalarAuthClient {
// Returns a scalar_token string // Returns a scalar_token string
getScalarToken() { getScalarToken() {
const token = window.localStorage.getItem("mx_scalar_token"); let token = this.scalarToken;
if (!token) token = window.localStorage.getItem("mx_scalar_token");
if (!token) { if (!token) {
return this.registerForToken(); return this.registerForToken();

View file

@ -292,16 +292,6 @@ const LoggedInView = React.createClass({
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.keyCode) { switch (ev.keyCode) {
case KeyCode.UP:
case KeyCode.DOWN:
if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
const action = ev.keyCode == KeyCode.UP ?
'view_prev_room' : 'view_next_room';
dis.dispatch({action: action});
handled = true;
}
break;
case KeyCode.PAGE_UP: case KeyCode.PAGE_UP:
case KeyCode.PAGE_DOWN: case KeyCode.PAGE_DOWN:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {

View file

@ -51,7 +51,9 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import { startAnyRegistrationFlow } from "../../Registration.js"; import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils'; import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import {ValidatedServerConfig} from "../../utils/AutoDiscoveryUtils"; import { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
import DMRoomMap from '../../utils/DMRoomMap';
// Disable warnings for now: we use deprecated bluebird functions // Disable warnings for now: we use deprecated bluebird functions
// and need to migrate, but they spam the console with warnings. // and need to migrate, but they spam the console with warnings.
@ -676,7 +678,7 @@ export default React.createClass({
}); });
}, },
_startRegistration: function(params) { _startRegistration: async function(params) {
const newState = { const newState = {
view: VIEWS.REGISTER, view: VIEWS.REGISTER,
}; };
@ -689,10 +691,12 @@ export default React.createClass({
params.is_url && params.is_url &&
params.sid params.sid
) { ) {
newState.serverConfig = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
params.hs_url, params.is_url,
);
newState.register_client_secret = params.client_secret; newState.register_client_secret = params.client_secret;
newState.register_session_id = params.session_id; newState.register_session_id = params.session_id;
newState.register_hs_url = params.hs_url;
newState.register_is_url = params.is_url;
newState.register_id_sid = params.sid; newState.register_id_sid = params.sid;
} }
@ -884,6 +888,7 @@ export default React.createClass({
} }
return; return;
} }
MatrixClientPeg.setJustRegisteredUserId(credentials.user_id);
this.onRegistered(credentials); this.onRegistered(credentials);
}, },
onDifferentServerClicked: (ev) => { onDifferentServerClicked: (ev) => {
@ -1128,29 +1133,81 @@ export default React.createClass({
} }
}, },
/**
* Starts a chat with the welcome user, if the user doesn't already have one
* @returns {string} The room ID of the new room, or null if no room was created
*/
async _startWelcomeUserChat() {
// We can end up with multiple tabs post-registration where the user
// might then end up with a session and we don't want them all making
// a chat with the welcome user: try to de-dupe.
// We need to wait for the first sync to complete for this to
// work though.
let waitFor;
if (!this.firstSyncComplete) {
waitFor = this.firstSyncPromise.promise;
} else {
waitFor = Promise.resolve();
}
await waitFor;
const welcomeUserRooms = DMRoomMap.shared().getDMRoomsForUserId(
this.props.config.welcomeUserId,
);
if (welcomeUserRooms.length === 0) {
const roomId = await createRoom({
dmUserId: this.props.config.welcomeUserId,
// Only view the welcome user if we're NOT looking at a room
andView: !this.state.currentRoomId,
spinner: false, // we're already showing one: we don't need another one
});
// This is a bit of a hack, but since the deduplication relies
// on m.direct being up to date, we need to force a sync
// of the database, otherwise if the user goes to the other
// tab before the next save happens (a few minutes), the
// saved sync will be restored from the db and this code will
// run without the update to m.direct, making another welcome
// user room (it doesn't wait for new data from the server, just
// the saved sync to be loaded).
const saveWelcomeUser = (ev) => {
if (
ev.getType() == 'm.direct' &&
ev.getContent() &&
ev.getContent()[this.props.config.welcomeUserId]
) {
MatrixClientPeg.get().store.save(true);
MatrixClientPeg.get().removeListener(
"accountData", saveWelcomeUser,
);
}
};
MatrixClientPeg.get().on("accountData", saveWelcomeUser);
return roomId;
}
return null;
},
/** /**
* Called when a new logged in session has started * Called when a new logged in session has started
*/ */
_onLoggedIn: async function() { _onLoggedIn: async function() {
this.setStateForNewView({ view: VIEWS.LOGGED_IN }); this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (this._is_registered) { if (MatrixClientPeg.currentUserIsJustRegistered()) {
this._is_registered = false; MatrixClientPeg.setJustRegisteredUserId(null);
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) { if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
const roomId = await createRoom({ const welcomeUserRoom = await this._startWelcomeUserChat();
dmUserId: this.props.config.welcomeUserId, if (welcomeUserRoom === null) {
// Only view the welcome user if we're NOT looking at a room // We didn't rediret to the welcome user room, so show
andView: !this.state.currentRoomId, // the homepage.
}); dis.dispatch({action: 'view_home_page'});
// if successful, return because we're already
// viewing the welcomeUserId room
// else, if failed, fall through to view_home_page
if (roomId) {
return;
} }
} else {
// The user has just logged in after registering,
// so show the homepage.
dis.dispatch({action: 'view_home_page'});
} }
// The user has just logged in after registering
dis.dispatch({action: 'view_home_page'});
} else { } else {
this._showScreenAfterLogin(); this._showScreenAfterLogin();
} }
@ -1691,9 +1748,6 @@ export default React.createClass({
return MatrixClientPeg.get(); return MatrixClientPeg.get();
} }
} }
// XXX: This should be in state or ideally store(s) because we risk not
// rendering the most up-to-date view of state otherwise.
this._is_registered = true;
return Lifecycle.setLoggedIn(credentials); return Lifecycle.setLoggedIn(credentials);
}, },

View file

@ -517,7 +517,8 @@ module.exports = React.createClass({
const DateSeparator = sdk.getComponent('messages.DateSeparator'); const DateSeparator = sdk.getComponent('messages.DateSeparator');
const ret = []; const ret = [];
const isEditing = this.props.editEvent && this.props.editEvent.getId() === mxEv.getId(); const isEditing = this.props.editState &&
this.props.editState.getEvent().getId() === mxEv.getId();
// is this a continuation of the previous message? // is this a continuation of the previous message?
let continuation = false; let continuation = false;
@ -585,13 +586,13 @@ module.exports = React.createClass({
continuation={continuation} continuation={continuation}
isRedacted={mxEv.isRedacted()} isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()} replacingEventId={mxEv.replacingEventId()}
isEditing={isEditing} editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged} onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts} readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap} readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting} checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.replacementOrOwnStatus()} eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour} isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}

View file

@ -35,6 +35,7 @@ const Modal = require("../../Modal");
const UserActivity = require("../../UserActivity"); const UserActivity = require("../../UserActivity");
import { KeyCode } from '../../Keyboard'; import { KeyCode } from '../../Keyboard';
import Timer from '../../utils/Timer'; import Timer from '../../utils/Timer';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
const PAGINATE_SIZE = 20; const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20; const INITIAL_SIZE = 20;
@ -411,7 +412,8 @@ const TimelinePanel = React.createClass({
this.forceUpdate(); this.forceUpdate();
} }
if (payload.action === "edit_event") { if (payload.action === "edit_event") {
this.setState({editEvent: payload.event}, () => { const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
this.setState({editState}, () => {
if (payload.event && this.refs.messagePanel) { if (payload.event && this.refs.messagePanel) {
this.refs.messagePanel.scrollToEventIfNeeded( this.refs.messagePanel.scrollToEventIfNeeded(
payload.event.getId(), payload.event.getId(),
@ -1306,7 +1308,7 @@ const TimelinePanel = React.createClass({
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
getRelationsForEvent={this.getRelationsForEvent} getRelationsForEvent={this.getRelationsForEvent}
editEvent={this.state.editEvent} editState={this.state.editState}
showReactions={this.props.showReactions} showReactions={this.props.showReactions}
/> />
); );

View file

@ -28,6 +28,8 @@ import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import * as ServerType from '../../views/auth/ServerTypeSelector'; import * as ServerType from '../../views/auth/ServerTypeSelector';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames"; import classNames from "classnames";
import * as Lifecycle from '../../../Lifecycle';
import MatrixClientPeg from "../../../MatrixClientPeg";
// Phases // Phases
// Show controls to configure server details // Show controls to configure server details
@ -80,6 +82,9 @@ module.exports = React.createClass({
// Phase of the overall registration dialog. // Phase of the overall registration dialog.
phase: PHASE_REGISTRATION, phase: PHASE_REGISTRATION,
flows: null, flows: null,
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: false,
// We perform liveliness checks later, but for now suppress the errors. // We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so // We also track the server dead errors independently of the regular errors so
@ -163,6 +168,8 @@ module.exports = React.createClass({
_replaceClient: async function(serverConfig) { _replaceClient: async function(serverConfig) {
this.setState({ this.setState({
errorText: null, errorText: null,
serverDeadError: null,
serverErrorIsFatal: false,
// busy while we do liveness check (we need to avoid trying to render // busy while we do liveness check (we need to avoid trying to render
// the UI auth component while we don't have a matrix client) // the UI auth component while we don't have a matrix client)
busy: true, busy: true,
@ -175,7 +182,10 @@ module.exports = React.createClass({
serverConfig.hsUrl, serverConfig.hsUrl,
serverConfig.isUrl, serverConfig.isUrl,
); );
this.setState({serverIsAlive: true}); this.setState({
serverIsAlive: true,
serverErrorIsFatal: false,
});
} catch (e) { } catch (e) {
this.setState({ this.setState({
busy: false, busy: false,
@ -209,6 +219,7 @@ module.exports = React.createClass({
errorText: _t("Registration has been disabled on this homeserver."), errorText: _t("Registration has been disabled on this homeserver."),
}); });
} else { } else {
console.log("Unable to query for supported registration methods.", e);
this.setState({ this.setState({
errorText: _t("Unable to query for supported registration methods."), errorText: _t("Unable to query for supported registration methods."),
}); });
@ -282,21 +293,29 @@ module.exports = React.createClass({
return; return;
} }
this.setState({ MatrixClientPeg.setJustRegisteredUserId(response.user_id);
// we're still busy until we get unmounted: don't show the registration form again
busy: true, const newState = {
doingUIAuth: false, doingUIAuth: false,
}); };
if (response.access_token) {
const cli = await this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
accessToken: response.access_token,
});
const cli = await this.props.onLoggedIn({ this._setupPushers(cli);
userId: response.user_id, // we're still busy until we get unmounted: don't show the registration form again
deviceId: response.device_id, newState.busy = true;
homeserverUrl: this.state.matrixClient.getHomeserverUrl(), } else {
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(), newState.busy = false;
accessToken: response.access_token, newState.completedNoSignin = true;
}); }
this._setupPushers(cli); this.setState(newState);
}, },
_setupPushers: function(matrixClient) { _setupPushers: function(matrixClient) {
@ -353,6 +372,12 @@ module.exports = React.createClass({
}, },
_makeRegisterRequest: function(auth) { _makeRegisterRequest: function(auth) {
// We inhibit login if we're trying to register with an email address: this
// avoids a lot of complex race conditions that can occur if we try to log
// the user in one one or both of the tabs they might end up with after
// clicking the email link.
let inhibitLogin = Boolean(this.state.formVals.email);
// Only send the bind params if we're sending username / pw params // Only send the bind params if we're sending username / pw params
// (Since we need to send no params at all to use the ones saved in the // (Since we need to send no params at all to use the ones saved in the
// session). // session).
@ -360,6 +385,8 @@ module.exports = React.createClass({
email: true, email: true,
msisdn: true, msisdn: true,
} : {}; } : {};
// Likewise inhibitLogin
if (!this.state.formVals.password) inhibitLogin = null;
return this.state.matrixClient.register( return this.state.matrixClient.register(
this.state.formVals.username, this.state.formVals.username,
@ -368,6 +395,7 @@ module.exports = React.createClass({
auth, auth,
bindThreepids, bindThreepids,
null, null,
inhibitLogin,
); );
}, },
@ -379,6 +407,19 @@ module.exports = React.createClass({
}; };
}, },
// Links to the login page shown after registration is completed are routed through this
// which checks the user hasn't already logged in somewhere else (perhaps we should do
// this more generally?)
_onLoginClickWithCheck: async function(ev) {
ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({});
if (!sessionLoaded) {
// ok fine, there's still no session: really go to the login page
this.props.onLoginClick();
}
},
renderServerComponent() { renderServerComponent() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig"); const ServerConfig = sdk.getComponent("auth.ServerConfig");
@ -390,7 +431,9 @@ module.exports = React.createClass({
// If we're on a different phase, we only show the server type selector, // If we're on a different phase, we only show the server type selector,
// which is always shown if we allow custom URLs at all. // which is always shown if we allow custom URLs at all.
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { // (if there's a fatal server error, we need to show the full server
// config as the user may need to change servers to resolve the error).
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) {
return <div> return <div>
<ServerTypeSelector <ServerTypeSelector
selected={this.state.serverType} selected={this.state.serverType}
@ -528,17 +571,49 @@ module.exports = React.createClass({
</a>; </a>;
} }
let body;
if (this.state.completedNoSignin) {
let regDoneText;
if (this.state.formVals.password) {
// We're the client that started the registration
regDoneText = _t(
"<a>Log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
},
);
} else {
// We're not the original client: the user probably got to us by clicking the
// email validation link. We can't offer a 'go straight to your account' link
// as we don't have the original creds.
regDoneText = _t(
"You can now close this window or <a>log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
},
);
}
body = <div>
<h2>{_t("Registration Successful")}</h2>
<h3>{ regDoneText }</h3>
</div>;
} else {
body = <div>
<h2>{ _t('Create your account') }</h2>
{ errorText }
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }
</div>;
}
return ( return (
<AuthPage> <AuthPage>
<AuthHeader /> <AuthHeader />
<AuthBody> <AuthBody>
<h2>{ _t('Create your account') }</h2> { body }
{ errorText }
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }
</AuthBody> </AuthBody>
</AuthPage> </AuthPage>
); );

View file

@ -101,16 +101,28 @@ export default class ServerConfig extends React.PureComponent {
return result; return result;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
return null; const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (!stateForError.isFatalError) {
this.setState({
busy: false,
});
// carry on anyway
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true);
this.props.onServerConfigChange(result);
return result;
} else {
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
return null;
}
} }
} }

View file

@ -49,6 +49,10 @@ export default class UploadConfirmDialog extends React.Component {
this.props.onFinished(true); this.props.onFinished(true);
} }
_onUploadAllClick = () => {
this.props.onFinished(true, true);
}
render() { render() {
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');
@ -85,6 +89,13 @@ export default class UploadConfirmDialog extends React.Component {
</div>; </div>;
} }
let uploadAllButton;
if (this.props.currentIndex + 1 < this.props.totalFiles) {
uploadAllButton = <button onClick={this._onUploadAllClick}>
{_t("Upload all")}
</button>;
}
return ( return (
<BaseDialog className='mx_UploadConfirmDialog' <BaseDialog className='mx_UploadConfirmDialog'
fixedWidth={false} fixedWidth={false}
@ -100,7 +111,9 @@ export default class UploadConfirmDialog extends React.Component {
hasCancel={false} hasCancel={false}
onPrimaryButtonClick={this._onUploadClick} onPrimaryButtonClick={this._onUploadClick}
focus={true} focus={true}
/> >
{uploadAllButton}
</DialogButtons>
</BaseDialog> </BaseDialog>
); );
} }

View file

@ -240,19 +240,13 @@ export default class AppTile extends React.Component {
if (this.props.onEditClick) { if (this.props.onEditClick) {
this.props.onEditClick(); this.props.onEditClick();
} else { } else {
// The dialog handles scalar auth for us
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
this._scalarClient.connect().done(() => { Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
const src = this._scalarClient.getScalarInterfaceUrlForRoom( room: this.props.room,
this.props.room, 'type_' + this.props.type, this.props.id); screen: 'type_' + this.props.type,
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { integrationId: this.props.id,
src: src, }, "mx_IntegrationsManager");
}, "mx_IntegrationsManager");
}, (err) => {
this.setState({
error: err.message,
});
console.error('Error ensuring a valid scalar_token exists', err);
});
} }
} }

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2017 New Vector Ltd Copyright 2017 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.
@ -17,95 +18,34 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import sdk from '../../../index'; import sdk from '../../../index';
import classNames from 'classnames';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient'; import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import AccessibleButton from './AccessibleButton';
export default class ManageIntegsButton extends React.Component { export default class ManageIntegsButton extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {
scalarError: null,
};
this.onManageIntegrations = this.onManageIntegrations.bind(this);
} }
componentWillMount() { onManageIntegrations = (ev) => {
ScalarMessaging.startListening();
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => {
this.forceUpdate();
}, (err) => {
this.setState({scalarError: err});
console.error('Error whilst initialising scalarClient for ManageIntegsButton', err);
});
}
}
componentWillUnmount() {
ScalarMessaging.stopListening();
}
onManageIntegrations(ev) {
ev.preventDefault(); ev.preventDefault();
if (this.state.scalarError && !this.scalarClient.hasCredentials()) {
return;
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
this.scalarClient.connect().done(() => { Modal.createDialog(IntegrationsManager, {
Modal.createDialog(IntegrationsManager, { room: this.props.room,
src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? }, "mx_IntegrationsManager");
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room) : };
null,
}, "mx_IntegrationsManager");
}, (err) => {
this.setState({scalarError: err});
console.error('Error ensuring a valid scalar_token exists', err);
});
}
render() { render() {
let integrationsButton = <div />; let integrationsButton = <div />;
let integrationsWarningTriangle = <div />; if (ScalarAuthClient.isPossible()) {
let integrationsErrorPopup = <div />; const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
if (this.scalarClient !== null) {
const integrationsButtonClasses = classNames({
mx_RoomHeader_button: true,
mx_RoomHeader_manageIntegsButton: true,
mx_ManageIntegsButton_error: !!this.state.scalarError,
});
if (this.state.scalarError && !this.scalarClient.hasCredentials()) {
integrationsWarningTriangle = <img
src={require("../../../../res/img/warning.svg")}
title={_t('Integrations Error')}
width="17"
/>;
// Popup shown when hovering over integrationsButton_error (via CSS)
integrationsErrorPopup = (
<span className="mx_ManageIntegsButton_errorPopup">
{ _t('Could not connect to the integration server') }
</span>
);
}
integrationsButton = ( integrationsButton = (
<AccessibleButton className={integrationsButtonClasses} <AccessibleButton
className='mx_RoomHeader_button mx_RoomHeader_manageIntegsButton'
title={_t("Manage Integrations")}
onClick={this.onManageIntegrations} onClick={this.onManageIntegrations}
title={_t('Manage Integrations')} />
>
{ integrationsWarningTriangle }
{ integrationsErrorPopup }
</AccessibleButton>
); );
} }

View file

@ -28,13 +28,14 @@ import {parseEvent} from '../../../editor/deserialize';
import Autocomplete from '../rooms/Autocomplete'; import Autocomplete from '../rooms/Autocomplete';
import {PartCreator} from '../../../editor/parts'; import {PartCreator} from '../../../editor/parts';
import {renderModel} from '../../../editor/render'; import {renderModel} from '../../../editor/render';
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import {MatrixClient} from 'matrix-js-sdk';
import classNames from 'classnames'; import classNames from 'classnames';
export default class MessageEditor extends React.Component { export default class MessageEditor extends React.Component {
static propTypes = { static propTypes = {
// the message event being edited // the message event being edited
event: PropTypes.instanceOf(MatrixEvent).isRequired, editState: PropTypes.instanceOf(EditorStateTransfer).isRequired,
}; };
static contextTypes = { static contextTypes = {
@ -44,16 +45,7 @@ export default class MessageEditor extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
const room = this._getRoom(); const room = this._getRoom();
const partCreator = new PartCreator( this.model = null;
() => this._autocompleteRef,
query => this.setState({query}),
room,
);
this.model = new EditorModel(
parseEvent(this.props.event, room),
partCreator,
this._updateEditorState,
);
this.state = { this.state = {
autoComplete: null, autoComplete: null,
room, room,
@ -64,7 +56,7 @@ export default class MessageEditor extends React.Component {
} }
_getRoom() { _getRoom() {
return this.context.matrixClient.getRoom(this.props.event.getRoomId()); return this.context.matrixClient.getRoom(this.props.editState.getEvent().getRoomId());
} }
_updateEditorState = (caret) => { _updateEditorState = (caret) => {
@ -133,7 +125,7 @@ export default class MessageEditor extends React.Component {
if (this._hasModifications || !this._isCaretAtStart()) { if (this._hasModifications || !this._isCaretAtStart()) {
return; return;
} }
const previousEvent = findEditableEvent(this._getRoom(), false, this.props.event.getId()); const previousEvent = findEditableEvent(this._getRoom(), false, this.props.editState.getEvent().getId());
if (previousEvent) { if (previousEvent) {
dis.dispatch({action: 'edit_event', event: previousEvent}); dis.dispatch({action: 'edit_event', event: previousEvent});
event.preventDefault(); event.preventDefault();
@ -142,7 +134,7 @@ export default class MessageEditor extends React.Component {
if (this._hasModifications || !this._isCaretAtEnd()) { if (this._hasModifications || !this._isCaretAtEnd()) {
return; return;
} }
const nextEvent = findEditableEvent(this._getRoom(), true, this.props.event.getId()); const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId());
if (nextEvent) { if (nextEvent) {
dis.dispatch({action: 'edit_event', event: nextEvent}); dis.dispatch({action: 'edit_event', event: nextEvent});
} else { } else {
@ -158,16 +150,28 @@ export default class MessageEditor extends React.Component {
dis.dispatch({action: 'focus_composer'}); dis.dispatch({action: 'focus_composer'});
} }
_isEmote() {
const firstPart = this.model.parts[0];
return firstPart && firstPart.type === "plain" && firstPart.text.startsWith("/me ");
}
_sendEdit = () => { _sendEdit = () => {
const isEmote = this._isEmote();
let model = this.model;
if (isEmote) {
// trim "/me "
model = model.clone();
model.removeText({index: 0, offset: 0}, 4);
}
const newContent = { const newContent = {
"msgtype": "m.text", "msgtype": isEmote ? "m.emote" : "m.text",
"body": textSerialize(this.model), "body": textSerialize(model),
}; };
const contentBody = { const contentBody = {
msgtype: newContent.msgtype, msgtype: newContent.msgtype,
body: ` * ${newContent.body}`, body: ` * ${newContent.body}`,
}; };
const formattedBody = htmlSerializeIfNeeded(this.model); const formattedBody = htmlSerializeIfNeeded(model);
if (formattedBody) { if (formattedBody) {
newContent.format = "org.matrix.custom.html"; newContent.format = "org.matrix.custom.html";
newContent.formatted_body = formattedBody; newContent.formatted_body = formattedBody;
@ -178,11 +182,11 @@ export default class MessageEditor extends React.Component {
"m.new_content": newContent, "m.new_content": newContent,
"m.relates_to": { "m.relates_to": {
"rel_type": "m.replace", "rel_type": "m.replace",
"event_id": this.props.event.getId(), "event_id": this.props.editState.getEvent().getId(),
}, },
}, contentBody); }, contentBody);
const roomId = this.props.event.getRoomId(); const roomId = this.props.editState.getEvent().getRoomId();
this.context.matrixClient.sendMessage(roomId, content); this.context.matrixClient.sendMessage(roomId, content);
dis.dispatch({action: "edit_event", event: null}); dis.dispatch({action: "edit_event", event: null});
@ -197,12 +201,63 @@ export default class MessageEditor extends React.Component {
this.model.autoComplete.onComponentSelectionChange(completion); this.model.autoComplete.onComponentSelectionChange(completion);
} }
componentWillUnmount() {
const sel = document.getSelection();
const {caret} = getCaretOffsetAndText(this._editorRef, sel);
const parts = this.model.serializeParts();
this.props.editState.setEditorState(caret, parts);
}
componentDidMount() { componentDidMount() {
this.model = this._createEditorModel();
// initial render of model
this._updateEditorState(); this._updateEditorState();
setCaretPosition(this._editorRef, this.model, this.model.getPositionAtEnd()); // initial caret position
this._initializeCaret();
this._editorRef.focus(); this._editorRef.focus();
} }
_createEditorModel() {
const {editState} = this.props;
const room = this._getRoom();
const partCreator = new PartCreator(
() => this._autocompleteRef,
query => this.setState({query}),
room,
this.context.matrixClient,
);
let parts;
if (editState.hasEditorState()) {
// if restoring state from a previous editor,
// restore serialized parts from the state
parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p));
} else {
// otherwise, parse the body of the event
parts = parseEvent(editState.getEvent(), room, this.context.matrixClient);
}
return new EditorModel(
parts,
partCreator,
this._updateEditorState,
);
}
_initializeCaret() {
const {editState} = this.props;
let caretPosition;
if (editState.hasEditorState()) {
// if restoring state from a previous editor,
// restore caret position from the state
const caret = editState.getCaret();
caretPosition = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
} else {
// otherwise, set it at the end
caretPosition = this.model.getPositionAtEnd();
}
setCaretPosition(this._editorRef, this.model, caretPosition);
}
render() { render() {
let autoComplete; let autoComplete;
if (this.state.autoComplete) { if (this.state.autoComplete) {

View file

@ -224,7 +224,7 @@ module.exports = React.createClass({
<div className="mx_MemberInfo_profile"> <div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField"> <div className="mx_MemberInfo_profileField">
{ this.state.groupRoom.canonical_alias } { this.state.groupRoom.canonicalAlias }
</div> </div>
</div> </div>

View file

@ -90,7 +90,7 @@ module.exports = React.createClass({
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
maxImageHeight={this.props.maxImageHeight} maxImageHeight={this.props.maxImageHeight}
replacingEventId={this.props.replacingEventId} replacingEventId={this.props.replacingEventId}
isEditing={this.props.isEditing} editState={this.props.editState}
onHeightChanged={this.props.onHeightChanged} />; onHeightChanged={this.props.onHeightChanged} />;
}, },
}); });

View file

@ -90,7 +90,7 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
this._unmounted = false; this._unmounted = false;
if (!this.props.isEditing) { if (!this.props.editState) {
this._applyFormatting(); this._applyFormatting();
} }
}, },
@ -131,8 +131,8 @@ module.exports = React.createClass({
}, },
componentDidUpdate: function(prevProps) { componentDidUpdate: function(prevProps) {
if (!this.props.isEditing) { if (!this.props.editState) {
const stoppedEditing = prevProps.isEditing && !this.props.isEditing; const stoppedEditing = prevProps.editState && !this.props.editState;
const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId; const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
if (messageWasEdited || stoppedEditing) { if (messageWasEdited || stoppedEditing) {
this._applyFormatting(); this._applyFormatting();
@ -153,7 +153,7 @@ module.exports = React.createClass({
nextProps.replacingEventId !== this.props.replacingEventId || nextProps.replacingEventId !== this.props.replacingEventId ||
nextProps.highlightLink !== this.props.highlightLink || nextProps.highlightLink !== this.props.highlightLink ||
nextProps.showUrlPreview !== this.props.showUrlPreview || nextProps.showUrlPreview !== this.props.showUrlPreview ||
nextProps.isEditing !== this.props.isEditing || nextProps.editState !== this.props.editState ||
nextState.links !== this.state.links || nextState.links !== this.state.links ||
nextState.editedMarkerHovered !== this.state.editedMarkerHovered || nextState.editedMarkerHovered !== this.state.editedMarkerHovered ||
nextState.widgetHidden !== this.state.widgetHidden); nextState.widgetHidden !== this.state.widgetHidden);
@ -469,9 +469,9 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
if (this.props.isEditing) { if (this.props.editState) {
const MessageEditor = sdk.getComponent('elements.MessageEditor'); const MessageEditor = sdk.getComponent('elements.MessageEditor');
return <MessageEditor event={this.props.mxEvent} className="mx_EventTile_content" />; return <MessageEditor editState={this.props.editState} className="mx_EventTile_content" />;
} }
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent(); const content = mxEvent.getContent();

View file

@ -24,8 +24,6 @@ import AppTile from '../elements/AppTile';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import sdk from '../../../index'; import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging'; import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
@ -63,20 +61,6 @@ module.exports = React.createClass({
}, },
componentDidMount: function() { componentDidMount: function() {
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().then(() => {
this.forceUpdate();
}).catch((e) => {
console.log('Failed to connect to integrations server');
// TODO -- Handle Scalar errors
// this.setState({
// scalar_error: err,
// });
});
}
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
}, },
@ -144,16 +128,10 @@ module.exports = React.createClass({
_launchManageIntegrations: function() { _launchManageIntegrations: function() {
const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager'); const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager');
this.scalarClient.connect().done(() => { Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? room: this.props.room,
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room, 'add_integ') : screen: 'add_integ',
null; }, 'mx_IntegrationsManager');
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, 'mx_IntegrationsManager');
}, (err) => {
console.error('Error ensuring a valid scalar_token exists', err);
});
}, },
onClickAddWidget: function(e) { onClickAddWidget: function(e) {

View file

@ -171,26 +171,13 @@ export default class Autocomplete extends React.Component {
} }
// called from MessageComposerInput // called from MessageComposerInput
onUpArrow(): ?Completion { moveSelection(delta): ?Completion {
const completionCount = this.countCompletions(); const completionCount = this.countCompletions();
// completionCount + 1, since 0 means composer is selected if (completionCount === 0) return; // there are no items to move the selection through
const selectionOffset = (completionCount + 1 + this.state.selectionOffset - 1)
% (completionCount + 1);
if (!completionCount) {
return null;
}
this.setSelection(selectionOffset);
}
// called from MessageComposerInput // Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected
onDownArrow(): ?Completion { const index = (this.state.selectionOffset + delta + completionCount + 1) % (completionCount + 1);
const completionCount = this.countCompletions(); this.setSelection(index);
// completionCount + 1, since 0 means composer is selected
const selectionOffset = (this.state.selectionOffset + 1) % (completionCount + 1);
if (!completionCount) {
return null;
}
this.setSelection(selectionOffset);
} }
onEscape(e): boolean { onEscape(e): boolean {

View file

@ -552,13 +552,14 @@ module.exports = withMatrixClient(React.createClass({
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
const isEditing = !!this.props.editState;
const classes = classNames({ const classes = classNames({
mx_EventTile: true, mx_EventTile: true,
mx_EventTile_isEditing: this.props.isEditing, mx_EventTile_isEditing: isEditing,
mx_EventTile_info: isInfoMessage, mx_EventTile_info: isInfoMessage,
mx_EventTile_12hr: this.props.isTwelveHour, mx_EventTile_12hr: this.props.isTwelveHour,
mx_EventTile_encrypting: this.props.eventSendStatus === 'encrypting', mx_EventTile_encrypting: this.props.eventSendStatus === 'encrypting',
mx_EventTile_sending: isSending, mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent', mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent',
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(), mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent, mx_EventTile_selected: this.props.isSelectedEvent,
@ -632,7 +633,7 @@ module.exports = withMatrixClient(React.createClass({
} }
const MessageActionBar = sdk.getComponent('messages.MessageActionBar'); const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
const actionBar = !this.props.isEditing ? <MessageActionBar const actionBar = !isEditing ? <MessageActionBar
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
reactions={this.state.reactions} reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
@ -794,7 +795,7 @@ module.exports = withMatrixClient(React.createClass({
<EventTileType ref="tile" <EventTileType ref="tile"
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
replacingEventId={this.props.replacingEventId} replacingEventId={this.props.replacingEventId}
isEditing={this.props.isEditing} editState={this.props.editState}
highlights={this.props.highlights} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}

View file

@ -60,6 +60,7 @@ import ReplyThread from "../elements/ReplyThread";
import {ContentHelpers} from 'matrix-js-sdk'; import {ContentHelpers} from 'matrix-js-sdk';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import {findEditableEvent} from '../../../utils/EventUtils'; import {findEditableEvent} from '../../../utils/EventUtils';
import ComposerHistoryManager from "../../../ComposerHistoryManager";
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
@ -140,6 +141,7 @@ export default class MessageComposerInput extends React.Component {
client: MatrixClient; client: MatrixClient;
autocomplete: Autocomplete; autocomplete: Autocomplete;
historyManager: ComposerHistoryManager;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -329,6 +331,7 @@ export default class MessageComposerInput extends React.Component {
componentWillMount() { componentWillMount() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_');
} }
componentWillUnmount() { componentWillUnmount() {
@ -673,6 +676,31 @@ export default class MessageComposerInput extends React.Component {
onKeyDown = (ev: KeyboardEvent, change: Change, editor: Editor) => { onKeyDown = (ev: KeyboardEvent, change: Change, editor: Editor) => {
this.suppressAutoComplete = false; this.suppressAutoComplete = false;
this.direction = '';
// Navigate autocomplete list with arrow keys
if (this.autocomplete.countCompletions() > 0) {
if (!(ev.ctrlKey || ev.shiftKey || ev.altKey || ev.metaKey)) {
switch (ev.keyCode) {
case KeyCode.LEFT:
this.autocomplete.moveSelection(-1);
ev.preventDefault();
return true;
case KeyCode.RIGHT:
this.autocomplete.moveSelection(+1);
ev.preventDefault();
return true;
case KeyCode.UP:
this.autocomplete.moveSelection(-1);
ev.preventDefault();
return true;
case KeyCode.DOWN:
this.autocomplete.moveSelection(+1);
ev.preventDefault();
return true;
}
}
}
// skip void nodes - see // skip void nodes - see
// https://github.com/ianstormtaylor/slate/issues/762#issuecomment-304855095 // https://github.com/ianstormtaylor/slate/issues/762#issuecomment-304855095
@ -680,8 +708,6 @@ export default class MessageComposerInput extends React.Component {
this.direction = 'Previous'; this.direction = 'Previous';
} else if (ev.keyCode === KeyCode.RIGHT) { } else if (ev.keyCode === KeyCode.RIGHT) {
this.direction = 'Next'; this.direction = 'Next';
} else {
this.direction = '';
} }
switch (ev.keyCode) { switch (ev.keyCode) {
@ -1039,6 +1065,7 @@ export default class MessageComposerInput extends React.Component {
if (cmd) { if (cmd) {
if (!cmd.error) { if (!cmd.error) {
this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown');
this.setState({ this.setState({
editorState: this.createEditorState(), editorState: this.createEditorState(),
}, ()=>{ }, ()=>{
@ -1116,6 +1143,8 @@ export default class MessageComposerInput extends React.Component {
let sendHtmlFn = ContentHelpers.makeHtmlMessage; let sendHtmlFn = ContentHelpers.makeHtmlMessage;
let sendTextFn = ContentHelpers.makeTextMessage; let sendTextFn = ContentHelpers.makeTextMessage;
this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown');
if (commandText && commandText.startsWith('/me')) { if (commandText && commandText.startsWith('/me')) {
if (replyingToEv) { if (replyingToEv) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -1175,36 +1204,89 @@ export default class MessageComposerInput extends React.Component {
}; };
onVerticalArrow = (e, up) => { onVerticalArrow = (e, up) => {
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) { if (e.ctrlKey || e.shiftKey || e.metaKey) return;
// selection must be collapsed
const selection = this.state.editorState.selection;
if (!selection.isCollapsed) return;
// and we must be at the edge of the document (up=start, down=end)
const document = this.state.editorState.document;
if (up) {
if (!selection.anchor.isAtStartOfNode(document)) return;
} else {
if (!selection.anchor.isAtEndOfNode(document)) return;
}
const editingEnabled = SettingsStore.isFeatureEnabled("feature_message_editing");
const shouldSelectHistory = (editingEnabled && e.altKey) || !editingEnabled;
const shouldEditLastMessage = editingEnabled && !e.altKey && up;
if (shouldSelectHistory) {
// Try select composer history
const selected = this.selectHistory(up);
if (selected) {
// We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
}
} else if (shouldEditLastMessage) {
const editEvent = findEditableEvent(this.props.room, false);
if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
dis.dispatch({
action: 'edit_event',
event: editEvent,
});
}
}
};
selectHistory = (up) => {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
if (this.historyManager.currentIndex === this.historyManager.history.length) {
// We can't go any further - there isn't any more history, so nop.
if (!up) {
return;
}
this.setState({
currentlyComposedEditorState: this.state.editorState,
});
} else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) {
// True when we return to the message being composed currently
this.setState({
editorState: this.state.currentlyComposedEditorState,
});
this.historyManager.currentIndex = this.historyManager.history.length;
return; return;
} }
// Select history only if we are not currently auto-completing let editorState;
if (this.autocomplete.state.completionList.length === 0) { const historyItem = this.historyManager.getItem(delta);
const selection = this.state.editorState.selection; if (!historyItem) return;
// selection must be collapsed if (historyItem.format === 'rich' && !this.state.isRichTextEnabled) {
if (!selection.isCollapsed) return; editorState = this.richToMdEditorState(historyItem.value);
const document = this.state.editorState.document; } else if (historyItem.format === 'markdown' && this.state.isRichTextEnabled) {
editorState = this.mdToRichEditorState(historyItem.value);
// and we must be at the edge of the document (up=start, down=end)
if (up) {
if (!selection.anchor.isAtStartOfNode(document)) return;
const editEvent = findEditableEvent(this.props.room, false);
if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
dis.dispatch({
action: 'edit_event',
event: editEvent,
});
}
}
} else { } else {
this.moveAutocompleteSelection(up); editorState = historyItem.value;
e.preventDefault();
} }
// Move selection to the end of the selected history
const change = editorState.change().moveToEndOfNode(editorState.document);
// We don't call this.onChange(change) now, as fixups on stuff like pills
// should already have been done and persisted in the history.
editorState = change.value;
this.suppressAutoComplete = true;
this.setState({ editorState }, ()=>{
this._editor.focus();
});
return true;
}; };
onTab = async (e) => { onTab = async (e) => {
@ -1212,23 +1294,19 @@ export default class MessageComposerInput extends React.Component {
someCompletions: null, someCompletions: null,
}); });
e.preventDefault(); e.preventDefault();
if (this.autocomplete.state.completionList.length === 0) { if (this.autocomplete.countCompletions() === 0) {
// Force completions to show for the text currently entered // Force completions to show for the text currently entered
const completionCount = await this.autocomplete.forceComplete(); const completionCount = await this.autocomplete.forceComplete();
this.setState({ this.setState({
someCompletions: completionCount > 0, someCompletions: completionCount > 0,
}); });
// Select the first item by moving "down" // Select the first item by moving "down"
await this.moveAutocompleteSelection(false); await this.autocomplete.moveSelection(+1);
} else { } else {
await this.moveAutocompleteSelection(e.shiftKey); await this.autocomplete.moveSelection(e.shiftKey ? -1 : +1);
} }
}; };
moveAutocompleteSelection = (up) => {
up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow();
};
onEscape = async (e) => { onEscape = async (e) => {
e.preventDefault(); e.preventDefault();
if (this.autocomplete) { if (this.autocomplete) {

View file

@ -14,12 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { _t } from '../../../languageHandler'; import {_t, _td} from '../../../languageHandler';
import AppTile from '../elements/AppTile'; import AppTile from '../elements/AppTile';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import sdk from '../../../index'; import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient'; import ScalarAuthClient from '../../../ScalarAuthClient';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
@ -53,6 +52,9 @@ export default class Stickerpicker extends React.Component {
this.popoverWidth = 300; this.popoverWidth = 300;
this.popoverHeight = 300; this.popoverHeight = 300;
// This is loaded by _acquireScalarClient on an as-needed basis.
this.scalarClient = null;
this.state = { this.state = {
showStickers: false, showStickers: false,
imError: null, imError: null,
@ -63,14 +65,34 @@ export default class Stickerpicker extends React.Component {
}; };
} }
_removeStickerpickerWidgets() { _acquireScalarClient() {
if (this.scalarClient) return Promise.resolve(this.scalarClient);
if (ScalarAuthClient.isPossible()) {
this.scalarClient = new ScalarAuthClient();
return this.scalarClient.connect().then(() => {
this.forceUpdate();
return this.scalarClient;
}).catch((e) => {
this._imError(_td("Failed to connect to integrations server"), e);
});
} else {
this._imError(_td("No integrations server is configured to manage stickers with"));
}
}
async _removeStickerpickerWidgets() {
const scalarClient = await this._acquireScalarClient();
console.warn('Removing Stickerpicker widgets'); console.warn('Removing Stickerpicker widgets');
if (this.state.widgetId) { if (this.state.widgetId) {
this.scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => { if (scalarClient) {
console.warn('Assets disabled'); scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => {
}).catch((err) => { console.warn('Assets disabled');
console.error('Failed to disable assets'); }).catch((err) => {
}); console.error('Failed to disable assets');
});
} else {
console.error("Cannot disable assets: no scalar client");
}
} else { } else {
console.warn('No widget ID specified, not disabling assets'); console.warn('No widget ID specified, not disabling assets');
} }
@ -87,19 +109,7 @@ export default class Stickerpicker extends React.Component {
// Close the sticker picker when the window resizes // Close the sticker picker when the window resizes
window.addEventListener('resize', this._onResize); window.addEventListener('resize', this._onResize);
this.scalarClient = null; this.dispatcherRef = dis.register(this._onWidgetAction);
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().then(() => {
this.forceUpdate();
}).catch((e) => {
this._imError("Failed to connect to integrations server", e);
});
}
if (!this.state.imError) {
this.dispatcherRef = dis.register(this._onWidgetAction);
}
// Track updates to widget state in account data // Track updates to widget state in account data
MatrixClientPeg.get().on('accountData', this._updateWidget); MatrixClientPeg.get().on('accountData', this._updateWidget);
@ -126,7 +136,7 @@ export default class Stickerpicker extends React.Component {
console.error(errorMsg, e); console.error(errorMsg, e);
this.setState({ this.setState({
showStickers: false, showStickers: false,
imError: errorMsg, imError: _t(errorMsg),
}); });
} }
@ -339,22 +349,13 @@ export default class Stickerpicker extends React.Component {
*/ */
_launchManageIntegrations() { _launchManageIntegrations() {
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
this.scalarClient.connect().done(() => {
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? // The integrations manager will handle scalar auth for us.
this.scalarClient.getScalarInterfaceUrlForRoom( Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
this.props.room, room: this.props.room,
'type_' + widgetType, screen: `type_${widgetType}`,
this.state.widgetId, integrationId: this.state.widgetId,
) : }, "mx_IntegrationsManager");
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
this.setState({showStickers: false});
}, (err) => {
this.setState({imError: err});
console.error('Error ensuring a valid scalar_token exists', err);
});
} }
render() { render() {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,50 +15,124 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict'; import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher';
import ScalarAuthClient from '../../../ScalarAuthClient';
const React = require('react'); export default class IntegrationsManager extends React.Component {
const sdk = require('../../../index'); static propTypes = {
const MatrixClientPeg = require('../../../MatrixClientPeg'); // the room object where the integrations manager should be opened in
const dis = require('../../../dispatcher'); room: PropTypes.object.isRequired,
module.exports = React.createClass({ // the screen name to open
displayName: 'IntegrationsManager', screen: PropTypes.string,
propTypes: { // the integration ID to open
src: React.PropTypes.string.isRequired, // the source of the integration manager being embedded integrationId: PropTypes.string,
onFinished: React.PropTypes.func.isRequired, // callback when the lightbox is dismissed
},
// XXX: keyboard shortcuts for managing dialogs should be done by the modal // callback when the manager is dismissed
// dialog base class somehow, surely... onFinished: PropTypes.func.isRequired,
componentDidMount: function() { };
constructor(props) {
super(props);
this.state = {
loading: true,
configured: ScalarAuthClient.isPossible(),
connected: false, // true if a `src` is set and able to be connected to
src: null, // string for where to connect to
};
}
componentWillMount() {
if (!this.state.configured) return;
const scalarClient = new ScalarAuthClient();
scalarClient.connect().then(() => {
const hasCredentials = scalarClient.hasCredentials();
if (!hasCredentials) {
this.setState({
connected: false,
loading: false,
});
} else {
const src = scalarClient.getScalarInterfaceUrlForRoom(
this.props.room,
this.props.screen,
this.props.integrationId,
);
this.setState({
loading: false,
connected: true,
src: src,
});
}
}).catch(err => {
console.error(err);
this.setState({
loading: false,
connected: false,
});
});
}
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
document.addEventListener("keydown", this.onKeyDown); document.addEventListener("keydown", this.onKeyDown);
}, }
componentWillUnmount: function() { componentWillUnmount() {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
document.removeEventListener("keydown", this.onKeyDown); document.removeEventListener("keydown", this.onKeyDown);
}, }
onKeyDown: function(ev) { onKeyDown = (ev) => {
if (ev.keyCode == 27) { // escape if (ev.keyCode === 27) { // escape
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
this.props.onFinished(); this.props.onFinished();
} }
}, };
onAction: function(payload) { onAction = (payload) => {
if (payload.action === 'close_scalar') { if (payload.action === 'close_scalar') {
this.props.onFinished(); this.props.onFinished();
} }
}, };
render: function() { render() {
return ( if (!this.state.configured) {
<iframe src={ this.props.src }></iframe> return (
); <div className='mx_IntegrationsManager_error'>
}, <h3>{_t("No integrations server configured")}</h3>
}); <p>{_t("This Riot instance does not have an integrations server configured.")}</p>
</div>
);
}
if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return (
<div className='mx_IntegrationsManager_loading'>
<h3>{_t("Connecting to integrations server...")}</h3>
<Spinner />
</div>
);
}
if (!this.state.connected) {
return (
<div className='mx_IntegrationsManager_error'>
<h3>{_t("Cannot connect to integrations server")}</h3>
<p>{_t("The integrations server is offline or it cannot reach your homeserver.")}</p>
</div>
);
}
return <iframe src={this.state.src}></iframe>;
}
}

View file

@ -30,12 +30,15 @@ import {getAddressType} from "./UserAddress";
* @param {object=} opts parameters for creating the room * @param {object=} opts parameters for creating the room
* @param {string=} opts.dmUserId If specified, make this a DM room for this user and invite them * @param {string=} opts.dmUserId If specified, make this a DM room for this user and invite them
* @param {object=} opts.createOpts set of options to pass to createRoom call. * @param {object=} opts.createOpts set of options to pass to createRoom call.
* @param {bool=} opts.spinner True to show a modal spinner while the room is created.
* Default: True
* *
* @returns {Promise} which resolves to the room id, or null if the * @returns {Promise} which resolves to the room id, or null if the
* action was aborted or failed. * action was aborted or failed.
*/ */
function createRoom(opts) { function createRoom(opts) {
opts = opts || {}; opts = opts || {};
if (opts.spinner === undefined) opts.spinner = true;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
@ -87,11 +90,12 @@ function createRoom(opts) {
}, },
]; ];
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); let modal;
if (opts.spinner) modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
let roomId; let roomId;
return client.createRoom(createOpts).finally(function() { return client.createRoom(createOpts).finally(function() {
modal.close(); if (modal) modal.close();
}).then(function(res) { }).then(function(res) {
roomId = res.room_id; roomId = res.room_id;
if (opts.dmUserId) { if (opts.dmUserId) {

View file

@ -18,12 +18,13 @@ limitations under the License.
import {UserPillPart, RoomPillPart, PlainPart} from "./parts"; import {UserPillPart, RoomPillPart, PlainPart} from "./parts";
export default class AutocompleteWrapperModel { export default class AutocompleteWrapperModel {
constructor(updateCallback, getAutocompleterComponent, updateQuery, room) { constructor(updateCallback, getAutocompleterComponent, updateQuery, room, client) {
this._updateCallback = updateCallback; this._updateCallback = updateCallback;
this._getAutocompleterComponent = getAutocompleterComponent; this._getAutocompleterComponent = getAutocompleterComponent;
this._updateQuery = updateQuery; this._updateQuery = updateQuery;
this._query = null; this._query = null;
this._room = room; this._room = room;
this._client = client;
} }
onEscape(e) { onEscape(e) {
@ -42,17 +43,13 @@ export default class AutocompleteWrapperModel {
async onTab(e) { async onTab(e) {
const acComponent = this._getAutocompleterComponent(); const acComponent = this._getAutocompleterComponent();
if (acComponent.state.completionList.length === 0) { if (acComponent.countCompletions() === 0) {
// Force completions to show for the text currently entered // Force completions to show for the text currently entered
await acComponent.forceComplete(); await acComponent.forceComplete();
// Select the first item by moving "down" // Select the first item by moving "down"
await acComponent.onDownArrow(); await acComponent.moveSelection(+1);
} else { } else {
if (e.shiftKey) { await acComponent.moveSelection(e.shiftKey ? -1 : +1);
await acComponent.onUpArrow();
} else {
await acComponent.onDownArrow();
}
} }
this._updateCallback({ this._updateCallback({
close: true, close: true,
@ -60,11 +57,11 @@ export default class AutocompleteWrapperModel {
} }
onUpArrow() { onUpArrow() {
this._getAutocompleterComponent().onUpArrow(); this._getAutocompleterComponent().moveSelection(-1);
} }
onDownArrow() { onDownArrow() {
this._getAutocompleterComponent().onDownArrow(); this._getAutocompleterComponent().moveSelection(+1);
} }
onPartUpdate(part, offset) { onPartUpdate(part, offset) {
@ -106,7 +103,7 @@ export default class AutocompleteWrapperModel {
} }
case "#": { case "#": {
const displayAlias = completion.completionId; const displayAlias = completion.completionId;
return new RoomPillPart(displayAlias); return new RoomPillPart(displayAlias, this._client);
} }
// also used for emoji completion // also used for emoji completion
default: default:

View file

@ -21,7 +21,7 @@ import { walkDOMDepthFirst } from "./dom";
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
function parseLink(a, room) { function parseLink(a, room, client) {
const {href} = a; const {href} = a;
const pillMatch = REGEX_MATRIXTO.exec(href) || []; const pillMatch = REGEX_MATRIXTO.exec(href) || [];
const resourceId = pillMatch[1]; // The room/user ID const resourceId = pillMatch[1]; // The room/user ID
@ -34,7 +34,7 @@ function parseLink(a, room) {
room.getMember(resourceId), room.getMember(resourceId),
); );
case "#": case "#":
return new RoomPillPart(resourceId); return new RoomPillPart(resourceId, client);
default: { default: {
if (href === a.textContent) { if (href === a.textContent) {
return new PlainPart(a.textContent); return new PlainPart(a.textContent);
@ -57,10 +57,10 @@ function parseCodeBlock(n) {
return parts; return parts;
} }
function parseElement(n, room) { function parseElement(n, room, client) {
switch (n.nodeName) { switch (n.nodeName) {
case "A": case "A":
return parseLink(n, room); return parseLink(n, room, client);
case "BR": case "BR":
return new NewlinePart("\n"); return new NewlinePart("\n");
case "EM": case "EM":
@ -140,7 +140,7 @@ function prefixQuoteLines(isFirstNode, parts) {
} }
} }
function parseHtmlMessage(html, room) { function parseHtmlMessage(html, room, client) {
// no nodes from parsing here should be inserted in the document, // no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then. // as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine // we're only taking text, so that is fine
@ -165,7 +165,7 @@ function parseHtmlMessage(html, room) {
if (n.nodeType === Node.TEXT_NODE) { if (n.nodeType === Node.TEXT_NODE) {
newParts.push(new PlainPart(n.nodeValue)); newParts.push(new PlainPart(n.nodeValue));
} else if (n.nodeType === Node.ELEMENT_NODE) { } else if (n.nodeType === Node.ELEMENT_NODE) {
const parseResult = parseElement(n, room); const parseResult = parseElement(n, room, client);
if (parseResult) { if (parseResult) {
if (Array.isArray(parseResult)) { if (Array.isArray(parseResult)) {
newParts.push(...parseResult); newParts.push(...parseResult);
@ -205,14 +205,15 @@ function parseHtmlMessage(html, room) {
return parts; return parts;
} }
export function parseEvent(event, room) { export function parseEvent(event, room, client) {
const content = event.getContent(); const content = event.getContent();
let parts;
if (content.format === "org.matrix.custom.html") { if (content.format === "org.matrix.custom.html") {
return parseHtmlMessage(content.formatted_body || "", room); parts = parseHtmlMessage(content.formatted_body || "", room, client);
} else { } else {
const body = content.body || ""; const body = content.body || "";
const lines = body.split("\n"); const lines = body.split("\n");
const parts = lines.reduce((parts, line, i) => { parts = lines.reduce((parts, line, i) => {
const isLast = i === lines.length - 1; const isLast = i === lines.length - 1;
const text = new PlainPart(line); const text = new PlainPart(line);
const newLine = !isLast && new NewlinePart("\n"); const newLine = !isLast && new NewlinePart("\n");
@ -222,6 +223,9 @@ export function parseEvent(event, room) {
return parts.concat(text); return parts.concat(text);
} }
}, []); }, []);
return parts;
} }
if (content.msgtype === "m.emote") {
parts.unshift(new PlainPart("/me "));
}
return parts;
} }

View file

@ -27,6 +27,10 @@ export default class EditorModel {
this._updateCallback = updateCallback; this._updateCallback = updateCallback;
} }
clone() {
return new EditorModel(this._parts, this._partCreator, this._updateCallback);
}
_insertPart(index, part) { _insertPart(index, part) {
this._parts.splice(index, 0, part); this._parts.splice(index, 0, part);
if (this._activePartIdx >= index) { if (this._activePartIdx >= index) {
@ -73,7 +77,7 @@ export default class EditorModel {
} }
serializeParts() { serializeParts() {
return this._parts.map(({type, text}) => {return {type, text};}); return this._parts.map(p => p.serialize());
} }
_diff(newValue, inputType, caret) { _diff(newValue, inputType, caret) {
@ -88,10 +92,10 @@ export default class EditorModel {
update(newValue, inputType, caret) { update(newValue, inputType, caret) {
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);
} }
let addedLen = 0; let addedLen = 0;
if (diff.added) { if (diff.added) {
@ -99,7 +103,7 @@ export default class EditorModel {
} }
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);
newPosition = newPosition.skipUneditableParts(this._parts); newPosition = newPosition.skipUneditableParts(this._parts);
this._setActivePart(newPosition); this._setActivePart(newPosition);
this._updateCallback(newPosition); this._updateCallback(newPosition);
@ -177,7 +181,7 @@ export default class EditorModel {
* @return {Number} how many characters before pos were also removed, * @return {Number} how many characters before pos were also removed,
* usually because of non-editable parts that can only be removed in their entirety. * usually because of non-editable parts that can only be removed in their entirety.
*/ */
_removeText(pos, len) { removeText(pos, len) {
let {index, offset} = pos; let {index, offset} = pos;
let removedOffsetDecrease = 0; let removedOffsetDecrease = 0;
while (len > 0) { while (len > 0) {
@ -248,7 +252,7 @@ export default class EditorModel {
return addLen; return addLen;
} }
_positionForOffset(totalOffset, atPartEnd) { positionForOffset(totalOffset, atPartEnd) {
let currentOffset = 0; let currentOffset = 0;
const index = this._parts.findIndex(part => { const index = this._parts.findIndex(part => {
const partLen = part.text.length; const partLen = part.text.length;

View file

@ -17,7 +17,6 @@ limitations under the License.
import AutocompleteWrapperModel from "./autocomplete"; import AutocompleteWrapperModel from "./autocomplete";
import Avatar from "../Avatar"; import Avatar from "../Avatar";
import MatrixClientPeg from "../MatrixClientPeg";
class BasePart { class BasePart {
constructor(text = "") { constructor(text = "") {
@ -102,6 +101,10 @@ class BasePart {
toString() { toString() {
return `${this.type}(${this.text})`; return `${this.type}(${this.text})`;
} }
serialize() {
return {type: this.type, text: this.text};
}
} }
export class PlainPart extends BasePart { export class PlainPart extends BasePart {
@ -233,13 +236,12 @@ export class NewlinePart extends BasePart {
} }
export class RoomPillPart extends PillPart { export class RoomPillPart extends PillPart {
constructor(displayAlias) { constructor(displayAlias, client) {
super(displayAlias, displayAlias); super(displayAlias, displayAlias);
this._room = this._findRoomByAlias(displayAlias); this._room = this._findRoomByAlias(displayAlias, client);
} }
_findRoomByAlias(alias) { _findRoomByAlias(alias, client) {
const client = MatrixClientPeg.get();
if (alias[0] === '#') { if (alias[0] === '#') {
return client.getRooms().find((r) => { return client.getRooms().find((r) => {
return r.getAliases().includes(alias); return r.getAliases().includes(alias);
@ -300,6 +302,12 @@ export class UserPillPart extends PillPart {
get className() { get className() {
return "mx_UserPill mx_Pill"; return "mx_UserPill mx_Pill";
} }
serialize() {
const obj = super.serialize();
obj.userId = this.resourceId;
return obj;
}
} }
@ -335,13 +343,16 @@ export class PillCandidatePart extends PlainPart {
} }
export class PartCreator { export class PartCreator {
constructor(getAutocompleterComponent, updateQuery, room) { constructor(getAutocompleterComponent, updateQuery, room, client) {
this._room = room;
this._client = client;
this._autoCompleteCreator = (updateCallback) => { this._autoCompleteCreator = (updateCallback) => {
return new AutocompleteWrapperModel( return new AutocompleteWrapperModel(
updateCallback, updateCallback,
getAutocompleterComponent, getAutocompleterComponent,
updateQuery, updateQuery,
room, room,
client,
); );
}; };
} }
@ -362,5 +373,22 @@ export class PartCreator {
createDefaultPart(text) { createDefaultPart(text) {
return new PlainPart(text); return new PlainPart(text);
} }
deserializePart(part) {
switch (part.type) {
case "plain":
return new PlainPart(part.text);
case "newline":
return new NewlinePart(part.text);
case "pill-candidate":
return new PillCandidatePart(part.text, this._autoCompleteCreator);
case "room-pill":
return new RoomPillPart(part.text, this._client);
case "user-pill": {
const member = this._room.getMember(part.userId);
return new UserPillPart(part.userId, part.text, member);
}
}
}
} }

View file

@ -483,6 +483,11 @@
"Email Address": "Email Address", "Email Address": "Email Address",
"Disable Notifications": "Disable Notifications", "Disable Notifications": "Disable Notifications",
"Enable Notifications": "Enable Notifications", "Enable Notifications": "Enable Notifications",
"No integrations server configured": "No integrations server configured",
"This Riot instance does not have an integrations server configured.": "This Riot instance does not have an integrations server configured.",
"Connecting to integrations server...": "Connecting to integrations server...",
"Cannot connect to integrations server": "Cannot connect to integrations server",
"The integrations server is offline or it cannot reach your homeserver.": "The integrations server is offline or it cannot reach your homeserver.",
"Delete Backup": "Delete Backup", "Delete Backup": "Delete Backup",
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.",
"Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.", "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.",
@ -864,6 +869,8 @@
"This Room": "This Room", "This Room": "This Room",
"All Rooms": "All Rooms", "All Rooms": "All Rooms",
"Search…": "Search…", "Search…": "Search…",
"Failed to connect to integrations server": "Failed to connect to integrations server",
"No integrations server is configured to manage stickers with": "No integrations server is configured to manage stickers with",
"You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
"Add some now": "Add some now", "Add some now": "Add some now",
"Stickerpack": "Stickerpack", "Stickerpack": "Stickerpack",
@ -1017,7 +1024,6 @@
"Rotate Right": "Rotate Right", "Rotate Right": "Rotate Right",
"Rotate clockwise": "Rotate clockwise", "Rotate clockwise": "Rotate clockwise",
"Download this file": "Download this file", "Download this file": "Download this file",
"Integrations Error": "Integrations Error",
"Manage Integrations": "Manage Integrations", "Manage Integrations": "Manage Integrations",
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
"%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times", "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times",
@ -1249,6 +1255,7 @@
"Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)", "Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)",
"Upload files": "Upload files", "Upload files": "Upload files",
"Upload": "Upload", "Upload": "Upload",
"Upload all": "Upload all",
"This file is <b>too large</b> to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "This file is <b>too large</b> to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.", "This file is <b>too large</b> to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "This file is <b>too large</b> to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.",
"These files are <b>too large</b> to upload. The file size limit is %(limit)s.": "These files are <b>too large</b> to upload. The file size limit is %(limit)s.", "These files are <b>too large</b> to upload. The file size limit is %(limit)s.": "These files are <b>too large</b> to upload. The file size limit is %(limit)s.",
"Some files are <b>too large</b> to be uploaded. The file size limit is %(limit)s.": "Some files are <b>too large</b> to be uploaded. The file size limit is %(limit)s.", "Some files are <b>too large</b> to be uploaded. The file size limit is %(limit)s.": "Some files are <b>too large</b> to be uploaded. The file size limit is %(limit)s.",
@ -1557,6 +1564,9 @@
"Registration has been disabled on this homeserver.": "Registration has been disabled on this homeserver.", "Registration has been disabled on this homeserver.": "Registration has been disabled on this homeserver.",
"Unable to query for supported registration methods.": "Unable to query for supported registration methods.", "Unable to query for supported registration methods.": "Unable to query for supported registration methods.",
"This server does not support authentication with a phone number.": "This server does not support authentication with a phone number.", "This server does not support authentication with a phone number.": "This server does not support authentication with a phone number.",
"<a>Log in</a> to your new account.": "<a>Log in</a> to your new account.",
"You can now close this window or <a>log in</a> to your new account.": "You can now close this window or <a>log in</a> to your new account.",
"Registration Successful": "Registration Successful",
"Create your account": "Create your account", "Create your account": "Create your account",
"Commands": "Commands", "Commands": "Commands",
"Results from DuckDuckGo": "Results from DuckDuckGo", "Results from DuckDuckGo": "Results from DuckDuckGo",

View file

@ -0,0 +1,49 @@
/*
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.
*/
/**
* Used while editing, to pass the event, and to preserve editor state
* from one editor instance to another when remounting the editor
* upon receiving the remote echo for an unsent event.
*/
export default class EditorStateTransfer {
constructor(event) {
this._event = event;
this._serializedParts = null;
this.caret = null;
}
setEditorState(caret, serializedParts) {
this._caret = caret;
this._serializedParts = serializedParts;
}
hasEditorState() {
return !!this._serializedParts;
}
getSerializedParts() {
return this._serializedParts;
}
getCaret() {
return this._caret;
}
getEvent() {
return this._event;
}
}

View file

@ -46,8 +46,12 @@ export function isContentActionable(mxEvent) {
} }
export function canEditContent(mxEvent) { export function canEditContent(mxEvent) {
return isContentActionable(mxEvent) && if (mxEvent.status === EventStatus.CANCELLED || mxEvent.getType() !== "m.room.message") {
mxEvent.getOriginalContent().msgtype === "m.text" && return false;
}
const content = mxEvent.getOriginalContent();
const {msgtype} = content;
return (msgtype === "m.text" || msgtype === "m.emote") &&
mxEvent.getSender() === MatrixClientPeg.get().getUserId(); mxEvent.getSender() === MatrixClientPeg.get().getUserId();
} }
@ -64,7 +68,7 @@ export function canEditOwnEvent(mxEvent) {
const MAX_JUMP_DISTANCE = 100; const MAX_JUMP_DISTANCE = 100;
export function findEditableEvent(room, isForward, fromEventId = undefined) { export function findEditableEvent(room, isForward, fromEventId = undefined) {
const liveTimeline = room.getLiveTimeline(); const liveTimeline = room.getLiveTimeline();
const events = liveTimeline.getEvents(); const events = liveTimeline.getEvents().concat(room.getPendingEvents());
const maxIdx = events.length - 1; const maxIdx = events.length - 1;
const inc = isForward ? 1 : -1; const inc = isForward ? 1 : -1;
const beginIdx = isForward ? 0 : maxIdx; const beginIdx = isForward ? 0 : maxIdx;

View file

@ -103,12 +103,6 @@ describe('InteractiveAuthDialog', function() {
password: "s3kr3t", password: "s3kr3t",
user: "@user:id", user: "@user:id",
})).toBe(true); })).toBe(true);
// there should now be a spinner
ReactTestUtils.findRenderedComponentWithType(
dlg, sdk.getComponent('elements.Spinner'),
);
// let the request complete // let the request complete
return Promise.delay(1); return Promise.delay(1);
}).then(() => { }).then(() => {