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/ChangePassword.js
src/components/views/settings/DevicesPanel.js
src/components/views/settings/IntegrationsManager.js
src/components/views/settings/Notifications.js
src/GroupAddressPicker.js
src/HtmlUtils.js

View file

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

View file

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

View file

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

View file

@ -20,7 +20,6 @@ limitations under the License.
}
.mx_ServerConfig_fields .mx_Field {
flex: 1;
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;
}
.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;
width: 100%;
}
@ -75,7 +79,6 @@ limitations under the License.
max-width: 684px;
min-height: 250px;
padding: 10px;
width: 100%;
}
.mx_DevTools_content .mx_Field_input {

View file

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

View file

@ -42,12 +42,6 @@ limitations under the License.
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 {
margin-bottom: 5px;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -35,9 +35,3 @@ limitations under the License.
.mx_ExistingEmailAddress_confirmBtn {
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%;
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;
}
.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 {
display: flex;
align-items: center;

View file

@ -22,11 +22,6 @@ limitations under the License.
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 {
height: 4em;
}

View file

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

View file

@ -14,31 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_GeneralUserSettingsTab_changePassword,
.mx_GeneralUserSettingsTab_themeSection {
display: block;
}
.mx_GeneralUserSettingsTab_changePassword .mx_Field,
.mx_GeneralUserSettingsTab_themeSection .mx_Field {
display: block;
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 {
margin-top: 0;
}
.mx_GeneralUserSettingsTab_themeSection .mx_Field select {
display: block;
width: 100%;
}
.mx_GeneralUserSettingsTab_accountSection > .mx_EmailAddresses,
.mx_GeneralUserSettingsTab_accountSection > .mx_PhoneNumbers,
.mx_GeneralUserSettingsTab_languageInput {

View file

@ -17,11 +17,3 @@ limitations under the License.
.mx_PreferencesUserSettingsTab .mx_Field {
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.
*/
.mx_VoiceUserSettingsTab .mx_Field select {
width: 100%;
max-width: 100%;
}
.mx_VoiceUserSettingsTab .mx_Field {
margin-right: 100px; // align with the rest of the fields
}

View file

@ -344,7 +344,7 @@ function _onAction(payload) {
}
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
// work for us. Better that the user knows before everyone else in the
// 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");
let uploadAll = false;
for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i];
if (!uploadAll) {
const shouldContinue = await new Promise((resolve) => {
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
file,
currentIndex: i,
totalFiles: okFiles.length,
onFinished: (shouldContinue) => {
onFinished: (shouldContinue, shouldUploadAll) => {
if (shouldUploadAll) {
uploadAll = true;
}
resolve(shouldContinue);
},
});
});
if (!shouldContinue) break;
}
this._sendContentToRoom(file, roomId, matrixClient);
}
}

View file

@ -17,9 +17,12 @@ limitations under the License.
import URL from 'url';
import dis from './dispatcher';
import IntegrationManager from './IntegrationManager';
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
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 SUPPORTED_WIDGET_API_VERSIONS = [
@ -189,7 +192,14 @@ export default class FromWidgetPostMessageApi {
const data = event.data.data || event.data.widgetData;
const integType = (data && data.integType) ? data.integType : 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') {
// This is a new message: there is no reason to support the deprecated widgetData here
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,
}, true).then(() => true);
}, (err) => {
console.error("Failed to register as guest: " + err + " " + err.data);
console.error("Failed to register as guest", err);
return false;
});
}

View file

