diff --git a/src/base-apis.js b/src/base-apis.js index a0109b3c1be..6ef0b6ee994 100644 --- a/src/base-apis.js +++ b/src/base-apis.js @@ -887,32 +887,6 @@ MatrixBaseApis.prototype.sendStateEvent = function(roomId, eventType, content, s ); }; -/** - * @param {string} roomId - * @param {string} eventId - * @param {string} [txnId] transaction id. One will be made up if not - * supplied. - * @param {module:client.callback} callback Optional. - * @return {module:client.Promise} Resolves: TODO - * @return {module:http-api.MatrixError} Rejects: with an error response. - */ -MatrixBaseApis.prototype.redactEvent = function( - roomId, eventId, txnId, callback, -) { - if (arguments.length === 3) { - callback = txnId; - } - - const path = utils.encodeUri("/rooms/$roomId/redact/$eventId/$txnId", { - $roomId: roomId, - $eventId: eventId, - $txnId: txnId ? txnId : this.makeTxnId(), - }); - - return this._http.authedRequest(callback, "PUT", path, undefined, {}); -}; - - /** * @param {string} roomId * @param {Number} limit diff --git a/src/client.js b/src/client.js index 6c2049f91bf..b1f2be0f01b 100644 --- a/src/client.js +++ b/src/client.js @@ -1695,6 +1695,21 @@ MatrixClient.prototype.setPowerLevel = function(roomId, userId, powerLevel, */ MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId, callback) { + return this._sendCompleteEvent(roomId, { + type: eventType, + content: content, + }, txnId, callback); +}; +/** + * @param {string} roomId + * @param {object} eventObject An object with the partial structure of an event, to which event_id, user_id, room_id and origin_server_ts will be added. + * @param {string} txnId the txnId. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype._sendCompleteEvent = function(roomId, eventObject, txnId, + callback) { if (utils.isFunction(txnId)) { callback = txnId; txnId = undefined; } @@ -1703,20 +1718,20 @@ MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId, txnId = this.makeTxnId(); } - logger.log(`sendEvent of type ${eventType} in ${roomId} with txnId ${txnId}`); + const localEvent = new MatrixEvent(Object.assign(eventObject, { + event_id: "~" + roomId + ":" + txnId, + user_id: this.credentials.userId, + room_id: roomId, + origin_server_ts: new Date().getTime(), + })); + + const type = localEvent.getType(); + logger.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`); // we always construct a MatrixEvent when sending because the store and // scheduler use them. We'll extract the params back out if it turns out // the client has no scheduler or store. const room = this.getRoom(roomId); - const localEvent = new MatrixEvent({ - event_id: "~" + roomId + ":" + txnId, - user_id: this.credentials.userId, - room_id: roomId, - type: eventType, - origin_server_ts: new Date().getTime(), - content: content, - }); localEvent._txnId = txnId; localEvent.setStatus(EventStatus.SENDING); @@ -1870,6 +1885,9 @@ function _sendEventHttpRequest(client, event) { pathTemplate = "/rooms/$roomId/state/$eventType/$stateKey"; } path = utils.encodeUri(pathTemplate, pathParams); + } else if (event.getType() === "m.room.redaction") { + const pathTemplate = `/rooms/$roomId/redact/${event.event.redacts}/$txnId`; + path = utils.encodeUri(pathTemplate, pathParams); } else { path = utils.encodeUri( "/rooms/$roomId/send/$eventType/$txnId", pathParams, @@ -1886,6 +1904,23 @@ function _sendEventHttpRequest(client, event) { }); } +/** + * @param {string} roomId + * @param {string} eventId + * @param {string} [txnId] transaction id. One will be made up if not + * supplied. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.redactEvent = function(roomId, eventId, txnId, callback) { + return this._sendCompleteEvent(roomId, { + type: "m.room.redaction", + content: {}, + redacts: eventId, + }, txnId, callback); +}; + /** * @param {string} roomId * @param {Object} content diff --git a/src/models/event.js b/src/models/event.js index 83325515440..e16d49b4dbc 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -124,6 +124,7 @@ module.exports.MatrixEvent = function MatrixEvent( this.forwardLooking = true; this._pushActions = null; this._replacingEvent = null; + this._locallyRedacted = false; this._clearEvent = {}; @@ -228,6 +229,9 @@ utils.extend(module.exports.MatrixEvent.prototype, { * @return {Object} The event content JSON, or an empty object. */ getOriginalContent: function() { + if (this._locallyRedacted) { + return {}; + } return this._clearEvent.content || this.event.content || {}; }, @@ -239,7 +243,9 @@ utils.extend(module.exports.MatrixEvent.prototype, { * @return {Object} The event content JSON, or an empty object. */ getContent: function() { - if (this._replacingEvent) { + if (this._locallyRedacted) { + return {}; + } else if (this._replacingEvent) { return this._replacingEvent.getContent()["m.new_content"] || {}; } else { return this.getOriginalContent(); @@ -666,6 +672,27 @@ utils.extend(module.exports.MatrixEvent.prototype, { return this.event.unsigned || {}; }, + unmarkLocallyRedacted: function() { + const value = this._locallyRedacted; + this._locallyRedacted = false; + if (this.event.unsigned) { + this.event.unsigned.redacted_because = null; + } + return value; + }, + + markLocallyRedacted: function(redactionEvent) { + if (this._locallyRedacted) { + return; + } + this.emit("Event.beforeRedaction", this, redactionEvent); + this._locallyRedacted = true; + if (!this.event.unsigned) { + this.event.unsigned = {}; + } + this.event.unsigned.redacted_because = redactionEvent.event; + }, + /** * Update the content of an event in the same way it would be by the server * if it were redacted before it was sent to us @@ -679,6 +706,8 @@ utils.extend(module.exports.MatrixEvent.prototype, { throw new Error("invalid redaction_event in makeRedacted"); } + this._locallyRedacted = false; + this.emit("Event.beforeRedaction", this, redaction_event); this._replacingEvent = null; diff --git a/src/models/room.js b/src/models/room.js index ec3ffb764d8..61c451f1678 100644 --- a/src/models/room.js +++ b/src/models/room.js @@ -1111,17 +1111,15 @@ Room.prototype.addPendingEvent = function(event, txnId) { // For pending events, add them to the relations collection immediately. // (The alternate case below already covers this as part of adding to // the timeline set.) - // TODO: We should consider whether this means it would be a better - // design to lift the relations handling up to the room instead. - for (let i = 0; i < this._timelineSets.length; i++) { - const timelineSet = this._timelineSets[i]; - if (timelineSet.getFilter()) { - if (this._filter.filterRoomTimeline([event]).length) { - timelineSet.aggregateRelations(event); - } - } else { - timelineSet.aggregateRelations(event); - } + this._aggregateNonLiveRelation(event); + } + + if (event.getType() === "m.room.redaction") { + const redactId = event.event.redacts; + const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); + if (redactedEvent) { + redactedEvent.markLocallyRedacted(event); + this.emit("Room.redaction", event, this); } } } else { @@ -1141,6 +1139,30 @@ Room.prototype.addPendingEvent = function(event, txnId) { this.emit("Room.localEchoUpdated", event, this, null, null); }; +/** + * Used to aggregate the local echo for a relation, and also + * for re-applying a relation after it's redaction has been cancelled, + * as the local echo for the redaction of the relation would have + * un-aggregated the relation. Note that this is different from regular messages, + * which are just kept detached for their local echo. + * + * Also note that live events are aggregated in the live EventTimelineSet. + * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. + */ +Room.prototype._aggregateNonLiveRelation = function(event) { + // TODO: We should consider whether this means it would be a better + // design to lift the relations handling up to the room instead. + for (let i = 0; i < this._timelineSets.length; i++) { + const timelineSet = this._timelineSets[i]; + if (timelineSet.getFilter()) { + if (this._filter.filterRoomTimeline([event]).length) { + timelineSet.aggregateRelations(event); + } + } else { + timelineSet.aggregateRelations(event); + } + } +}; /** * Deal with the echo of a message we sent. @@ -1277,12 +1299,13 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { } else if (newStatus == EventStatus.CANCELLED) { // remove it from the pending event list, or the timeline. if (this._pendingEventList) { - utils.removeElement( - this._pendingEventList, - function(ev) { - return ev.getId() == oldEventId; - }, false, - ); + const idx = this._pendingEventList.findIndex(ev => ev.getId() === oldEventId); + if (idx !== -1) { + const [removedEvent] = this._pendingEventList.splice(idx, 1); + if (removedEvent.getType() === "m.room.redaction") { + this._revertRedactionLocalEcho(removedEvent); + } + } } this.removeEvent(oldEventId); } @@ -1290,6 +1313,23 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus); }; +Room.prototype._revertRedactionLocalEcho = function(redactionEvent) { + const redactId = redactionEvent.event.redacts; + if (!redactId) { + return; + } + const redactedEvent = this.getUnfilteredTimelineSet() + .findEventById(redactId); + if (redactedEvent) { + redactedEvent.unmarkLocallyRedacted(); + // re-render after undoing redaction + this.emit("Room.redactionCancelled", redactionEvent, this); + // reapply relation now redaction failed + if (redactedEvent.isRelation()) { + this._aggregateNonLiveRelation(redactedEvent); + } + } +}; /** * Add some events to this room. This can include state events, message @@ -1368,6 +1408,9 @@ Room.prototype.removeEvent = function(eventId) { for (let i = 0; i < this._timelineSets.length; i++) { const removed = this._timelineSets[i].removeEvent(eventId); if (removed) { + if (removed.getType() === "m.room.redaction") { + this._revertRedactionLocalEcho(removed); + } removedAny = true; } } @@ -1791,10 +1834,21 @@ module.exports = Room; * event). * * @event module:client~MatrixClient#"Room.redaction" - * @param {MatrixEvent} event The matrix event which was redacted + * @param {MatrixEvent} event The matrix redaction event * @param {Room} room The room containing the redacted event */ +/** + * Fires when an event that was previously redacted isn't anymore. + * This happens when the redaction couldn't be sent and + * was subsequently cancelled by the user. Redactions have a local echo + * which is undone in this scenario. + * + * @event module:client~MatrixClient#"Room.redactionCancelled" + * @param {MatrixEvent} event The matrix redaction event that was cancelled. + * @param {Room} room The room containing the unredacted event + */ + /** * Fires whenever the name of a room is updated. * @event module:client~MatrixClient#"Room.name" diff --git a/src/sync.js b/src/sync.js index 22f63d6bb51..7d9e2e3ff0b 100644 --- a/src/sync.js +++ b/src/sync.js @@ -127,7 +127,9 @@ SyncApi.prototype.createRoom = function(roomId) { timelineSupport, unstableClientRelationAggregation, }); - client.reEmitter.reEmit(room, ["Room.name", "Room.timeline", "Room.redaction", + client.reEmitter.reEmit(room, ["Room.name", "Room.timeline", + "Room.redaction", + "Room.redactionCancelled", "Room.receipt", "Room.tags", "Room.timelineReset", "Room.localEchoUpdated",