Merge pull request #3097 from matrix-org/bwindels/edit-unsent

Edit unsent messages
This commit is contained in:
Bruno Windels 2019-06-13 14:00:46 +00:00 committed by GitHub
commit 3d944b715e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 181 additions and 61 deletions

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,7 +586,7 @@ 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}

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,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 {
@ -178,11 +170,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 +189,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

@ -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

@ -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

@ -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) {
@ -106,7 +107,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,10 +205,10 @@ function parseHtmlMessage(html, room) {
return parts;
}
export function parseEvent(event, room) {
export function parseEvent(event, room, client) {
const content = event.getContent();
if (content.format === "org.matrix.custom.html") {
return parseHtmlMessage(content.formatted_body || "", room);
return parseHtmlMessage(content.formatted_body || "", room, client);
} else {
const body = content.body || "";
const lines = body.split("\n");

View file

@ -73,7 +73,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,7 +88,7 @@ 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);
@ -99,7 +99,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);
@ -248,7 +248,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

@ -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,7 +46,8 @@ export function isContentActionable(mxEvent) {
}
export function canEditContent(mxEvent) {
return isContentActionable(mxEvent) &&
return mxEvent.status !== EventStatus.CANCELLED &&
mxEvent.getType() === 'm.room.message' &&
mxEvent.getOriginalContent().msgtype === "m.text" &&
mxEvent.getSender() === MatrixClientPeg.get().getUserId();
}
@ -64,7 +65,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(() => {