@ -51,6 +51,7 @@ interface MatrixClientCreds {
class MatrixClientPeg {
constructor() {
this.matrixClient = null;
this._justRegisteredUserId = null;
// These are the default options used when when the
// client is started in 'start'. These can be altered
@ -85,6 +86,31 @@ class MatrixClientPeg {
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
* homeserver / identity server URLs and active credentials

View file

@ -29,6 +29,14 @@ class ScalarAuthClient {
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() {
return this.getScalarToken().then((tok) => {
this.scalarToken = tok;
@ -41,7 +49,8 @@ class ScalarAuthClient {
// Returns a scalar_token string
getScalarToken() {
const token = window.localStorage.getItem("mx_scalar_token");
let token = this.scalarToken;
if (!token) token = window.localStorage.getItem("mx_scalar_token");
if (!token) {
return this.registerForToken();

View file

@ -292,16 +292,6 @@ const LoggedInView = React.createClass({
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
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_DOWN:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {

View file

@ -52,6 +52,8 @@ import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
import { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
import DMRoomMap from '../../utils/DMRoomMap';
// Disable warnings for now: we use deprecated bluebird functions
// 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 = {
view: VIEWS.REGISTER,
};
@ -689,10 +691,12 @@ export default React.createClass({
params.is_url &&
params.sid
) {
newState.serverConfig = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
params.hs_url, params.is_url,
);
newState.register_client_secret = params.client_secret;
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;
}
@ -884,6 +888,7 @@ export default React.createClass({
}
return;
}
MatrixClientPeg.setJustRegisteredUserId(credentials.user_id);
this.onRegistered(credentials);
},
onDifferentServerClicked: (ev) => {
@ -1129,28 +1134,80 @@ export default React.createClass({
},
/**
* Called when a new logged in session has started
* 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
*/
_onLoggedIn: async function() {
this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (this._is_registered) {
this._is_registered = false;
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;
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
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
});
// if successful, return because we're already
// viewing the welcomeUserId room
// else, if failed, fall through to view_home_page
if (roomId) {
return;
// 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;
}
// The user has just logged in after registering
return null;
},
/**
* Called when a new logged in session has started
*/
_onLoggedIn: async function() {
this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (MatrixClientPeg.currentUserIsJustRegistered()) {
MatrixClientPeg.setJustRegisteredUserId(null);
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
const welcomeUserRoom = await this._startWelcomeUserChat();
if (welcomeUserRoom === null) {
// We didn't rediret to the welcome user room, so show
// the homepage.
dis.dispatch({action: 'view_home_page'});
}
} else {
// The user has just logged in after registering,
// so show the homepage.
dis.dispatch({action: 'view_home_page'});
}
} else {
this._showScreenAfterLogin();
}
@ -1691,9 +1748,6 @@ export default React.createClass({
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);
},

View file

@ -517,7 +517,8 @@ module.exports = React.createClass({
const DateSeparator = sdk.getComponent('messages.DateSeparator');
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?
let continuation = false;
@ -585,13 +586,13 @@ module.exports = React.createClass({
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
isEditing={isEditing}
editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.replacementOrOwnStatus()}
eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}

View file

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

View file

@ -28,6 +28,8 @@ import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import * as ServerType from '../../views/auth/ServerTypeSelector';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
import * as Lifecycle from '../../../Lifecycle';
import MatrixClientPeg from "../../../MatrixClientPeg";
// Phases
// Show controls to configure server details
@ -80,6 +82,9 @@ module.exports = React.createClass({
// Phase of the overall registration dialog.
phase: PHASE_REGISTRATION,
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 also track the server dead errors independently of the regular errors so
@ -163,6 +168,8 @@ module.exports = React.createClass({
_replaceClient: async function(serverConfig) {
this.setState({
errorText: null,
serverDeadError: null,
serverErrorIsFatal: false,
// 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)
busy: true,
@ -175,7 +182,10 @@ module.exports = React.createClass({
serverConfig.hsUrl,
serverConfig.isUrl,
);
this.setState({serverIsAlive: true});
this.setState({
serverIsAlive: true,
serverErrorIsFatal: false,
});
} catch (e) {
this.setState({
busy: false,
@ -209,6 +219,7 @@ module.exports = React.createClass({
errorText: _t("Registration has been disabled on this homeserver."),
});
} else {
console.log("Unable to query for supported registration methods.", e);
this.setState({
errorText: _t("Unable to query for supported registration methods."),
});
@ -282,12 +293,12 @@ module.exports = React.createClass({
return;
}
this.setState({
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
doingUIAuth: false,
});
MatrixClientPeg.setJustRegisteredUserId(response.user_id);
const newState = {
doingUIAuth: false,
};
if (response.access_token) {
const cli = await this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
@ -297,6 +308,14 @@ module.exports = React.createClass({
});
this._setupPushers(cli);
// we're still busy until we get unmounted: don't show the registration form again
newState.busy = true;
} else {
newState.busy = false;
newState.completedNoSignin = true;
}
this.setState(newState);
},
_setupPushers: function(matrixClient) {
@ -353,6 +372,12 @@ module.exports = React.createClass({
},
_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
// (Since we need to send no params at all to use the ones saved in the
// session).
@ -360,6 +385,8 @@ module.exports = React.createClass({
email: true,
msisdn: true,
} : {};
// Likewise inhibitLogin
if (!this.state.formVals.password) inhibitLogin = null;
return this.state.matrixClient.register(
this.state.formVals.username,
@ -368,6 +395,7 @@ module.exports = React.createClass({
auth,
bindThreepids,
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() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
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,
// 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>
<ServerTypeSelector
selected={this.state.serverType}
@ -528,10 +571,34 @@ module.exports = React.createClass({
</a>;
}
return (
<AuthPage>
<AuthHeader />
<AuthBody>
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 }
@ -539,6 +606,14 @@ module.exports = React.createClass({
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }
</div>;
}
return (
<AuthPage>
<AuthHeader />
<AuthBody>
{ body }
</AuthBody>
</AuthPage>
);

View file

@ -101,6 +101,17 @@ export default class ServerConfig extends React.PureComponent {
return result;
} catch (e) {
console.error(e);
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;
@ -113,6 +124,7 @@ export default class ServerConfig extends React.PureComponent {
return null;
}
}
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {

View file

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

View file

@ -240,19 +240,13 @@ export default class AppTile extends React.Component {
if (this.props.onEditClick) {
this.props.onEditClick();
} else {
// The dialog handles scalar auth for us
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
this._scalarClient.connect().done(() => {
const src = this._scalarClient.getScalarInterfaceUrlForRoom(
this.props.room, 'type_' + this.props.type, this.props.id);
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
room: this.props.room,
screen: 'type_' + this.props.type,
integrationId: this.props.id,
}, "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 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.
@ -17,95 +18,34 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import classNames from 'classnames';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import Modal from "../../../Modal";
import { _t } from '../../../languageHandler';
import AccessibleButton from './AccessibleButton';
export default class ManageIntegsButton extends React.Component {
constructor(props) {
super(props);
this.state = {
scalarError: null,
};
this.onManageIntegrations = this.onManageIntegrations.bind(this);
}
componentWillMount() {
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) {
onManageIntegrations = (ev) => {
ev.preventDefault();
if (this.state.scalarError && !this.scalarClient.hasCredentials()) {
return;
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
this.scalarClient.connect().done(() => {
Modal.createDialog(IntegrationsManager, {
src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room) :
null,
room: this.props.room,
}, "mx_IntegrationsManager");
}, (err) => {
this.setState({scalarError: err});
console.error('Error ensuring a valid scalar_token exists', err);
});
}
};
render() {
let integrationsButton = <div />;
let integrationsWarningTriangle = <div />;
let integrationsErrorPopup = <div />;
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>
);
}
if (ScalarAuthClient.isPossible()) {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
integrationsButton = (
<AccessibleButton className={integrationsButtonClasses}
<AccessibleButton
className='mx_RoomHeader_button mx_RoomHeader_manageIntegsButton'
title={_t("Manage Integrations")}
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 {PartCreator} from '../../../editor/parts';
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';
export default class MessageEditor extends React.Component {
static propTypes = {
// the message event being edited
event: PropTypes.instanceOf(MatrixEvent).isRequired,
editState: PropTypes.instanceOf(EditorStateTransfer).isRequired,
};
static contextTypes = {
@ -44,16 +45,7 @@ export default class MessageEditor extends React.Component {
constructor(props, context) {
super(props, context);
const room = this._getRoom();
const partCreator = new PartCreator(
() => this._autocompleteRef,
query => this.setState({query}),
room,
);
this.model = new EditorModel(
parseEvent(this.props.event, room),
partCreator,
this._updateEditorState,
);
this.model = null;
this.state = {
autoComplete: null,
room,
@ -64,7 +56,7 @@ export default class MessageEditor extends React.Component {
}
_getRoom() {
return this.context.matrixClient.getRoom(this.props.event.getRoomId());
return this.context.matrixClient.getRoom(this.props.editState.getEvent().getRoomId());
}
_updateEditorState = (caret) => {
@ -133,7 +125,7 @@ export default class MessageEditor extends React.Component {
if (this._hasModifications || !this._isCaretAtStart()) {
return;
}
const previousEvent = findEditableEvent(this._getRoom(), false, this.props.event.getId());
const previousEvent = findEditableEvent(this._getRoom(), false, this.props.editState.getEvent().getId());
if (previousEvent) {
dis.dispatch({action: 'edit_event', event: previousEvent});
event.preventDefault();
@ -142,7 +134,7 @@ export default class MessageEditor extends React.Component {
if (this._hasModifications || !this._isCaretAtEnd()) {
return;
}
const nextEvent = findEditableEvent(this._getRoom(), true, this.props.event.getId());
const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId());
if (nextEvent) {
dis.dispatch({action: 'edit_event', event: nextEvent});
} else {
@ -158,16 +150,28 @@ export default class MessageEditor extends React.Component {
dis.dispatch({action: 'focus_composer'});
}
_isEmote() {
const firstPart = this.model.parts[0];
return firstPart && firstPart.type === "plain" && firstPart.text.startsWith("/me ");
}
_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 = {
"msgtype": "m.text",
"body": textSerialize(this.model),
"msgtype": isEmote ? "m.emote" : "m.text",
"body": textSerialize(model),
};
const contentBody = {
msgtype: newContent.msgtype,
body: ` * ${newContent.body}`,
};
const formattedBody = htmlSerializeIfNeeded(this.model);
const formattedBody = htmlSerializeIfNeeded(model);
if (formattedBody) {
newContent.format = "org.matrix.custom.html";
newContent.formatted_body = formattedBody;
@ -178,11 +182,11 @@ export default class MessageEditor extends React.Component {
"m.new_content": newContent,
"m.relates_to": {
"rel_type": "m.replace",
"event_id": this.props.event.getId(),
"event_id": this.props.editState.getEvent().getId(),
},
}, contentBody);
const roomId = this.props.event.getRoomId();
const roomId = this.props.editState.getEvent().getRoomId();
this.context.matrixClient.sendMessage(roomId, content);
dis.dispatch({action: "edit_event", event: null});
@ -197,12 +201,63 @@ export default class MessageEditor extends React.Component {
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() {
this.model = this._createEditorModel();
// initial render of model
this._updateEditorState();
setCaretPosition(this._editorRef, this.model, this.model.getPositionAtEnd());
// initial caret position
this._initializeCaret();
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() {
let autoComplete;
if (this.state.autoComplete) {

View file

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

View file

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

View file

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

View file

@ -24,8 +24,6 @@ import AppTile from '../elements/AppTile';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../utils/WidgetUtils';
@ -63,20 +61,6 @@ module.exports = React.createClass({
},
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);
},
@ -144,16 +128,10 @@ module.exports = React.createClass({
_launchManageIntegrations: function() {
const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager');
this.scalarClient.connect().done(() => {
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room, 'add_integ') :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
room: this.props.room,
screen: 'add_integ',
}, 'mx_IntegrationsManager');
}, (err) => {
console.error('Error ensuring a valid scalar_token exists', err);
});
},
onClickAddWidget: function(e) {

View file

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

View file

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

View file

@ -60,6 +60,7 @@ import ReplyThread from "../elements/ReplyThread";
import {ContentHelpers} from 'matrix-js-sdk';
import AccessibleButton from '../elements/AccessibleButton';
import {findEditableEvent} from '../../../utils/EventUtils';
import ComposerHistoryManager from "../../../ComposerHistoryManager";
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
@ -140,6 +141,7 @@ export default class MessageComposerInput extends React.Component {
client: MatrixClient;
autocomplete: Autocomplete;
historyManager: ComposerHistoryManager;
constructor(props, context) {
super(props, context);
@ -329,6 +331,7 @@ export default class MessageComposerInput extends React.Component {
componentWillMount() {
this.dispatcherRef = dis.register(this.onAction);
this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_');
}
componentWillUnmount() {
@ -673,6 +676,31 @@ export default class MessageComposerInput extends React.Component {
onKeyDown = (ev: KeyboardEvent, change: Change, editor: Editor) => {
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
// https://github.com/ianstormtaylor/slate/issues/762#issuecomment-304855095
@ -680,8 +708,6 @@ export default class MessageComposerInput extends React.Component {
this.direction = 'Previous';
} else if (ev.keyCode === KeyCode.RIGHT) {
this.direction = 'Next';
} else {
this.direction = '';
}
switch (ev.keyCode) {
@ -1039,6 +1065,7 @@ export default class MessageComposerInput extends React.Component {
if (cmd) {
if (!cmd.error) {
this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown');
this.setState({
editorState: this.createEditorState(),
}, ()=>{
@ -1116,6 +1143,8 @@ export default class MessageComposerInput extends React.Component {
let sendHtmlFn = ContentHelpers.makeHtmlMessage;
let sendTextFn = ContentHelpers.makeTextMessage;
this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown');
if (commandText && commandText.startsWith('/me')) {
if (replyingToEv) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -1175,22 +1204,31 @@ export default class MessageComposerInput extends React.Component {
};
onVerticalArrow = (e, up) => {
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
return;
}
// Select history only if we are not currently auto-completing
if (this.autocomplete.state.completionList.length === 0) {
const selection = this.state.editorState.selection;
if (e.ctrlKey || e.shiftKey || e.metaKey) return;
// selection must be collapsed
const selection = this.state.editorState.selection;
if (!selection.isCollapsed) return;
const document = this.state.editorState.document;
// 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
@ -1201,10 +1239,54 @@ export default class MessageComposerInput extends React.Component {
});
}
}
} else {
this.moveAutocompleteSelection(up);
e.preventDefault();
};
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;
}
let editorState;
const historyItem = this.historyManager.getItem(delta);
if (!historyItem) return;
if (historyItem.format === 'rich' && !this.state.isRichTextEnabled) {
editorState = this.richToMdEditorState(historyItem.value);
} else if (historyItem.format === 'markdown' && this.state.isRichTextEnabled) {
editorState = this.mdToRichEditorState(historyItem.value);
} else {
editorState = historyItem.value;
}
// 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) => {
@ -1212,23 +1294,19 @@ export default class MessageComposerInput extends React.Component {
someCompletions: null,
});
e.preventDefault();
if (this.autocomplete.state.completionList.length === 0) {
if (this.autocomplete.countCompletions() === 0) {
// Force completions to show for the text currently entered
const completionCount = await this.autocomplete.forceComplete();
this.setState({
someCompletions: completionCount > 0,
});
// Select the first item by moving "down"
await this.moveAutocompleteSelection(false);
await this.autocomplete.moveSelection(+1);
} 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) => {
e.preventDefault();
if (this.autocomplete) {

View file

@ -14,12 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import {_t, _td} from '../../../languageHandler';
import AppTile from '../elements/AppTile';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
@ -53,6 +52,9 @@ export default class Stickerpicker extends React.Component {
this.popoverWidth = 300;
this.popoverHeight = 300;
// This is loaded by _acquireScalarClient on an as-needed basis.
this.scalarClient = null;
this.state = {
showStickers: false,
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');
if (this.state.widgetId) {
this.scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => {
if (scalarClient) {
scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => {
console.warn('Assets disabled');
}).catch((err) => {
console.error('Failed to disable assets');
});
} else {
console.error("Cannot disable assets: no scalar client");
}
} else {
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
window.addEventListener('resize', this._onResize);
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) => {
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
MatrixClientPeg.get().on('accountData', this._updateWidget);
@ -126,7 +136,7 @@ export default class Stickerpicker extends React.Component {
console.error(errorMsg, e);
this.setState({
showStickers: false,
imError: errorMsg,
imError: _t(errorMsg),
});
}
@ -339,22 +349,13 @@ export default class Stickerpicker extends React.Component {
*/
_launchManageIntegrations() {
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
this.scalarClient.connect().done(() => {
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(
this.props.room,
'type_' + widgetType,
this.state.widgetId,
) :
null;
// The integrations manager will handle scalar auth for us.
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
room: this.props.room,
screen: `type_${widgetType}`,
integrationId: this.state.widgetId,
}, "mx_IntegrationsManager");
this.setState({showStickers: false});
}, (err) => {
this.setState({imError: err});
console.error('Error ensuring a valid scalar_token exists', err);
});
}
render() {

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,50 +15,124 @@ See the License for the specific language governing permissions and
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');
const sdk = require('../../../index');
const MatrixClientPeg = require('../../../MatrixClientPeg');
const dis = require('../../../dispatcher');
export default class IntegrationsManager extends React.Component {
static propTypes = {
// the room object where the integrations manager should be opened in
room: PropTypes.object.isRequired,
module.exports = React.createClass({
displayName: 'IntegrationsManager',
// the screen name to open
screen: PropTypes.string,
propTypes: {
src: React.PropTypes.string.isRequired, // the source of the integration manager being embedded
onFinished: React.PropTypes.func.isRequired, // callback when the lightbox is dismissed
},
// the integration ID to open
integrationId: PropTypes.string,
// XXX: keyboard shortcuts for managing dialogs should be done by the modal
// dialog base class somehow, surely...
componentDidMount: function() {
// callback when the manager is dismissed
onFinished: PropTypes.func.isRequired,
};
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);
document.addEventListener("keydown", this.onKeyDown);
},
}
componentWillUnmount: function() {
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
document.removeEventListener("keydown", this.onKeyDown);
},
}
onKeyDown: function(ev) {
if (ev.keyCode == 27) { // escape
onKeyDown = (ev) => {
if (ev.keyCode === 27) { // escape
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
}
},
};
onAction: function(payload) {
onAction = (payload) => {
if (payload.action === 'close_scalar') {
this.props.onFinished();
}
},
};
render: function() {
render() {
if (!this.state.configured) {
return (
<iframe src={ this.props.src }></iframe>
<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 {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 {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
* action was aborted or failed.
*/
function createRoom(opts) {
opts = opts || {};
if (opts.spinner === undefined) opts.spinner = true;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
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;
return client.createRoom(createOpts).finally(function() {
modal.close();
if (modal) modal.close();
}).then(function(res) {
roomId = res.room_id;
if (opts.dmUserId) {

View file

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

View file

@ -21,7 +21,7 @@ import { walkDOMDepthFirst } from "./dom";
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
function parseLink(a, room) {
function parseLink(a, room, client) {
const {href} = a;
const pillMatch = REGEX_MATRIXTO.exec(href) || [];
const resourceId = pillMatch[1]; // The room/user ID
@ -34,7 +34,7 @@ function parseLink(a, room) {
room.getMember(resourceId),
);
case "#":
return new RoomPillPart(resourceId);
return new RoomPillPart(resourceId, client);
default: {
if (href === a.textContent) {
return new PlainPart(a.textContent);
@ -57,10 +57,10 @@ function parseCodeBlock(n) {
return parts;
}
function parseElement(n, room) {
function parseElement(n, room, client) {
switch (n.nodeName) {
case "A":
return parseLink(n, room);
return parseLink(n, room, client);
case "BR":
return new NewlinePart("\n");
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,
// as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine
@ -165,7 +165,7 @@ function parseHtmlMessage(html, room) {
if (n.nodeType === Node.TEXT_NODE) {
newParts.push(new PlainPart(n.nodeValue));
} else if (n.nodeType === Node.ELEMENT_NODE) {
const parseResult = parseElement(n, room);
const parseResult = parseElement(n, room, client);
if (parseResult) {
if (Array.isArray(parseResult)) {
newParts.push(...parseResult);
@ -205,14 +205,15 @@ function parseHtmlMessage(html, room) {
return parts;
}
export function parseEvent(event, room) {
export function parseEvent(event, room, client) {
const content = event.getContent();
let parts;
if (content.format === "org.matrix.custom.html") {
return parseHtmlMessage(content.formatted_body || "", room);
parts = parseHtmlMessage(content.formatted_body || "", room, client);
} else {
const body = content.body || "";
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 text = new PlainPart(line);
const newLine = !isLast && new NewlinePart("\n");
@ -222,6 +223,9 @@ export function parseEvent(event, room) {
return parts.concat(text);
}
}, []);
}
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;
}
clone() {
return new EditorModel(this._parts, this._partCreator, this._updateCallback);
}
_insertPart(index, part) {
this._parts.splice(index, 0, part);
if (this._activePartIdx >= index) {
@ -73,7 +77,7 @@ export default class EditorModel {
}
serializeParts() {
return this._parts.map(({type, text}) => {return {type, text};});
return this._parts.map(p => p.serialize());
}
_diff(newValue, inputType, caret) {
@ -88,10 +92,10 @@ export default class EditorModel {
update(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;
if (diff.removed) {
removedOffsetDecrease = this._removeText(position, diff.removed.length);
removedOffsetDecrease = this.removeText(position, diff.removed.length);
}
let addedLen = 0;
if (diff.added) {
@ -99,7 +103,7 @@ export default class EditorModel {
}
this._mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
let newPosition = this._positionForOffset(caretOffset, true);
let newPosition = this.positionForOffset(caretOffset, true);
newPosition = newPosition.skipUneditableParts(this._parts);
this._setActivePart(newPosition);
this._updateCallback(newPosition);
@ -177,7 +181,7 @@ export default class EditorModel {
* @return {Number} how many characters before pos were also removed,
* 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 removedOffsetDecrease = 0;
while (len > 0) {
@ -248,7 +252,7 @@ export default class EditorModel {
return addLen;
}
_positionForOffset(totalOffset, atPartEnd) {
positionForOffset(totalOffset, atPartEnd) {
let currentOffset = 0;
const index = this._parts.findIndex(part => {
const partLen = part.text.length;

View file

@ -17,7 +17,6 @@ limitations under the License.
import AutocompleteWrapperModel from "./autocomplete";
import Avatar from "../Avatar";
import MatrixClientPeg from "../MatrixClientPeg";
class BasePart {
constructor(text = "") {
@ -102,6 +101,10 @@ class BasePart {
toString() {
return `${this.type}(${this.text})`;
}
serialize() {
return {type: this.type, text: this.text};
}
}
export class PlainPart extends BasePart {
@ -233,13 +236,12 @@ export class NewlinePart extends BasePart {
}
export class RoomPillPart extends PillPart {
constructor(displayAlias) {
constructor(displayAlias, client) {
super(displayAlias, displayAlias);
this._room = this._findRoomByAlias(displayAlias);
this._room = this._findRoomByAlias(displayAlias, client);
}
_findRoomByAlias(alias) {
const client = MatrixClientPeg.get();
_findRoomByAlias(alias, client) {
if (alias[0] === '#') {
return client.getRooms().find((r) => {
return r.getAliases().includes(alias);
@ -300,6 +302,12 @@ export class UserPillPart extends PillPart {
get className() {
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 {
constructor(getAutocompleterComponent, updateQuery, room) {
constructor(getAutocompleterComponent, updateQuery, room, client) {
this._room = room;
this._client = client;
this._autoCompleteCreator = (updateCallback) => {
return new AutocompleteWrapperModel(
updateCallback,
getAutocompleterComponent,
updateQuery,
room,
client,
);
};
}
@ -362,5 +373,22 @@ export class PartCreator {
createDefaultPart(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",
"Disable Notifications": "Disable 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",
"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.",
@ -864,6 +869,8 @@
"This Room": "This Room",
"All Rooms": "All Rooms",
"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",
"Add some now": "Add some now",
"Stickerpack": "Stickerpack",
@ -1017,7 +1024,6 @@
"Rotate Right": "Rotate Right",
"Rotate clockwise": "Rotate clockwise",
"Download this file": "Download this file",
"Integrations Error": "Integrations Error",
"Manage Integrations": "Manage Integrations",
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
"%(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": "Upload files",
"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.",
"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.",
@ -1557,6 +1564,9 @@
"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.",
"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",
"Commands": "Commands",
"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) {
return isContentActionable(mxEvent) &&
mxEvent.getOriginalContent().msgtype === "m.text" &&
if (mxEvent.status === EventStatus.CANCELLED || mxEvent.getType() !== "m.room.message") {
return false;
}
const content = mxEvent.getOriginalContent();
const {msgtype} = content;
return (msgtype === "m.text" || msgtype === "m.emote") &&
mxEvent.getSender() === MatrixClientPeg.get().getUserId();
}
@ -64,7 +68,7 @@ export function canEditOwnEvent(mxEvent) {
const MAX_JUMP_DISTANCE = 100;
export function findEditableEvent(room, isForward, fromEventId = undefined) {
const liveTimeline = room.getLiveTimeline();
const events = liveTimeline.getEvents();
const events = liveTimeline.getEvents().concat(room.getPendingEvents());
const maxIdx = events.length - 1;
const inc = isForward ? 1 : -1;
const beginIdx = isForward ? 0 : maxIdx;

View file

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