Merge branches 'develop' and 't3chguy/restore_composer_history' of github.com:matrix-org/matrix-react-sdk into t3chguy/restore_composer_history
# Conflicts: # src/components/views/rooms/MessageComposerInput.js
This commit is contained in:
commit
876acc0f76
35 changed files with 362 additions and 253 deletions
|
@ -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";
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
|
@ -42,6 +42,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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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%;
|
|
||||||
}
|
|
||||||
|
|
|
@ -14,31 +14,15 @@ 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 {
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,7 +586,7 @@ 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}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -28,6 +28,7 @@ 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';
|
||||||
|
|
||||||
// Phases
|
// Phases
|
||||||
// Show controls to configure server details
|
// Show controls to configure server details
|
||||||
|
@ -80,6 +81,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
|
||||||
|
@ -209,6 +213,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 +287,27 @@ module.exports = React.createClass({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({
|
const newState = {
|
||||||
// we're still busy until we get unmounted: don't show the registration form again
|
|
||||||
busy: true,
|
|
||||||
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 +364,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 +377,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 +387,7 @@ module.exports = React.createClass({
|
||||||
auth,
|
auth,
|
||||||
bindThreepids,
|
bindThreepids,
|
||||||
null,
|
null,
|
||||||
|
inhibitLogin,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -379,6 +399,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");
|
||||||
|
@ -528,17 +561,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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -101,16 +101,25 @@ 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) {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -178,11 +170,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 +189,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) {
|
||||||
|
|
|
@ -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} />;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -676,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
|
||||||
|
@ -683,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) {
|
||||||
|
@ -1181,45 +1204,36 @@ export default class MessageComposerInput extends React.Component {
|
||||||
};
|
};
|
||||||
|
|
||||||
onVerticalArrow = (e, up) => {
|
onVerticalArrow = (e, up) => {
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey) {
|
if (e.ctrlKey || e.shiftKey || e.metaKey) return;
|
||||||
return;
|
|
||||||
|
// Select history
|
||||||
|
const selection = this.state.editorState.selection;
|
||||||
|
|
||||||
|
// selection must be collapsed
|
||||||
|
if (!selection.isCollapsed) return;
|
||||||
|
const document = this.state.editorState.document;
|
||||||
|
|
||||||
|
// and we must be at the edge of the document (up=start, down=end)
|
||||||
|
if (up) {
|
||||||
|
if (!selection.anchor.isAtStartOfNode(document)) return;
|
||||||
|
|
||||||
|
if (!e.altKey) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select history only if we are not currently auto-completing
|
const selected = this.selectHistory(up);
|
||||||
if (this.autocomplete.state.completionList.length === 0) {
|
if (selected) {
|
||||||
const selection = this.state.editorState.selection;
|
// We're selecting history, so prevent the key event from doing anything else
|
||||||
|
|
||||||
// selection must be collapsed
|
|
||||||
if (!selection.isCollapsed) return;
|
|
||||||
const document = this.state.editorState.document;
|
|
||||||
|
|
||||||
// and we must be at the edge of the document (up=start, down=end)
|
|
||||||
if (up) {
|
|
||||||
if (!selection.anchor.isAtStartOfNode(document)) return;
|
|
||||||
|
|
||||||
if (!e.shiftKey) {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!selection.anchor.isAtEndOfNode(document)) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = this.selectHistory(up);
|
|
||||||
if (selected) {
|
|
||||||
// We're selecting history, so prevent the key event from doing anything else
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.moveAutocompleteSelection(up);
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1277,23 +1291,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) {
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,10 +205,10 @@ 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();
|
||||||
if (content.format === "org.matrix.custom.html") {
|
if (content.format === "org.matrix.custom.html") {
|
||||||
return parseHtmlMessage(content.formatted_body || "", room);
|
return 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");
|
||||||
|
|
|
@ -73,7 +73,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,7 +88,7 @@ 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);
|
||||||
|
@ -99,7 +99,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);
|
||||||
|
@ -248,7 +248,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;
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1557,6 +1557,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",
|
||||||
|
|
49
src/utils/EditorStateTransfer.js
Normal file
49
src/utils/EditorStateTransfer.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -46,7 +46,8 @@ export function isContentActionable(mxEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canEditContent(mxEvent) {
|
export function canEditContent(mxEvent) {
|
||||||
return isContentActionable(mxEvent) &&
|
return mxEvent.status !== EventStatus.CANCELLED &&
|
||||||
|
mxEvent.getType() === 'm.room.message' &&
|
||||||
mxEvent.getOriginalContent().msgtype === "m.text" &&
|
mxEvent.getOriginalContent().msgtype === "m.text" &&
|
||||||
mxEvent.getSender() === MatrixClientPeg.get().getUserId();
|
mxEvent.getSender() === MatrixClientPeg.get().getUserId();
|
||||||
}
|
}
|
||||||
|
@ -64,7 +65,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;
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue