From fdcafaf6d39d89f7b037d37d35623e73f77d6cd7 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Mon, 19 Jul 2021 16:10:35 -0700 Subject: [PATCH 01/54] Add _replyTo field to ComposerViewModel that can be set from a message --- src/domain/session/room/RoomViewModel.js | 19 +++++++++++++++++++ .../room/timeline/TimelineViewModel.js | 4 ++-- .../room/timeline/tiles/BaseMessageTile.js | 4 ++++ .../session/room/timeline/tiles/SimpleTile.js | 4 ++++ .../session/room/timeline/BaseMessageView.js | 1 + 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 3b04f83a..0bd56f8e 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -44,6 +44,7 @@ export class RoomViewModel extends ViewModel { const timeline = await this._room.openTimeline(); const timelineVM = this.track(new TimelineViewModel(this.childOptions({ room: this._room, + roomVM: this, timeline, }))); this._timelineVM = timelineVM; @@ -294,12 +295,17 @@ export class RoomViewModel extends ViewModel { path = path.with(this.navigation.segment("details", true)); this.navigation.applyPath(path); } + + setReply(entry) { + this._composerVM.setReply(entry); + } } class ComposerViewModel extends ViewModel { constructor(roomVM) { super(); this._roomVM = roomVM; + this._replyTo = null; this._isEmpty = true; } @@ -307,6 +313,19 @@ class ComposerViewModel extends ViewModel { return this._roomVM.isEncrypted; } + setReply(entry) { + this._replyTo = entry; + this.emitChange("replyTo"); + } + + clearReply() { + this.setReply(null); + } + + get replyTo() { + return this._replyTo; + } + sendMessage(message) { const success = this._roomVM._sendMessage(message); if (success) { diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index d91b9acb..5ec21d9a 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -38,9 +38,9 @@ import {ViewModel} from "../../../ViewModel.js"; export class TimelineViewModel extends ViewModel { constructor(options) { super(options); - const {room, timeline} = options; + const {room, timeline, roomVM} = options; this._timeline = this.track(timeline); - this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, timeline}))); + this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, timeline, roomVM}))); } /** diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 71c709b8..7090c514 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -106,6 +106,10 @@ export class BaseMessageTile extends SimpleTile { return action; } + setReply() { + this._roomVM.setReply(this._entry); + } + redact(reason, log) { return this._room.sendRedaction(this._entry.id, reason, log); } diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 6ec913c0..efaa0a6a 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -129,6 +129,10 @@ export class SimpleTile extends ViewModel { return this._options.room; } + get _roomVM() { + return this._options.roomVM; + } + get _timeline() { return this._options.timeline; } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index c5d860f2..c91a5ed7 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -113,6 +113,7 @@ export class BaseMessageView extends TemplateView { if (vm.canReact && vm.shape !== "redacted") { options.push(new QuickReactionsMenuOption(vm)); } + options.push(Menu.option(vm.i18n`Reply`, () => vm.setReply())); if (vm.canAbortSending) { options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); } else if (vm.canRedact) { From 800b4785d199a1420767b684d17efa80705b3295 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 20 Jul 2021 12:17:44 -0700 Subject: [PATCH 02/54] Accomodate in_reply_to relation shape --- src/matrix/room/sending/PendingEvent.js | 6 +++--- src/matrix/room/sending/SendQueue.js | 10 ++++++---- .../room/timeline/persistence/RelationWriter.js | 5 +++-- src/matrix/room/timeline/relations.js | 14 +++++++++++++- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 7738847f..731707e5 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -16,7 +16,7 @@ limitations under the License. import {createEnum} from "../../../utils/enum.js"; import {AbortError} from "../../../utils/error.js"; import {REDACTION_TYPE} from "../common.js"; -import {getRelationFromContent} from "../timeline/relations.js"; +import {getRelationFromContent, getRelationTarget, setRelationTarget} from "../timeline/relations.js"; export const SendStatus = createEnum( "Waiting", @@ -54,7 +54,7 @@ export class PendingEvent { const relation = getRelationFromContent(this.content); if (relation) { // may be null when target is not sent yet, is intended - return relation.event_id; + return getRelationTarget(relation); } else { return this._data.relatedEventId; } @@ -63,7 +63,7 @@ export class PendingEvent { setRelatedEventId(eventId) { const relation = getRelationFromContent(this.content); if (relation) { - relation.event_id = eventId; + setRelationTarget(relation, eventId); } else { this._data.relatedEventId = eventId; } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index d6b16ac1..dd562610 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -19,7 +19,7 @@ import {ConnectionError} from "../../error.js"; import {PendingEvent, SendStatus} from "./PendingEvent.js"; import {makeTxnId, isTxnId} from "../../common.js"; import {REDACTION_TYPE} from "../common.js"; -import {getRelationFromContent, REACTION_TYPE, ANNOTATION_RELATION_TYPE} from "../timeline/relations.js"; +import {getRelationFromContent, getRelationTarget, setRelationTarget, REACTION_TYPE, ANNOTATION_RELATION_TYPE} from "../timeline/relations.js"; export class SendQueue { constructor({roomId, storage, hsApi, pendingEvents}) { @@ -206,11 +206,13 @@ export class SendQueue { const relation = getRelationFromContent(content); let relatedTxnId = null; if (relation) { - if (isTxnId(relation.event_id)) { - relatedTxnId = relation.event_id; - relation.event_id = null; + const relationTarget = getRelationTarget(relation); + if (isTxnId(relationTarget)) { + relatedTxnId = relationTarget; + setRelationTarget(relation, null); } if (relation.rel_type === ANNOTATION_RELATION_TYPE) { + // Here we know the shape of the relation, and can use event_id safely const isAlreadyAnnotating = this._pendingEvents.array.some(pe => { const r = getRelationFromContent(pe.content); return pe.eventType === eventType && r && r.key === relation.key && diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index b56988be..4944cc64 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -30,7 +30,8 @@ export class RelationWriter { const {relatedEventId} = sourceEntry; if (relatedEventId) { const relation = getRelation(sourceEntry.event); - if (relation) { + if (relation && relation.rel_type) { + // we don't consider replies (which aren't relations in the MSC2674 sense) txn.timelineRelations.add(this._roomId, relation.event_id, relation.rel_type, sourceEntry.id); } const target = await txn.timelineEvents.getByEventId(this._roomId, relatedEventId); @@ -120,7 +121,7 @@ export class RelationWriter { log.set("id", redactedEvent.event_id); const relation = getRelation(redactedEvent); - if (relation) { + if (relation && relation.rel_type) { txn.timelineRelations.remove(this._roomId, relation.event_id, relation.rel_type, redactedEvent.event_id); } // check if we're the target of a relation and remove all relations then as well diff --git a/src/matrix/room/timeline/relations.js b/src/matrix/room/timeline/relations.js index 5bf0f490..4009d8c4 100644 --- a/src/matrix/room/timeline/relations.js +++ b/src/matrix/room/timeline/relations.js @@ -29,13 +29,25 @@ export function createAnnotation(targetId, key) { }; } +export function getRelationTarget(relation) { + return relation.event_id || relation["m.in_reply_to"]?.event_id +} + +export function setRelationTarget(relation, target) { + if (relation.event_id !== undefined) { + relation.event_id = target; + } else if (relation["m.in_reply_to"]) { + relation["m.in_reply_to"].event_id = target; + } +} + export function getRelatedEventId(event) { if (event.type === REDACTION_TYPE) { return event.redacts; } else { const relation = getRelation(event); if (relation) { - return relation.event_id; + return getRelationTarget(relation); } } return null; From 46215b3c51adbd62d133637def38652d1b171821 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 20 Jul 2021 12:53:31 -0700 Subject: [PATCH 03/54] Add the ability to reply --- src/domain/session/room/RoomViewModel.js | 10 +++++++--- src/matrix/room/timeline/entries/BaseEventEntry.js | 6 +++++- src/matrix/room/timeline/relations.js | 8 ++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 0bd56f8e..164712df 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -155,7 +155,7 @@ export class RoomViewModel extends ViewModel { this._room.join(); } - async _sendMessage(message) { + async _sendMessage(message, replyTo) { if (!this._room.isArchived && message) { try { let msgtype = "m.text"; @@ -163,7 +163,11 @@ export class RoomViewModel extends ViewModel { message = message.substr(4).trim(); msgtype = "m.emote"; } - await this._room.sendEvent("m.room.message", {msgtype, body: message}); + const content = {msgtype, body: message}; + if (replyTo) { + content["m.relates_to"] = replyTo.reply(); + } + await this._room.sendEvent("m.room.message", content); } catch (err) { console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); this._sendError = err; @@ -327,7 +331,7 @@ class ComposerViewModel extends ViewModel { } sendMessage(message) { - const success = this._roomVM._sendMessage(message); + const success = this._roomVM._sendMessage(message, this._replyTo); if (success) { this._isEmpty = true; this.emitChange("canSend"); diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 2e681104..68f38357 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -16,7 +16,7 @@ limitations under the License. import {BaseEntry} from "./BaseEntry.js"; import {REDACTION_TYPE} from "../../common.js"; -import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; +import {createAnnotation, createReply, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; import {PendingAnnotation} from "../PendingAnnotation.js"; /** Deals mainly with local echo for relations and redactions, @@ -151,6 +151,10 @@ export class BaseEventEntry extends BaseEntry { return createAnnotation(this.id, key); } + reply() { + return createReply(this.id); + } + /** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */ isRelatedToId(id) { return id && this.relatedEventId === id; diff --git a/src/matrix/room/timeline/relations.js b/src/matrix/room/timeline/relations.js index 4009d8c4..0f861d0e 100644 --- a/src/matrix/room/timeline/relations.js +++ b/src/matrix/room/timeline/relations.js @@ -29,6 +29,14 @@ export function createAnnotation(targetId, key) { }; } +export function createReply(targetId) { + return { + "m.in_reply_to": { + "event_id": targetId + } + }; +} + export function getRelationTarget(relation) { return relation.event_id || relation["m.in_reply_to"]?.event_id } From f486bc0e042663a71e97aa8aec874cf57d6b32ed Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 20 Jul 2021 13:01:04 -0700 Subject: [PATCH 04/54] Reset reply state after successfully sending a reply --- src/domain/session/room/RoomViewModel.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 164712df..aa1bde25 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -175,6 +175,7 @@ export class RoomViewModel extends ViewModel { this.emitChange("error"); return false; } + this.setReply(null); return true; } return false; From d33d55376a1805cb6438cbf02cf0081abdfbad76 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 20 Jul 2021 15:22:12 -0700 Subject: [PATCH 05/54] Put reply into its own view model. Otherwise, we re-render the reply message on every keystroke. --- src/domain/session/room/RoomViewModel.js | 60 ++++++++++++------- .../room/timeline/tiles/BaseMessageTile.js | 4 +- .../session/room/timeline/BaseMessageView.js | 2 +- 3 files changed, 41 insertions(+), 25 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index aa1bde25..3b0d61e1 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -155,7 +155,7 @@ export class RoomViewModel extends ViewModel { this._room.join(); } - async _sendMessage(message, replyTo) { + async _sendMessage(message, replyingTo) { if (!this._room.isArchived && message) { try { let msgtype = "m.text"; @@ -164,8 +164,8 @@ export class RoomViewModel extends ViewModel { msgtype = "m.emote"; } const content = {msgtype, body: message}; - if (replyTo) { - content["m.relates_to"] = replyTo.reply(); + if (replyingTo) { + content["m.relates_to"] = replyingTo.reply(); } await this._room.sendEvent("m.room.message", content); } catch (err) { @@ -175,7 +175,6 @@ export class RoomViewModel extends ViewModel { this.emitChange("error"); return false; } - this.setReply(null); return true; } return false; @@ -301,8 +300,10 @@ export class RoomViewModel extends ViewModel { this.navigation.applyPath(path); } - setReply(entry) { - this._composerVM.setReply(entry); + startReply(entry) { + if (!this._room.isArchived) { + this._composerVM.startReply(entry); + } } } @@ -310,32 +311,23 @@ class ComposerViewModel extends ViewModel { constructor(roomVM) { super(); this._roomVM = roomVM; - this._replyTo = null; - this._isEmpty = true; + this._replyVM = new ReplyViewModel(roomVM); + } + + startReply(entry) { + this._replyVM.setReplyingTo(entry); } get isEncrypted() { return this._roomVM.isEncrypted; } - setReply(entry) { - this._replyTo = entry; - this.emitChange("replyTo"); - } - - clearReply() { - this.setReply(null); - } - - get replyTo() { - return this._replyTo; - } - sendMessage(message) { - const success = this._roomVM._sendMessage(message, this._replyTo); + const success = this._roomVM._sendMessage(message, this._replyVM.replyingTo); if (success) { this._isEmpty = true; this.emitChange("canSend"); + this._replyVM.clearReply(); } return success; } @@ -372,6 +364,30 @@ class ComposerViewModel extends ViewModel { } } +class ReplyViewModel extends ViewModel { + constructor(roomVM) { + super(); + this._roomVM = roomVM; + this._replyingTo = null; + } + + setReplyingTo(entry) { + const changed = this._replyingTo !== entry; + this._replyingTo = entry; + if (changed) { + this.emitChange("replyingTo"); + } + } + + clearReply() { + this.setReplyingTo(null); + } + + get replyingTo() { + return this._replyingTo; + } +} + function imageToInfo(image) { return { w: image.width, diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 7090c514..01d32a9c 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -106,8 +106,8 @@ export class BaseMessageTile extends SimpleTile { return action; } - setReply() { - this._roomVM.setReply(this._entry); + startReply() { + this._roomVM.startReply(this._entry); } redact(reason, log) { diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index c91a5ed7..b17bd93a 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -113,7 +113,7 @@ export class BaseMessageView extends TemplateView { if (vm.canReact && vm.shape !== "redacted") { options.push(new QuickReactionsMenuOption(vm)); } - options.push(Menu.option(vm.i18n`Reply`, () => vm.setReply())); + options.push(Menu.option(vm.i18n`Reply`, () => vm.startReply())); if (vm.canAbortSending) { options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); } else if (vm.canRedact) { From 7adb0e5ddcbafba7e424ccdb09862e90f678d014 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 21 Jul 2021 11:47:52 -0700 Subject: [PATCH 06/54] Get rid of intermediate view model --- src/domain/session/room/RoomViewModel.js | 55 ++++++++----------- .../room/timeline/tiles/BaseMessageTile.js | 6 +- 2 files changed, 27 insertions(+), 34 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 3b0d61e1..52613002 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -155,7 +155,7 @@ export class RoomViewModel extends ViewModel { this._room.join(); } - async _sendMessage(message, replyingTo) { + async _sendMessage(message, reply) { if (!this._room.isArchived && message) { try { let msgtype = "m.text"; @@ -164,8 +164,8 @@ export class RoomViewModel extends ViewModel { msgtype = "m.emote"; } const content = {msgtype, body: message}; - if (replyingTo) { - content["m.relates_to"] = replyingTo.reply(); + if (reply) { + content["m.relates_to"] = reply; } await this._room.sendEvent("m.room.message", content); } catch (err) { @@ -302,7 +302,7 @@ export class RoomViewModel extends ViewModel { startReply(entry) { if (!this._room.isArchived) { - this._composerVM.startReply(entry); + this._composerVM.setReplyingTo(entry); } } } @@ -311,11 +311,24 @@ class ComposerViewModel extends ViewModel { constructor(roomVM) { super(); this._roomVM = roomVM; - this._replyVM = new ReplyViewModel(roomVM); + this._isEmpty = true; + this._replyVM = null; } - startReply(entry) { - this._replyVM.setReplyingTo(entry); + setReplyingTo(tile) { + const changed = this._replyVM !== tile; + this._replyVM = tile; + if (changed) { + this.emitChange("replyViewModel"); + } + } + + clearReplyingTo() { + this.setReplyingTo(null); + } + + get replyViewModel() { + return this._replyVM; } get isEncrypted() { @@ -323,11 +336,11 @@ class ComposerViewModel extends ViewModel { } sendMessage(message) { - const success = this._roomVM._sendMessage(message, this._replyVM.replyingTo); + const success = this._roomVM._sendMessage(message, this._replyVM?.reply()); if (success) { this._isEmpty = true; this.emitChange("canSend"); - this._replyVM.clearReply(); + this.clearReplyingTo(); } return success; } @@ -364,30 +377,6 @@ class ComposerViewModel extends ViewModel { } } -class ReplyViewModel extends ViewModel { - constructor(roomVM) { - super(); - this._roomVM = roomVM; - this._replyingTo = null; - } - - setReplyingTo(entry) { - const changed = this._replyingTo !== entry; - this._replyingTo = entry; - if (changed) { - this.emitChange("replyingTo"); - } - } - - clearReply() { - this.setReplyingTo(null); - } - - get replyingTo() { - return this._replyingTo; - } -} - function imageToInfo(image) { return { w: image.width, diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 01d32a9c..0cff2793 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -107,7 +107,11 @@ export class BaseMessageTile extends SimpleTile { } startReply() { - this._roomVM.startReply(this._entry); + this._roomVM.startReply(this); + } + + reply() { + return this._entry.reply(); } redact(reason, log) { From 94ae5faa3c77495046daf5fc5a9390126d35328b Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 22 Jul 2021 11:16:34 -0700 Subject: [PATCH 07/54] Add a disabled flag to message view. --- .../ui/session/room/timeline/BaseMessageView.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index b17bd93a..98ff8d94 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -24,9 +24,12 @@ import {Menu} from "../../../general/Menu.js"; import {ReactionsView} from "./ReactionsView.js"; export class BaseMessageView extends TemplateView { - constructor(value) { + constructor(value, disabled = false) { super(value); this._menuPopup = null; + // TODO An enum could be nice to make code + // easier to read at call sites. + this._disabled = disabled; } render(t, vm) { @@ -40,7 +43,7 @@ export class BaseMessageView extends TemplateView { // dynamically added and removed nodes are handled below this.renderMessageBody(t, vm), // should be after body as it is overlayed on top - t.button({className: "Timeline_messageOptions"}, "⋯"), + this._disabled ? [] : t.button({className: "Timeline_messageOptions"}, "⋯"), ]); // given that there can be many tiles, we don't add // unneeded DOM nodes in case of a continuation, and we add it @@ -48,10 +51,10 @@ export class BaseMessageView extends TemplateView { // as the avatar or sender doesn't need any bindings or event handlers. // don't use `t` from within the side-effect callback t.mapSideEffect(vm => vm.isContinuation, (isContinuation, wasContinuation) => { - if (isContinuation && wasContinuation === false) { + if (isContinuation && wasContinuation === false && !this._disabled) { li.removeChild(li.querySelector(".Timeline_messageAvatar")); li.removeChild(li.querySelector(".Timeline_messageSender")); - } else if (!isContinuation) { + } else if (!isContinuation || this._disabled) { li.insertBefore(renderStaticAvatar(vm, 30, "Timeline_messageAvatar"), li.firstChild); li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild); } @@ -60,11 +63,11 @@ export class BaseMessageView extends TemplateView { // but that adds a comment node to all messages without reactions let reactionsView = null; t.mapSideEffect(vm => vm.reactions, reactions => { - if (reactions && !reactionsView) { + if (reactions && !reactionsView && !this._disabled) { reactionsView = new ReactionsView(vm.reactions); this.addSubView(reactionsView); li.appendChild(mountView(reactionsView)); - } else if (!reactions && reactionsView) { + } else if (!reactions && reactionsView && !this._disabled) { li.removeChild(reactionsView.root()); reactionsView.unmount(); this.removeSubView(reactionsView); From 66f686210fb9d9092ecc1f7c9439921c957d304f Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 22 Jul 2021 12:28:24 -0700 Subject: [PATCH 08/54] Add a very basic, unstyled view of the message to the composer. --- .../web/ui/session/room/MessageComposer.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index 95f48747..ac4f59f5 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -17,6 +17,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView.js"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; +import {TextMessageView} from "./timeline/TextMessageView.js"; export class MessageComposer extends TemplateView { constructor(viewModel) { @@ -33,6 +34,16 @@ export class MessageComposer extends TemplateView { onInput: () => vm.setInput(this._input.value), }); return t.div({className: "MessageComposer"}, [ + t.map(vm => vm.replyViewModel, (rvm, t) => !rvm ? null : + t.div({ + className: "replyBox" + }, [ + t.span('Replying'), + t.span({onClick: () => this._clearReplyingTo()}, 'Close'), + // TODO need proper view, not just assumed TextMessageView + t.view(new TextMessageView(vm.replyViewModel, true)) + ]) + ), this._input, t.button({ className: "sendFile", @@ -48,6 +59,10 @@ export class MessageComposer extends TemplateView { ]); } + _clearReplyingTo() { + this.value.clearReplyingTo(); + } + _trySend() { this._input.focus(); if (this.value.sendMessage(this._input.value)) { From 013f187dc29872a088b4dac5e57b31ac8bad952d Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 22 Jul 2021 12:51:24 -0700 Subject: [PATCH 09/54] Avoid inserting li tags outside a list --- src/platform/web/ui/session/room/MessageComposer.js | 2 +- .../web/ui/session/room/timeline/BaseMessageView.js | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index ac4f59f5..6f8ed542 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -41,7 +41,7 @@ export class MessageComposer extends TemplateView { t.span('Replying'), t.span({onClick: () => this._clearReplyingTo()}, 'Close'), // TODO need proper view, not just assumed TextMessageView - t.view(new TextMessageView(vm.replyViewModel, true)) + t.view(new TextMessageView(vm.replyViewModel, true, "div")) ]) ), this._input, diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 98ff8d94..bb04a2b4 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -24,20 +24,22 @@ import {Menu} from "../../../general/Menu.js"; import {ReactionsView} from "./ReactionsView.js"; export class BaseMessageView extends TemplateView { - constructor(value, disabled = false) { + constructor(value, disabled = false, tagName = "li") { super(value); this._menuPopup = null; + this._tagName = tagName; // TODO An enum could be nice to make code // easier to read at call sites. this._disabled = disabled; } render(t, vm) { - const li = t.li({className: { + const li = t.el(this._tagName, {className: { "Timeline_message": true, own: vm.isOwn, unsent: vm.isUnsent, unverified: vm.isUnverified, + disabled: this._disabled, continuation: vm => vm.isContinuation, }}, [ // dynamically added and removed nodes are handled below @@ -115,8 +117,8 @@ export class BaseMessageView extends TemplateView { const options = []; if (vm.canReact && vm.shape !== "redacted") { options.push(new QuickReactionsMenuOption(vm)); + options.push(Menu.option(vm.i18n`Reply`, () => vm.startReply())); } - options.push(Menu.option(vm.i18n`Reply`, () => vm.startReply())); if (vm.canAbortSending) { options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); } else if (vm.canRedact) { From 1dcfdfc1d8096f2d85d7b8996557d7a474e42735 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 22 Jul 2021 13:37:35 -0700 Subject: [PATCH 10/54] Split composer into preview and input --- .../web/ui/session/room/MessageComposer.js | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index 6f8ed542..1191dea6 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -33,17 +33,17 @@ export class MessageComposer extends TemplateView { onKeydown: e => this._onKeyDown(e), onInput: () => vm.setInput(this._input.value), }); - return t.div({className: "MessageComposer"}, [ - t.map(vm => vm.replyViewModel, (rvm, t) => !rvm ? null : - t.div({ - className: "replyBox" - }, [ - t.span('Replying'), - t.span({onClick: () => this._clearReplyingTo()}, 'Close'), - // TODO need proper view, not just assumed TextMessageView - t.view(new TextMessageView(vm.replyViewModel, true, "div")) - ]) - ), + const replyPreview = t.map(vm => vm.replyViewModel, (rvm, t) => !rvm ? null : + t.div({ + className: "MessageComposer_replyPreview" + }, [ + t.span('Replying'), + t.span({onClick: () => this._clearReplyingTo()}, 'Close'), + // TODO need proper view, not just assumed TextMessageView + t.view(new TextMessageView(vm.replyViewModel, true, "div")) + ]) + ); + const input = t.div({className: "MessageComposer_input"}, [ this._input, t.button({ className: "sendFile", @@ -57,6 +57,7 @@ export class MessageComposer extends TemplateView { onClick: () => this._trySend(), }, vm.i18n`Send`), ]); + return t.div({ className: "MessageComposer" }, [replyPreview, input]); } _clearReplyingTo() { From 83f7391af33a59bed9f733fa0921f68b78256b8b Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 22 Jul 2021 13:39:40 -0700 Subject: [PATCH 11/54] Adjust CSS to match new class structure --- src/platform/web/ui/css/room.css | 4 ++-- src/platform/web/ui/css/themes/bubbles/theme.css | 2 +- src/platform/web/ui/css/themes/element/theme.css | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/platform/web/ui/css/room.css b/src/platform/web/ui/css/room.css index 23fab8c1..8b8da1c2 100644 --- a/src/platform/web/ui/css/room.css +++ b/src/platform/web/ui/css/room.css @@ -50,12 +50,12 @@ limitations under the License. margin: 0; } -.MessageComposer { +.MessageComposer_input { display: flex; align-items: center; } -.MessageComposer > input { +.MessageComposer_input > input { display: block; flex: 1; min-width: 0; diff --git a/src/platform/web/ui/css/themes/bubbles/theme.css b/src/platform/web/ui/css/themes/bubbles/theme.css index 3e302110..06d85350 100644 --- a/src/platform/web/ui/css/themes/bubbles/theme.css +++ b/src/platform/web/ui/css/themes/bubbles/theme.css @@ -118,7 +118,7 @@ a { color: red; } -.MessageComposer > input { +.MessageComposer_input > input { padding: 0.8em; border: none; } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 56eaf224..1d74b453 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -462,16 +462,16 @@ a { color: red; } -.MessageComposer { +.MessageComposer_input { border-top: 1px solid rgba(245, 245, 245, 0.90); padding: 8px 16px; } -.MessageComposer > :not(:first-child) { +.MessageComposer_input > :not(:first-child) { margin-left: 12px; } -.MessageComposer > input { +.MessageComposer_input > input { padding: 0 16px; border: none; border-radius: 24px; @@ -481,7 +481,7 @@ a { font-family: "Inter", sans-serif; } -.MessageComposer > button.send { +.MessageComposer_input > button.send { width: 32px; height: 32px; display: block; @@ -496,7 +496,7 @@ a { background-position: center; } -.MessageComposer > button.sendFile { +.MessageComposer_input > button.sendFile { width: 32px; height: 32px; display: block; @@ -510,7 +510,7 @@ a { background-position: center; } -.MessageComposer > button.send:disabled { +.MessageComposer_input > button.send:disabled { background-color: #E3E8F0; } From 650389538d75fd66a7430c795555121bee92e1ca Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 22 Jul 2021 14:07:13 -0700 Subject: [PATCH 12/54] Add some basic styling --- src/platform/web/ui/css/room.css | 9 ++++++ .../web/ui/css/themes/element/theme.css | 28 ++++++++++++++++++- .../web/ui/css/themes/element/timeline.css | 2 +- .../web/ui/session/room/MessageComposer.js | 7 +++-- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/platform/web/ui/css/room.css b/src/platform/web/ui/css/room.css index 8b8da1c2..746349c8 100644 --- a/src/platform/web/ui/css/room.css +++ b/src/platform/web/ui/css/room.css @@ -50,6 +50,15 @@ limitations under the License. margin: 0; } +.MessageComposer_replyPreview { + display: grid; + grid-template-columns: 1fr auto; +} + +.MessageComposer_replyPreview .Timeline_message { + grid-column: 1/-1; +} + .MessageComposer_input { display: flex; align-items: center; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 1d74b453..ad9a77ca 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -462,11 +462,37 @@ a { color: red; } -.MessageComposer_input { +.MessageComposer_replyPreview .Timeline_message { + margin: 0; + margin-top: 5px; +} + +.MessageComposer_input, .MessageComposer_replyPreview { border-top: 1px solid rgba(245, 245, 245, 0.90); padding: 8px 16px; } +.MessageComposer_replyPreview > .replying { + display: inline-flex; + flex-direction: row; + align-items: center; +} + +.MessageComposer_replyPreview > button.cancel { + width: 32px; + height: 32px; + display: block; + border: none; + text-indent: 200%; + white-space: nowrap; + overflow: hidden; + background-color: transparent; + background-image: url('icons/clear.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: 18px; +} + .MessageComposer_input > :not(:first-child) { margin-left: 12px; } diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 29e2fbf0..cb8c43c6 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -51,7 +51,7 @@ limitations under the License. } } -.Timeline_message:hover, .Timeline_message.selected, .Timeline_message.menuOpen { +.Timeline_message:hover:not(.disabled), .Timeline_message.selected, .Timeline_message.menuOpen { background-color: rgba(141, 151, 165, 0.1); border-radius: 4px; } diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index 1191dea6..ea9e0bfb 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -37,8 +37,11 @@ export class MessageComposer extends TemplateView { t.div({ className: "MessageComposer_replyPreview" }, [ - t.span('Replying'), - t.span({onClick: () => this._clearReplyingTo()}, 'Close'), + t.span({ className: "replying" }, "Replying"), + t.button({ + className: "cancel", + onClick: () => this._clearReplyingTo() + }, "Close"), // TODO need proper view, not just assumed TextMessageView t.view(new TextMessageView(vm.replyViewModel, true, "div")) ]) From b0c5b2f2ce4b67ad7f3c528f6b5c1f8b8d720091 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 22 Jul 2021 14:10:59 -0700 Subject: [PATCH 13/54] Use the proper tile view to display reply preview --- .../web/ui/session/room/MessageComposer.js | 28 ++++++++++--------- .../web/ui/session/room/TimelineList.js | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index ea9e0bfb..b28c51b0 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -18,6 +18,7 @@ import {TemplateView} from "../../general/TemplateView.js"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; import {TextMessageView} from "./timeline/TextMessageView.js"; +import {viewClassForEntry} from "./TimelineList.js" export class MessageComposer extends TemplateView { constructor(viewModel) { @@ -33,19 +34,20 @@ export class MessageComposer extends TemplateView { onKeydown: e => this._onKeyDown(e), onInput: () => vm.setInput(this._input.value), }); - const replyPreview = t.map(vm => vm.replyViewModel, (rvm, t) => !rvm ? null : - t.div({ - className: "MessageComposer_replyPreview" - }, [ - t.span({ className: "replying" }, "Replying"), - t.button({ - className: "cancel", - onClick: () => this._clearReplyingTo() - }, "Close"), - // TODO need proper view, not just assumed TextMessageView - t.view(new TextMessageView(vm.replyViewModel, true, "div")) - ]) - ); + const replyPreview = t.map(vm => vm.replyViewModel, (rvm, t) => { + const View = rvm && viewClassForEntry(rvm); + if (!View) { return null; } + return t.div({ + className: "MessageComposer_replyPreview" + }, [ + t.span({ className: "replying" }, "Replying"), + t.button({ + className: "cancel", + onClick: () => this._clearReplyingTo() + }, "Close"), + t.view(new View(rvm, true, "div")) + ]) + }); const input = t.div({className: "MessageComposer_input"}, [ this._input, t.button({ diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index 74556c57..e0179e5d 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineList.js @@ -24,7 +24,7 @@ import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js"; import {RedactedView} from "./timeline/RedactedView.js"; -function viewClassForEntry(entry) { +export function viewClassForEntry(entry) { switch (entry.shape) { case "gap": return GapView; case "announcement": return AnnouncementView; From 711732200e809a1a919553a207754909022e7a30 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 22 Jul 2021 14:44:57 -0700 Subject: [PATCH 14/54] Make the reply box more distinct --- src/platform/web/ui/css/themes/element/theme.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index ad9a77ca..7c95fb1f 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -467,8 +467,12 @@ a { margin-top: 5px; } +.MessageComposer_replyPreview { + border: 1px solid rgba(225, 225, 225, 0.9); + background: rgba(245, 245, 245, 0.90); +} + .MessageComposer_input, .MessageComposer_replyPreview { - border-top: 1px solid rgba(245, 245, 245, 0.90); padding: 8px 16px; } @@ -493,6 +497,10 @@ a { background-size: 18px; } +.MessageComposer_input:first-child { + border-top: 1px solid rgba(245, 245, 245, 0.90); +} + .MessageComposer_input > :not(:first-child) { margin-left: 12px; } From 242a9c209b4fe2711696bbf5a7c711ef17ce2f91 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 23 Jul 2021 14:34:11 -0700 Subject: [PATCH 15/54] Handle replies in EventEntry --- src/domain/session/room/RoomViewModel.js | 12 ++++++------ .../session/room/timeline/tiles/BaseMessageTile.js | 4 ++-- src/matrix/room/timeline/entries/BaseEventEntry.js | 4 ++-- src/matrix/room/timeline/relations.js | 10 +++++++--- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 52613002..151af2e6 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -155,7 +155,7 @@ export class RoomViewModel extends ViewModel { this._room.join(); } - async _sendMessage(message, reply) { + async _sendMessage(message, replyingTo) { if (!this._room.isArchived && message) { try { let msgtype = "m.text"; @@ -163,11 +163,11 @@ export class RoomViewModel extends ViewModel { message = message.substr(4).trim(); msgtype = "m.emote"; } - const content = {msgtype, body: message}; - if (reply) { - content["m.relates_to"] = reply; + if (replyingTo) { + await replyingTo.reply(msgtype, message); + } else { + await this._room.sendEvent("m.room.message", {msgtype, body: message}); } - await this._room.sendEvent("m.room.message", content); } catch (err) { console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); this._sendError = err; @@ -336,7 +336,7 @@ class ComposerViewModel extends ViewModel { } sendMessage(message) { - const success = this._roomVM._sendMessage(message, this._replyVM?.reply()); + const success = this._roomVM._sendMessage(message, this._replyVM); if (success) { this._isEmpty = true; this.emitChange("canSend"); diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 0cff2793..9a206ad2 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -110,8 +110,8 @@ export class BaseMessageTile extends SimpleTile { this._roomVM.startReply(this); } - reply() { - return this._entry.reply(); + reply(msgtype, body) { + return this._room.sendEvent("m.room.message", this._entry.reply(msgtype, body)); } redact(reason, log) { diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 68f38357..b1744227 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -151,8 +151,8 @@ export class BaseEventEntry extends BaseEntry { return createAnnotation(this.id, key); } - reply() { - return createReply(this.id); + reply(msgtype, body) { + return createReply(this.id, msgtype, body); } /** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */ diff --git a/src/matrix/room/timeline/relations.js b/src/matrix/room/timeline/relations.js index 0f861d0e..b6d23c50 100644 --- a/src/matrix/room/timeline/relations.js +++ b/src/matrix/room/timeline/relations.js @@ -29,10 +29,14 @@ export function createAnnotation(targetId, key) { }; } -export function createReply(targetId) { +export function createReply(targetId, msgtype, body) { return { - "m.in_reply_to": { - "event_id": targetId + msgtype, + body, + "m.relates_to": { + "m.in_reply_to": { + "event_id": targetId + } } }; } From 73ca5d21ff0b19c5e48463c5066da94e87960575 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 23 Jul 2021 15:32:37 -0700 Subject: [PATCH 16/54] Add ideas of pending replies --- doc/impl-thoughts/PENDING_REPLIES.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 doc/impl-thoughts/PENDING_REPLIES.md diff --git a/doc/impl-thoughts/PENDING_REPLIES.md b/doc/impl-thoughts/PENDING_REPLIES.md new file mode 100644 index 00000000..4a5e4997 --- /dev/null +++ b/doc/impl-thoughts/PENDING_REPLIES.md @@ -0,0 +1,25 @@ +# Replying to pending messages +The matrix spec requires clients capable of rich replies (that would be us once replies work) to include fallback (textual in `body` and structured in `formatted_body`) that can be rendered +by clients that do not natively support rich replies (that would be us at the time of writing). The schema for the fallback is as follows: + +``` + +
+ In reply to + @alice:example.org +
+ +
+
+``` + +There's a single complication here for pending events: we have `$event:example.org` in the schema (the `In reply to` link), and it must +be present _within the content_, inside `formatted_body`. The issue is that, if we are queuing a reply to a pending event, +we don't know its remote ID. All we know is its transaction ID on our end. If we were to use that while formatting the message, +we'd be sending messages that contain our internal transaction IDs instead of proper matrix event identifiers. + +To solve this, we'd need `SendQueue`, whenever it receives a remote echo, to update pending events that are replies with their +`relatedEventId`. This already happens, and the `event_id` field in `m.relates_to` is updated. But we'd need to extend this +to adjust the messages' `formatted_body` with the resolved remote ID, too. + +How do we safely do this, without accidentally substituting event IDs into places in the body where they were not intended? From c0d39a598333f6b00b40d41d44519037dca89b3e Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 23 Jul 2021 15:34:04 -0700 Subject: [PATCH 17/54] Add very rudimentary fallback reply formatting code --- src/matrix/room/timeline/entries/BaseEventEntry.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index b1744227..5c4ef6cf 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -151,8 +151,18 @@ export class BaseEventEntry extends BaseEntry { return createAnnotation(this.id, key); } + _formatReplyBody() { + // This is just a rough sketch for now. + // TODO case-by-case formatting + // TODO check for absense? + const bodyLines = this.content.body.split("\n"); + const sender = this.sender; + bodyLines[0] = `<${sender}> ${bodyLines[0]}` + return `> ${bodyLines.join("\n> ")}\n\n`; + } + reply(msgtype, body) { - return createReply(this.id, msgtype, body); + return createReply(this.id, msgtype, this._formatReplyBody() + body); } /** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */ From 0db6870edb6b7829ca87f5e937268d11aed01c0c Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 23 Jul 2021 15:52:29 -0700 Subject: [PATCH 18/54] Flesh out the fallback formatting a bit. --- .../room/timeline/entries/BaseEventEntry.js | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 5c4ef6cf..f59d15f4 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -151,18 +151,48 @@ export class BaseEventEntry extends BaseEntry { return createAnnotation(this.id, key); } - _formatReplyBody() { - // This is just a rough sketch for now. - // TODO case-by-case formatting + _fallbackBlurb() { + switch (this.content.msgtype) { + case "m.file": + return "sent a file."; + case "m.image": + return "sent an image."; + case "m.video": + return "sent a video."; + case "m.audio": + return "sent an audio file."; + } + } + + _fallbackPrefix() { + return this.content.msgtype === "m.emote" ? "* " : ""; + } + + _replyFormattedFallback() { // TODO check for absense? - const bodyLines = this.content.body.split("\n"); - const sender = this.sender; - bodyLines[0] = `<${sender}> ${bodyLines[0]}` - return `> ${bodyLines.join("\n> ")}\n\n`; + // TODO escape unformatted body if needed + const body = this._fallbackBlurb() || this.content.formatted_body || this.content.body; + const prefix = this._fallbackPrefix(); + return ` +
+ In reply to + ${prefix}${this.displayName} +
+ ${body} +
+
` + } + + _replyBodyFallback() { + // TODO check for absense? + const body = this._fallbackBlurb() || this.content.body; + const bodyLines = body.split("\n"); + bodyLines[0] = `> <${this.sender}> ${bodyLines[0]}` + return `${bodyLines.join("\n> ")}`; } reply(msgtype, body) { - return createReply(this.id, msgtype, this._formatReplyBody() + body); + return createReply(this.id, msgtype, this._replyBodyFallback() + '\n\n' + body); } /** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */ From 305fab467e8353505f6f9382bc0bd27d9b782312 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 23 Jul 2021 16:45:22 -0700 Subject: [PATCH 19/54] Send a formatted body from quotes --- src/matrix/room/timeline/entries/BaseEventEntry.js | 6 ++++-- src/matrix/room/timeline/relations.js | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index f59d15f4..a9825eaa 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -170,7 +170,7 @@ export class BaseEventEntry extends BaseEntry { _replyFormattedFallback() { // TODO check for absense? - // TODO escape unformatted body if needed + // TODO escape and tranform unformatted body as needed const body = this._fallbackBlurb() || this.content.formatted_body || this.content.body; const prefix = this._fallbackPrefix(); return ` @@ -192,7 +192,9 @@ export class BaseEventEntry extends BaseEntry { } reply(msgtype, body) { - return createReply(this.id, msgtype, this._replyBodyFallback() + '\n\n' + body); + const newBody = this._replyBodyFallback() + '\n\n' + body; + const newFormattedBody = this._replyFormattedFallback() + body; + return createReply(this.id, msgtype, newBody, newFormattedBody); } /** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */ diff --git a/src/matrix/room/timeline/relations.js b/src/matrix/room/timeline/relations.js index b6d23c50..17d1e9d7 100644 --- a/src/matrix/room/timeline/relations.js +++ b/src/matrix/room/timeline/relations.js @@ -29,10 +29,12 @@ export function createAnnotation(targetId, key) { }; } -export function createReply(targetId, msgtype, body) { +export function createReply(targetId, msgtype, body, formattedBody) { return { msgtype, body, + "format": "org.matrix.custom.html", + "formatted_body": formattedBody, "m.relates_to": { "m.in_reply_to": { "event_id": targetId From 753bb8392b36b0b8e0c33c7bf70e0dc7c3aefefb Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Mon, 26 Jul 2021 12:21:20 -0700 Subject: [PATCH 20/54] Add mx-reply to dompurify's list so we can ignore it ourselves. --- src/platform/web/parsehtml.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/parsehtml.js b/src/platform/web/parsehtml.js index 456559d9..8db9a92c 100644 --- a/src/platform/web/parsehtml.js +++ b/src/platform/web/parsehtml.js @@ -56,6 +56,7 @@ class HTMLParseResult { const sanitizeConfig = { ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx|mxc):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i, + ADD_TAGS: ['mx-reply'] } export function parseHTML(html) { From 3d911f2a22ec74699830a9db5f1474539eaadb86 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Mon, 26 Jul 2021 14:49:06 -0700 Subject: [PATCH 21/54] Add escaping to replies --- .../room/timeline/entries/BaseEventEntry.js | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index a9825eaa..9782c530 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -19,6 +19,10 @@ import {REDACTION_TYPE} from "../../common.js"; import {createAnnotation, createReply, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; import {PendingAnnotation} from "../PendingAnnotation.js"; +function htmlEscape(string) { + return string.replace(/&/g, "&").replace(//g, ">"); +} + /** Deals mainly with local echo for relations and redactions, * so it is shared between PendingEventEntry and EventEntry */ export class BaseEventEntry extends BaseEntry { @@ -168,15 +172,21 @@ export class BaseEventEntry extends BaseEntry { return this.content.msgtype === "m.emote" ? "* " : ""; } + get _formattedBody() { + return this.content.formatted_body || (this.content.body && htmlEscape(this.content.body)); + } + + get _plainBody() { + return this.content.body; + } + _replyFormattedFallback() { - // TODO check for absense? - // TODO escape and tranform unformatted body as needed - const body = this._fallbackBlurb() || this.content.formatted_body || this.content.body; + const body = this._fallbackBlurb() || this._formattedBody || ""; const prefix = this._fallbackPrefix(); return `
In reply to - ${prefix}${this.displayName} + ${prefix}${this.displayName || this.sender}
${body}
@@ -184,16 +194,16 @@ export class BaseEventEntry extends BaseEntry { } _replyBodyFallback() { - // TODO check for absense? - const body = this._fallbackBlurb() || this.content.body; + const body = this._fallbackBlurb() || this._plainBody || ""; const bodyLines = body.split("\n"); bodyLines[0] = `> <${this.sender}> ${bodyLines[0]}` - return `${bodyLines.join("\n> ")}`; + return bodyLines.join("\n> "); } reply(msgtype, body) { + // TODO check for absense of sender / body / msgtype / etc? const newBody = this._replyBodyFallback() + '\n\n' + body; - const newFormattedBody = this._replyFormattedFallback() + body; + const newFormattedBody = this._replyFormattedFallback() + htmlEscape(body); return createReply(this.id, msgtype, newBody, newFormattedBody); } From fb54ab68a34ad7d7834c73cad931c85c717d5328 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 27 Jul 2021 15:08:34 -0700 Subject: [PATCH 22/54] Tweak reply style --- src/platform/web/ui/css/themes/element/theme.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 7c95fb1f..3416c9a8 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -465,11 +465,15 @@ a { .MessageComposer_replyPreview .Timeline_message { margin: 0; margin-top: 5px; + max-height: 30vh; + overflow: auto; } .MessageComposer_replyPreview { - border: 1px solid rgba(225, 225, 225, 0.9); background: rgba(245, 245, 245, 0.90); + margin: 0px 10px 10px 10px; + box-shadow: 0px 0px 5px #91919169; + border-radius: 5px; } .MessageComposer_input, .MessageComposer_replyPreview { @@ -480,6 +484,7 @@ a { display: inline-flex; flex-direction: row; align-items: center; + font-weight: bold; } .MessageComposer_replyPreview > button.cancel { From e2ad589aa364aa8a4b854a2285c52849d6b999b9 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 27 Jul 2021 16:51:34 -0700 Subject: [PATCH 23/54] Go through and clean up affected files. --- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 4 ++-- src/matrix/room/timeline/entries/BaseEventEntry.js | 6 +++--- .../web/ui/session/room/timeline/BaseMessageView.js | 5 ++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 9a206ad2..eef83cb6 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -110,8 +110,8 @@ export class BaseMessageTile extends SimpleTile { this._roomVM.startReply(this); } - reply(msgtype, body) { - return this._room.sendEvent("m.room.message", this._entry.reply(msgtype, body)); + reply(msgtype, body, log = null) { + return this._room.sendEvent("m.room.message", this._entry.reply(msgtype, body), null, log); } redact(reason, log) { diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 9782c530..41960e75 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -190,10 +190,10 @@ export class BaseEventEntry extends BaseEntry {
${body} -
` +
`; } - _replyBodyFallback() { + _replyPlainFallback() { const body = this._fallbackBlurb() || this._plainBody || ""; const bodyLines = body.split("\n"); bodyLines[0] = `> <${this.sender}> ${bodyLines[0]}` @@ -202,7 +202,7 @@ export class BaseEventEntry extends BaseEntry { reply(msgtype, body) { // TODO check for absense of sender / body / msgtype / etc? - const newBody = this._replyBodyFallback() + '\n\n' + body; + const newBody = this._replyPlainFallback() + '\n\n' + body; const newFormattedBody = this._replyFormattedFallback() + htmlEscape(body); return createReply(this.id, msgtype, newBody, newFormattedBody); } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index bb04a2b4..018b455b 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -28,8 +28,7 @@ export class BaseMessageView extends TemplateView { super(value); this._menuPopup = null; this._tagName = tagName; - // TODO An enum could be nice to make code - // easier to read at call sites. + // TODO An enum could be nice to make code easier to read at call sites. this._disabled = disabled; } @@ -56,7 +55,7 @@ export class BaseMessageView extends TemplateView { if (isContinuation && wasContinuation === false && !this._disabled) { li.removeChild(li.querySelector(".Timeline_messageAvatar")); li.removeChild(li.querySelector(".Timeline_messageSender")); - } else if (!isContinuation || this._disabled) { + } else if (!isContinuation && !this._disabled) { li.insertBefore(renderStaticAvatar(vm, 30, "Timeline_messageAvatar"), li.firstChild); li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild); } From 28248722a33d2dc29e3a6deddeec0277b07199fa Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 28 Jul 2021 16:17:25 -0700 Subject: [PATCH 24/54] Fix incorrect conditions for showing avatar --- .../web/ui/session/room/timeline/BaseMessageView.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 018b455b..a27e9bef 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -52,10 +52,10 @@ export class BaseMessageView extends TemplateView { // as the avatar or sender doesn't need any bindings or event handlers. // don't use `t` from within the side-effect callback t.mapSideEffect(vm => vm.isContinuation, (isContinuation, wasContinuation) => { - if (isContinuation && wasContinuation === false && !this._disabled) { + if (isContinuation && !this._disabled && wasContinuation === false) { li.removeChild(li.querySelector(".Timeline_messageAvatar")); li.removeChild(li.querySelector(".Timeline_messageSender")); - } else if (!isContinuation && !this._disabled) { + } else if (!isContinuation || this._disabled) { li.insertBefore(renderStaticAvatar(vm, 30, "Timeline_messageAvatar"), li.firstChild); li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild); } @@ -64,11 +64,11 @@ export class BaseMessageView extends TemplateView { // but that adds a comment node to all messages without reactions let reactionsView = null; t.mapSideEffect(vm => vm.reactions, reactions => { - if (reactions && !reactionsView && !this._disabled) { + if (reactions && !this._disabled && !reactionsView) { reactionsView = new ReactionsView(vm.reactions); this.addSubView(reactionsView); li.appendChild(mountView(reactionsView)); - } else if (!reactions && reactionsView && !this._disabled) { + } else if (!reactions && reactionsView) { li.removeChild(reactionsView.root()); reactionsView.unmount(); this.removeSubView(reactionsView); From 99a630fb84707331af29bc0aa5b028433feed94e Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 29 Jul 2021 10:46:17 -0700 Subject: [PATCH 25/54] Add a note on TilesCollection and diposing of tiles --- src/domain/session/room/timeline/TilesCollection.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 0acb2859..83d3aace 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -181,6 +181,8 @@ export class TilesCollection extends BaseObservableList { } _replaceTile(tileIdx, existingTile, newTile, updateParams) { + // TODO What happens with a tile that's being replied to? Can we have + // reference counting of some sort? existingTile.dispose(); const prevTile = this._getTileAtIdx(tileIdx - 1); const nextTile = this._getTileAtIdx(tileIdx + 1); From d4ed146cd77710a1ec6fcc38441b03b015f9562a Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 29 Jul 2021 13:40:02 -0700 Subject: [PATCH 26/54] Add implementation thoughts --- doc/impl-thoughts/REPLIES.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 doc/impl-thoughts/REPLIES.md diff --git a/doc/impl-thoughts/REPLIES.md b/doc/impl-thoughts/REPLIES.md new file mode 100644 index 00000000..eac5b7ea --- /dev/null +++ b/doc/impl-thoughts/REPLIES.md @@ -0,0 +1,17 @@ +If we were to render replies in a smart way (instead of relying on the fallback), we would +need to manually find entries that are pointed to be `in_reply_to`. Consulting the timeline +code, it seems appropriate to add a `_replyingTo` field to a `BaseEventEntry` (much like we +have `_pendingAnnotations` and `pendingRedactions`). We can then: +* use `TilesCollection`'s `_findTileIdx` to find the tile of the message being replied to, + and put a reference to its tile into the new tile being created (?). + * It doesn't seem appropriate to add an additional argument to TileCreator, but we may + want to re-use tiles instead of creating duplicate ones. Otherwise, of course, `tileCreator` + can create more than one tile from an entry's `_replyingTo` field. +* Resolve `_replyingTo` much like we resolve `redactingEntry` in timeline: search by `relatedTxnId` + and `relatedEventId` if our entry is a reply (we can add an `isReply` flag there). + * This works fine for local entries, which are loaded via an `AsyncMappedList`, but what + about remote entries? They are not loaded asynchronously, and the fact that they are + not a derived collection is used throughout `Timeline`. +* Entries that don't have replies that are loadeded (but that are replies) probably need + to be tracked somehow? + * Then, on timeline add, check new IDs and update corresponding entries From 9bd7d1397cde767a88cb4397245d052037556687 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 30 Jul 2021 14:37:34 -0700 Subject: [PATCH 27/54] Preserve the m.relates_to field for message. --- src/matrix/room/sending/PendingEvent.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index 731707e5..af295579 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -28,6 +28,10 @@ export const SendStatus = createEnum( "Error", ); +const preservedContentFields = { + "m.room.message": [ "m.relates_to" ] +}; + export class PendingEvent { constructor({data, remove, emitUpdate, attachments}) { this._data = data; @@ -96,7 +100,20 @@ export class PendingEvent { this._emitUpdate("status"); } + _preserveContentFields(into) { + const preservedFields = preservedContentFields[this.eventType]; + if (preservedFields) { + const content = this._data.content; + for (const field of preservedFields) { + if (content[field] !== undefined) { + into[field] = content[field]; + } + } + } + } + setEncrypted(type, content) { + this._preserveContentFields(content); this._data.encryptedEventType = type; this._data.encryptedContent = content; this._data.needsEncryption = false; From 8956f6ecf414d4dcda9ad93ee0a4cc7443f6060f Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 3 Aug 2021 13:10:36 -0700 Subject: [PATCH 28/54] Fuse methods and properties related to replies --- .../room/timeline/entries/BaseEventEntry.js | 68 ++++++------------- src/matrix/room/timeline/entries/reply.js | 33 +++++++++ src/matrix/room/timeline/relations.js | 14 ---- 3 files changed, 53 insertions(+), 62 deletions(-) create mode 100644 src/matrix/room/timeline/entries/reply.js diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 41960e75..a106af58 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -16,8 +16,9 @@ limitations under the License. import {BaseEntry} from "./BaseEntry.js"; import {REDACTION_TYPE} from "../../common.js"; -import {createAnnotation, createReply, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; +import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; import {PendingAnnotation} from "../PendingAnnotation.js"; +import {createReply, fallbackBlurb, fallbackPrefix} from "./reply.js" function htmlEscape(string) { return string.replace(/&/g, "&").replace(//g, ">"); @@ -155,55 +156,26 @@ export class BaseEventEntry extends BaseEntry { return createAnnotation(this.id, key); } - _fallbackBlurb() { - switch (this.content.msgtype) { - case "m.file": - return "sent a file."; - case "m.image": - return "sent an image."; - case "m.video": - return "sent a video."; - case "m.audio": - return "sent an audio file."; - } - } - - _fallbackPrefix() { - return this.content.msgtype === "m.emote" ? "* " : ""; - } - - get _formattedBody() { - return this.content.formatted_body || (this.content.body && htmlEscape(this.content.body)); - } - - get _plainBody() { - return this.content.body; - } - - _replyFormattedFallback() { - const body = this._fallbackBlurb() || this._formattedBody || ""; - const prefix = this._fallbackPrefix(); - return ` -
- In reply to - ${prefix}${this.displayName || this.sender} -
- ${body} -
-
`; - } - - _replyPlainFallback() { - const body = this._fallbackBlurb() || this._plainBody || ""; - const bodyLines = body.split("\n"); - bodyLines[0] = `> <${this.sender}> ${bodyLines[0]}` - return bodyLines.join("\n> "); - } - reply(msgtype, body) { // TODO check for absense of sender / body / msgtype / etc? - const newBody = this._replyPlainFallback() + '\n\n' + body; - const newFormattedBody = this._replyFormattedFallback() + htmlEscape(body); + let blurb = fallbackBlurb(this.content.msgtype); + const prefix = fallbackPrefix(this.content.msgtype); + const sender = this.sender; + const name = this.displayName || sender; + + const formattedBody = blurb || this.content.formatted_body || + (this.content.body && htmlEscape(this.content.body)) || ""; + const formattedFallback = `
In reply to ${prefix}` + + `${name}
` + + `${formattedBody}
`; + + const plainBody = blurb || this.content.body || ""; + const bodyLines = plainBody.split("\n"); + bodyLines[0] = `> ${prefix}<${sender}> ${bodyLines[0]}` + const plainFallback = bodyLines.join("\n> "); + + const newBody = plainFallback + '\n\n' + body; + const newFormattedBody = formattedFallback + htmlEscape(body); return createReply(this.id, msgtype, newBody, newFormattedBody); } diff --git a/src/matrix/room/timeline/entries/reply.js b/src/matrix/room/timeline/entries/reply.js new file mode 100644 index 00000000..00d591e0 --- /dev/null +++ b/src/matrix/room/timeline/entries/reply.js @@ -0,0 +1,33 @@ + +export function fallbackBlurb(msgtype) { + switch (msgtype) { + case "m.file": + return "sent a file."; + case "m.image": + return "sent an image."; + case "m.video": + return "sent a video."; + case "m.audio": + return "sent an audio file."; + } + return null; +} + +export function fallbackPrefix(msgtype) { + return msgtype === "m.emote" ? "* " : ""; +} + +export function createReply(targetId, msgtype, body, formattedBody) { + return { + msgtype, + body, + "format": "org.matrix.custom.html", + "formatted_body": formattedBody, + "m.relates_to": { + "m.in_reply_to": { + "event_id": targetId + } + } + }; +} + diff --git a/src/matrix/room/timeline/relations.js b/src/matrix/room/timeline/relations.js index 17d1e9d7..4009d8c4 100644 --- a/src/matrix/room/timeline/relations.js +++ b/src/matrix/room/timeline/relations.js @@ -29,20 +29,6 @@ export function createAnnotation(targetId, key) { }; } -export function createReply(targetId, msgtype, body, formattedBody) { - return { - msgtype, - body, - "format": "org.matrix.custom.html", - "formatted_body": formattedBody, - "m.relates_to": { - "m.in_reply_to": { - "event_id": targetId - } - } - }; -} - export function getRelationTarget(relation) { return relation.event_id || relation["m.in_reply_to"]?.event_id } From f0b6384ad78dc00a1836dcdd9ccc972245261b6a Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 3 Aug 2021 13:27:33 -0700 Subject: [PATCH 29/54] Rename 'disabled' to 'interactive' in BaseMessageView --- .../web/ui/session/room/MessageComposer.js | 2 +- .../ui/session/room/timeline/BaseMessageView.js | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index b28c51b0..20fd3659 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -45,7 +45,7 @@ export class MessageComposer extends TemplateView { className: "cancel", onClick: () => this._clearReplyingTo() }, "Close"), - t.view(new View(rvm, true, "div")) + t.view(new View(rvm, false, "div")) ]) }); const input = t.div({className: "MessageComposer_input"}, [ diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index a27e9bef..0fac7bc9 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -24,12 +24,12 @@ import {Menu} from "../../../general/Menu.js"; import {ReactionsView} from "./ReactionsView.js"; export class BaseMessageView extends TemplateView { - constructor(value, disabled = false, tagName = "li") { + constructor(value, interactive = true, tagName = "li") { super(value); this._menuPopup = null; this._tagName = tagName; // TODO An enum could be nice to make code easier to read at call sites. - this._disabled = disabled; + this._interactive = interactive; } render(t, vm) { @@ -38,13 +38,13 @@ export class BaseMessageView extends TemplateView { own: vm.isOwn, unsent: vm.isUnsent, unverified: vm.isUnverified, - disabled: this._disabled, + disabled: !this._interactive, continuation: vm => vm.isContinuation, }}, [ // dynamically added and removed nodes are handled below this.renderMessageBody(t, vm), // should be after body as it is overlayed on top - this._disabled ? [] : t.button({className: "Timeline_messageOptions"}, "⋯"), + this._interactive ? t.button({className: "Timeline_messageOptions"}, "⋯") : [], ]); // given that there can be many tiles, we don't add // unneeded DOM nodes in case of a continuation, and we add it @@ -52,10 +52,10 @@ export class BaseMessageView extends TemplateView { // as the avatar or sender doesn't need any bindings or event handlers. // don't use `t` from within the side-effect callback t.mapSideEffect(vm => vm.isContinuation, (isContinuation, wasContinuation) => { - if (isContinuation && !this._disabled && wasContinuation === false) { + if (isContinuation && this._interactive && wasContinuation === false) { li.removeChild(li.querySelector(".Timeline_messageAvatar")); li.removeChild(li.querySelector(".Timeline_messageSender")); - } else if (!isContinuation || this._disabled) { + } else if (!isContinuation || !this._interactive) { li.insertBefore(renderStaticAvatar(vm, 30, "Timeline_messageAvatar"), li.firstChild); li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild); } @@ -64,7 +64,7 @@ export class BaseMessageView extends TemplateView { // but that adds a comment node to all messages without reactions let reactionsView = null; t.mapSideEffect(vm => vm.reactions, reactions => { - if (reactions && !this._disabled && !reactionsView) { + if (reactions && this._interactive && !reactionsView) { reactionsView = new ReactionsView(vm.reactions); this.addSubView(reactionsView); li.appendChild(mountView(reactionsView)); From 1a0e3052127351311f2f5c0979fc3e35421b0d62 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Tue, 3 Aug 2021 14:02:11 -0700 Subject: [PATCH 30/54] Extract ComposerViewModel to its own file --- src/domain/session/room/ComposerViewModel.js | 71 ++++++++++++++++++++ src/domain/session/room/RoomViewModel.js | 71 +------------------- 2 files changed, 72 insertions(+), 70 deletions(-) create mode 100644 src/domain/session/room/ComposerViewModel.js diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js new file mode 100644 index 00000000..3ae11872 --- /dev/null +++ b/src/domain/session/room/ComposerViewModel.js @@ -0,0 +1,71 @@ +import {ViewModel} from "../../ViewModel.js"; + +export class ComposerViewModel extends ViewModel { + constructor(roomVM) { + super(); + this._roomVM = roomVM; + this._isEmpty = true; + this._replyVM = null; + } + + setReplyingTo(tile) { + const changed = this._replyVM !== tile; + this._replyVM = tile; + if (changed) { + this.emitChange("replyViewModel"); + } + } + + clearReplyingTo() { + this.setReplyingTo(null); + } + + get replyViewModel() { + return this._replyVM; + } + + get isEncrypted() { + return this._roomVM.isEncrypted; + } + + sendMessage(message) { + const success = this._roomVM._sendMessage(message, this._replyVM); + if (success) { + this._isEmpty = true; + this.emitChange("canSend"); + this.clearReplyingTo(); + } + return success; + } + + sendPicture() { + this._roomVM._pickAndSendPicture(); + } + + sendFile() { + this._roomVM._pickAndSendFile(); + } + + sendVideo() { + this._roomVM._pickAndSendVideo(); + } + + get canSend() { + return !this._isEmpty; + } + + async setInput(text) { + const wasEmpty = this._isEmpty; + this._isEmpty = text.length === 0; + if (wasEmpty && !this._isEmpty) { + this._roomVM._room.ensureMessageKeyIsShared(); + } + if (wasEmpty !== this._isEmpty) { + this.emitChange("canSend"); + } + } + + get kind() { + return "composer"; + } +} diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 151af2e6..11f08df1 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -16,6 +16,7 @@ limitations under the License. */ import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; +import {ComposerViewModel} from "./ComposerViewModel.js" import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {ViewModel} from "../../ViewModel.js"; @@ -307,76 +308,6 @@ export class RoomViewModel extends ViewModel { } } -class ComposerViewModel extends ViewModel { - constructor(roomVM) { - super(); - this._roomVM = roomVM; - this._isEmpty = true; - this._replyVM = null; - } - - setReplyingTo(tile) { - const changed = this._replyVM !== tile; - this._replyVM = tile; - if (changed) { - this.emitChange("replyViewModel"); - } - } - - clearReplyingTo() { - this.setReplyingTo(null); - } - - get replyViewModel() { - return this._replyVM; - } - - get isEncrypted() { - return this._roomVM.isEncrypted; - } - - sendMessage(message) { - const success = this._roomVM._sendMessage(message, this._replyVM); - if (success) { - this._isEmpty = true; - this.emitChange("canSend"); - this.clearReplyingTo(); - } - return success; - } - - sendPicture() { - this._roomVM._pickAndSendPicture(); - } - - sendFile() { - this._roomVM._pickAndSendFile(); - } - - sendVideo() { - this._roomVM._pickAndSendVideo(); - } - - get canSend() { - return !this._isEmpty; - } - - async setInput(text) { - const wasEmpty = this._isEmpty; - this._isEmpty = text.length === 0; - if (wasEmpty && !this._isEmpty) { - this._roomVM._room.ensureMessageKeyIsShared(); - } - if (wasEmpty !== this._isEmpty) { - this.emitChange("canSend"); - } - } - - get kind() { - return "composer"; - } -} - function imageToInfo(image) { return { w: image.width, From 611c6e9717abaa2cf70eb8572533d01eb06986e0 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 4 Aug 2021 09:26:26 -0700 Subject: [PATCH 31/54] Move replying code into reply.js and add license --- .../room/timeline/entries/BaseEventEntry.js | 27 +---------- src/matrix/room/timeline/entries/reply.js | 47 +++++++++++++++++-- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index a106af58..6893b890 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -18,11 +18,7 @@ import {BaseEntry} from "./BaseEntry.js"; import {REDACTION_TYPE} from "../../common.js"; import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; import {PendingAnnotation} from "../PendingAnnotation.js"; -import {createReply, fallbackBlurb, fallbackPrefix} from "./reply.js" - -function htmlEscape(string) { - return string.replace(/&/g, "&").replace(//g, ">"); -} +import {reply} from "./reply.js" /** Deals mainly with local echo for relations and redactions, * so it is shared between PendingEventEntry and EventEntry */ @@ -157,26 +153,7 @@ export class BaseEventEntry extends BaseEntry { } reply(msgtype, body) { - // TODO check for absense of sender / body / msgtype / etc? - let blurb = fallbackBlurb(this.content.msgtype); - const prefix = fallbackPrefix(this.content.msgtype); - const sender = this.sender; - const name = this.displayName || sender; - - const formattedBody = blurb || this.content.formatted_body || - (this.content.body && htmlEscape(this.content.body)) || ""; - const formattedFallback = `
In reply to ${prefix}` + - `${name}
` + - `${formattedBody}
`; - - const plainBody = blurb || this.content.body || ""; - const bodyLines = plainBody.split("\n"); - bodyLines[0] = `> ${prefix}<${sender}> ${bodyLines[0]}` - const plainFallback = bodyLines.join("\n> "); - - const newBody = plainFallback + '\n\n' + body; - const newFormattedBody = formattedFallback + htmlEscape(body); - return createReply(this.id, msgtype, newBody, newFormattedBody); + return reply(this, msgtype, body); } /** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */ diff --git a/src/matrix/room/timeline/entries/reply.js b/src/matrix/room/timeline/entries/reply.js index 00d591e0..7afae036 100644 --- a/src/matrix/room/timeline/entries/reply.js +++ b/src/matrix/room/timeline/entries/reply.js @@ -1,5 +1,24 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. -export function fallbackBlurb(msgtype) { +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. +*/ + +function htmlEscape(string) { + return string.replace(/&/g, "&").replace(//g, ">"); +} + +function fallbackBlurb(msgtype) { switch (msgtype) { case "m.file": return "sent a file."; @@ -13,11 +32,11 @@ export function fallbackBlurb(msgtype) { return null; } -export function fallbackPrefix(msgtype) { +function fallbackPrefix(msgtype) { return msgtype === "m.emote" ? "* " : ""; } -export function createReply(targetId, msgtype, body, formattedBody) { +function createReply(targetId, msgtype, body, formattedBody) { return { msgtype, body, @@ -31,3 +50,25 @@ export function createReply(targetId, msgtype, body, formattedBody) { }; } +export function reply(entry, msgtype, body) { + // TODO check for absense of sender / body / msgtype / etc? + let blurb = fallbackBlurb(entry.content.msgtype); + const prefix = fallbackPrefix(entry.content.msgtype); + const sender = entry.sender; + const name = entry.displayName || sender; + + const formattedBody = blurb || entry.content.formatted_body || + (entry.content.body && htmlEscape(entry.content.body)) || ""; + const formattedFallback = `
In reply to ${prefix}` + + `${name}
` + + `${formattedBody}
`; + + const plainBody = blurb || entry.content.body || ""; + const bodyLines = plainBody.split("\n"); + bodyLines[0] = `> ${prefix}<${sender}> ${bodyLines[0]}` + const plainFallback = bodyLines.join("\n> "); + + const newBody = plainFallback + '\n\n' + body; + const newFormattedBody = formattedFallback + htmlEscape(body); + return createReply(entry.id, msgtype, newBody, newFormattedBody); +} From fa985f8f16d932a7c20a0acefcdf1356efee6483 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 4 Aug 2021 09:30:02 -0700 Subject: [PATCH 32/54] Blurb isn't really the right word. --- src/matrix/room/timeline/entries/reply.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/matrix/room/timeline/entries/reply.js b/src/matrix/room/timeline/entries/reply.js index 7afae036..0120f332 100644 --- a/src/matrix/room/timeline/entries/reply.js +++ b/src/matrix/room/timeline/entries/reply.js @@ -18,7 +18,7 @@ function htmlEscape(string) { return string.replace(/&/g, "&").replace(//g, ">"); } -function fallbackBlurb(msgtype) { +function fallbackForNonTextualMessage(msgtype) { switch (msgtype) { case "m.file": return "sent a file."; @@ -52,18 +52,18 @@ function createReply(targetId, msgtype, body, formattedBody) { export function reply(entry, msgtype, body) { // TODO check for absense of sender / body / msgtype / etc? - let blurb = fallbackBlurb(entry.content.msgtype); + const nonTextual = fallbackForNonTextualMessage(entry.content.msgtype); const prefix = fallbackPrefix(entry.content.msgtype); const sender = entry.sender; const name = entry.displayName || sender; - const formattedBody = blurb || entry.content.formatted_body || + const formattedBody = nonTextual || entry.content.formatted_body || (entry.content.body && htmlEscape(entry.content.body)) || ""; const formattedFallback = `
In reply to ${prefix}` + `${name}
` + `${formattedBody}
`; - const plainBody = blurb || entry.content.body || ""; + const plainBody = nonTextual || entry.content.body || ""; const bodyLines = plainBody.split("\n"); bodyLines[0] = `> ${prefix}<${sender}> ${bodyLines[0]}` const plainFallback = bodyLines.join("\n> "); From 2375bf061c250a84ba9a7b4de937ff5b36b6c79c Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 4 Aug 2021 10:26:03 -0700 Subject: [PATCH 33/54] Strip relates_to from encrypted events' original contents. --- src/matrix/room/sending/PendingEvent.js | 23 +++++++++++++---------- src/matrix/room/sending/SendQueue.js | 3 ++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index af295579..a6f53059 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -28,9 +28,7 @@ export const SendStatus = createEnum( "Error", ); -const preservedContentFields = { - "m.room.message": [ "m.relates_to" ] -}; +const preservedContentFields = [ "m.relates_to" ]; export class PendingEvent { constructor({data, remove, emitUpdate, attachments}) { @@ -100,14 +98,19 @@ export class PendingEvent { this._emitUpdate("status"); } + get cleanedContent() { + const content = Object.assign({}, this._data.content); + for (const field of preservedContentFields) { + delete content[field]; + } + return content; + } + _preserveContentFields(into) { - const preservedFields = preservedContentFields[this.eventType]; - if (preservedFields) { - const content = this._data.content; - for (const field of preservedFields) { - if (content[field] !== undefined) { - into[field] = content[field]; - } + const content = this._data.content; + for (const field of preservedContentFields) { + if (content[field] !== undefined) { + into[field] = content[field]; } } } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index dd562610..f9cb19f9 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -97,8 +97,9 @@ export class SendQueue { } if (pendingEvent.needsEncryption) { pendingEvent.setEncrypting(); + const cleanedContent = pendingEvent.cleanedContent; const {type, content} = await log.wrap("encrypt", log => this._roomEncryption.encrypt( - pendingEvent.eventType, pendingEvent.content, this._hsApi, log)); + pendingEvent.eventType, cleanedContent, this._hsApi, log)); pendingEvent.setEncrypted(type, content); await this._tryUpdateEvent(pendingEvent); } From 960e3ec469fa8d472e0f2c3a27cdce6917dbd0ac Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 4 Aug 2021 11:08:35 -0700 Subject: [PATCH 34/54] Fix unsubscribing from observed events containing null --- src/matrix/room/ObservedEventMap.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/matrix/room/ObservedEventMap.js b/src/matrix/room/ObservedEventMap.js index 1e21df63..59f0e26a 100644 --- a/src/matrix/room/ObservedEventMap.js +++ b/src/matrix/room/ObservedEventMap.js @@ -25,7 +25,7 @@ export class ObservedEventMap { observe(eventId, eventEntry = null) { let observable = this._map.get(eventId); if (!observable) { - observable = new ObservedEvent(this, eventEntry); + observable = new ObservedEvent(this, eventEntry, eventId); this._map.set(eventId, observable); } return observable; @@ -39,8 +39,8 @@ export class ObservedEventMap { } } - _remove(observable) { - this._map.delete(observable.get().id); + _remove(id) { + this._map.delete(id); if (this._map.size === 0) { this._notifyEmpty(); } @@ -48,16 +48,17 @@ export class ObservedEventMap { } class ObservedEvent extends BaseObservableValue { - constructor(eventMap, entry) { + constructor(eventMap, entry, id) { super(); this._eventMap = eventMap; this._entry = entry; + this._id = id; // remove subscription in microtask after creating it // otherwise ObservedEvents would easily never get // removed if you never subscribe Promise.resolve().then(() => { if (!this.hasSubscriptions) { - this._eventMap.remove(this); + this._eventMap._remove(this._id); this._eventMap = null; } }); @@ -71,7 +72,7 @@ class ObservedEvent extends BaseObservableValue { } onUnsubscribeLast() { - this._eventMap._remove(this); + this._eventMap._remove(this._id); this._eventMap = null; super.onUnsubscribeLast(); } From 06961ff6930afc0450e28f3d7b8d052c02b996e3 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 4 Aug 2021 15:30:35 -0700 Subject: [PATCH 35/54] Add isReply flag to entries --- src/matrix/room/timeline/entries/BaseEventEntry.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 6893b890..b6850540 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -29,6 +29,10 @@ export class BaseEventEntry extends BaseEntry { this._pendingAnnotations = null; } + get isReply() { + return !!this.relation?.["m.in_reply_to"]; + } + get isRedacting() { return !!this._pendingRedactions; } From b5f16468ce7550d049be8bc6cd5d68136012b522 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 4 Aug 2021 15:31:25 -0700 Subject: [PATCH 36/54] Add a flag to strip replies --- .../session/room/timeline/deserialize.js | 19 ++++++++++++++----- .../session/room/timeline/tiles/TextTile.js | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/domain/session/room/timeline/deserialize.js b/src/domain/session/room/timeline/deserialize.js index d7f81316..12d3d80d 100644 --- a/src/domain/session/room/timeline/deserialize.js +++ b/src/domain/session/room/timeline/deserialize.js @@ -34,7 +34,8 @@ const baseUrl = 'https://matrix.to'; const linkPrefix = `${baseUrl}/#/`; class Deserializer { - constructor(result, mediaRepository) { + constructor(result, mediaRepository, allowReplies) { + this.allowReplies = allowReplies; this.result = result; this.mediaRepository = mediaRepository; } @@ -287,6 +288,10 @@ class Deserializer { return true; } + _isAllowedNode(node) { + return this.allowReplies || !this._ensureElement(node, "MX-REPLY"); + } + _parseInlineNodes(nodes, into) { for (const htmlNode of nodes) { if (this._parseTextParts(htmlNode, into)) { @@ -301,7 +306,9 @@ class Deserializer { } // Node is either block or unrecognized. In // both cases, just move on to its children. - this._parseInlineNodes(this.result.getChildNodes(htmlNode), into); + if (this._isAllowedNode(htmlNode)) { + this._parseInlineNodes(this.result.getChildNodes(htmlNode), into); + } } } @@ -325,7 +332,9 @@ class Deserializer { continue; } // Node is unrecognized. Just move on to its children. - this._parseAnyNodes(this.result.getChildNodes(htmlNode), into); + if (this._isAllowedNode(htmlNode)) { + this._parseAnyNodes(this.result.getChildNodes(htmlNode), into); + } } } @@ -336,9 +345,9 @@ class Deserializer { } } -export function parseHTMLBody(platform, mediaRepository, html) { +export function parseHTMLBody(platform, mediaRepository, allowReplies, html) { const parseResult = platform.parseHTML(html); - const deserializer = new Deserializer(parseResult, mediaRepository); + const deserializer = new Deserializer(parseResult, mediaRepository, allowReplies); const parts = deserializer.parseAnyNodes(parseResult.rootNodes); return new MessageBody(html, parts); } diff --git a/src/domain/session/room/timeline/tiles/TextTile.js b/src/domain/session/room/timeline/tiles/TextTile.js index ffd06c1a..3dc830a2 100644 --- a/src/domain/session/room/timeline/tiles/TextTile.js +++ b/src/domain/session/room/timeline/tiles/TextTile.js @@ -54,7 +54,7 @@ export class TextTile extends BaseTextTile { _parseBody(body, format) { if (format === BodyFormat.Html) { - return parseHTMLBody(this.platform, this._mediaRepository, body); + return parseHTMLBody(this.platform, this._mediaRepository, this._entry.isReply, body); } else { return parsePlainBody(body); } From 508214a46be691372441399c2ba7ad8fc34f4cc8 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 4 Aug 2021 15:50:42 -0700 Subject: [PATCH 37/54] Insert emote text after quotes --- src/domain/session/room/timeline/MessageBody.js | 9 +++++++++ .../session/room/timeline/tiles/TextTile.js | 16 ++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/domain/session/room/timeline/MessageBody.js b/src/domain/session/room/timeline/MessageBody.js index d6a61e69..a811425c 100644 --- a/src/domain/session/room/timeline/MessageBody.js +++ b/src/domain/session/room/timeline/MessageBody.js @@ -142,6 +142,15 @@ export class MessageBody { this.sourceString = sourceString; this.parts = parts; } + + insertEmote(string) { + // We want to skip quotes introduced by replies when emoting. + // We assume that such quotes are not TextParts, because replies + // must have a formatted body. + let i = 0; + for (i = 0; i < this.parts.length && this.parts[i].type === "format" && this.parts[i].format === "blockquote"; i++); + this.parts.splice(i, 0, new TextPart(string)); + } } export function tests() { diff --git a/src/domain/session/room/timeline/tiles/TextTile.js b/src/domain/session/room/timeline/tiles/TextTile.js index 3dc830a2..1b235e20 100644 --- a/src/domain/session/room/timeline/tiles/TextTile.js +++ b/src/domain/session/room/timeline/tiles/TextTile.js @@ -20,12 +20,7 @@ import {parseHTMLBody} from "../deserialize.js"; export class TextTile extends BaseTextTile { _getContentString(key) { - const content = this._getContent(); - let val = content?.[key] || ""; - if (content.msgtype === "m.emote") { - val = `* ${this.displayName} ${val}`; - } - return val; + return this._getContent()?.[key] || ""; } _getPlainBody() { @@ -53,10 +48,15 @@ export class TextTile extends BaseTextTile { } _parseBody(body, format) { + let messageBody; if (format === BodyFormat.Html) { - return parseHTMLBody(this.platform, this._mediaRepository, this._entry.isReply, body); + messageBody = parseHTMLBody(this.platform, this._mediaRepository, this._entry.isReply, body); } else { - return parsePlainBody(body); + messageBody = parsePlainBody(body); } + if (this._getContent()?.msgtype === "m.emote") { + messageBody.insertEmote(`* ${this.displayName} `); + } + return messageBody; } } From b4a0c31e1c0d2808545fb763a1899756fe7d7fa2 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 4 Aug 2021 15:54:11 -0700 Subject: [PATCH 38/54] Update test code with new function signature --- src/domain/session/room/timeline/deserialize.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/deserialize.js b/src/domain/session/room/timeline/deserialize.js index 12d3d80d..e937bc7b 100644 --- a/src/domain/session/room/timeline/deserialize.js +++ b/src/domain/session/room/timeline/deserialize.js @@ -398,7 +398,7 @@ export function tests() { }; function test(assert, input, output) { - assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output)); + assert.deepEqual(parseHTMLBody(platform, null, true, input), new MessageBody(input, output)); } return { From a9731f5a1db98395bde3c32c1a0808c4449e4c5e Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Wed, 4 Aug 2021 16:02:37 -0700 Subject: [PATCH 39/54] Clean up code for inserting emotes --- src/domain/session/room/timeline/MessageBody.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/MessageBody.js b/src/domain/session/room/timeline/MessageBody.js index a811425c..b3cf532e 100644 --- a/src/domain/session/room/timeline/MessageBody.js +++ b/src/domain/session/room/timeline/MessageBody.js @@ -137,6 +137,10 @@ export class TextPart { get type() { return "text"; } } +function isBlockquote(part){ + return part.type === "format" && part.format === "blockquote"; +} + export class MessageBody { constructor(sourceString, parts) { this.sourceString = sourceString; @@ -148,7 +152,7 @@ export class MessageBody { // We assume that such quotes are not TextParts, because replies // must have a formatted body. let i = 0; - for (i = 0; i < this.parts.length && this.parts[i].type === "format" && this.parts[i].format === "blockquote"; i++); + for (; i < this.parts.length && isBlockquote(this.parts[i]); i++); this.parts.splice(i, 0, new TextPart(string)); } } From 434882069e1224a3407e663b03d2aacb3fe4e11e Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 5 Aug 2021 09:39:59 -0700 Subject: [PATCH 40/54] Lift tiles creator to RoomViewModel --- src/domain/session/room/RoomViewModel.js | 9 +++++++-- src/domain/session/room/timeline/TimelineViewModel.js | 5 ++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 11f08df1..f2450743 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -18,6 +18,7 @@ limitations under the License. import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; import {ComposerViewModel} from "./ComposerViewModel.js" import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; +import {tilesCreator} from "./timeline/tilesCreator.js"; import {ViewModel} from "../../ViewModel.js"; export class RoomViewModel extends ViewModel { @@ -26,6 +27,7 @@ export class RoomViewModel extends ViewModel { const {room} = options; this._room = room; this._timelineVM = null; + this._tilesCreator = null; this._onRoomChange = this._onRoomChange.bind(this); this._timelineError = null; this._sendError = null; @@ -43,12 +45,15 @@ export class RoomViewModel extends ViewModel { this._room.on("change", this._onRoomChange); try { const timeline = await this._room.openTimeline(); - const timelineVM = this.track(new TimelineViewModel(this.childOptions({ + this._tilesCreator = tilesCreator(this.childOptions({ room: this._room, roomVM: this, timeline, + })); + this._timelineVM = this.track(new TimelineViewModel(this.childOptions({ + tilesCreator: this._tilesCreator, + timeline, }))); - this._timelineVM = timelineVM; this.emitChange("timelineViewModel"); } catch (err) { console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`); diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 5ec21d9a..7b2765f5 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -32,15 +32,14 @@ to the room timeline, which unload entries from memory. when loading, it just reads events from a sortkey backwards or forwards... */ import {TilesCollection} from "./TilesCollection.js"; -import {tilesCreator} from "./tilesCreator.js"; import {ViewModel} from "../../../ViewModel.js"; export class TimelineViewModel extends ViewModel { constructor(options) { super(options); - const {room, timeline, roomVM} = options; + const {timeline, tilesCreator} = options; this._timeline = this.track(timeline); - this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, timeline, roomVM}))); + this._tiles = new TilesCollection(timeline.entries, tilesCreator); } /** From 21b067eaffec5384abd535b3e80ec317dfab5e6d Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Thu, 5 Aug 2021 10:05:50 -0700 Subject: [PATCH 41/54] Create new tiles instead of keeping old ones --- src/domain/session/room/ComposerViewModel.js | 16 +++++++++++++--- src/domain/session/room/RoomViewModel.js | 4 ++++ .../room/timeline/tiles/BaseMessageTile.js | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js index 3ae11872..e3ed317c 100644 --- a/src/domain/session/room/ComposerViewModel.js +++ b/src/domain/session/room/ComposerViewModel.js @@ -5,13 +5,23 @@ export class ComposerViewModel extends ViewModel { super(); this._roomVM = roomVM; this._isEmpty = true; + this._replyId = null; this._replyVM = null; } - setReplyingTo(tile) { - const changed = this._replyVM !== tile; - this._replyVM = tile; + setReplyingTo(entry) { + const newId = entry?.id || null; + const changed = this._replyId !== newId; if (changed) { + this._replyId = newId; + if (this._replyVM) { + this.untrack(this._replyVM); + this._replyVM.dispose(); + } + this._replyVM = entry && this._roomVM._createTile(entry); + if (this._replyVM) { + this.track(this._replyVM); + } this.emitChange("replyViewModel"); } } diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index f2450743..3a420b16 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -160,6 +160,10 @@ export class RoomViewModel extends ViewModel { rejoinRoom() { this._room.join(); } + + _createTile(entry) { + return this._tilesCreator(entry); + } async _sendMessage(message, replyingTo) { if (!this._room.isArchived && message) { diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index eef83cb6..8e7379c6 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -107,7 +107,7 @@ export class BaseMessageTile extends SimpleTile { } startReply() { - this._roomVM.startReply(this); + this._roomVM.startReply(this._entry); } reply(msgtype, body, log = null) { From 542690844482013a78afbfb7a208b8288222888d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 6 Aug 2021 17:49:39 +0200 Subject: [PATCH 42/54] add copyright header --- src/domain/session/room/ComposerViewModel.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js index e3ed317c..1f98a21b 100644 --- a/src/domain/session/room/ComposerViewModel.js +++ b/src/domain/session/room/ComposerViewModel.js @@ -1,3 +1,19 @@ +/* +Copyright 2021 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. +*/ + import {ViewModel} from "../../ViewModel.js"; export class ComposerViewModel extends ViewModel { From 3feaf38252595be95b4521b2a86da7dfd3f1a05e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 6 Aug 2021 17:53:58 +0200 Subject: [PATCH 43/54] use internalId to compare so we don't have to cache the entry id separately --- src/domain/session/room/ComposerViewModel.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js index 1f98a21b..d3ee27a0 100644 --- a/src/domain/session/room/ComposerViewModel.js +++ b/src/domain/session/room/ComposerViewModel.js @@ -21,15 +21,12 @@ export class ComposerViewModel extends ViewModel { super(); this._roomVM = roomVM; this._isEmpty = true; - this._replyId = null; this._replyVM = null; } setReplyingTo(entry) { - const newId = entry?.id || null; - const changed = this._replyId !== newId; + const changed = this._replyVM?.internalId !== entry?.asEventKey().toString(); if (changed) { - this._replyId = newId; if (this._replyVM) { this.untrack(this._replyVM); this._replyVM.dispose(); From 2a923633173ece4c498a3a69ecd65c02dea38139 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 6 Aug 2021 17:56:02 +0200 Subject: [PATCH 44/54] use disposeTracked --- src/domain/session/room/ComposerViewModel.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js index d3ee27a0..3ee7f8e3 100644 --- a/src/domain/session/room/ComposerViewModel.js +++ b/src/domain/session/room/ComposerViewModel.js @@ -27,13 +27,9 @@ export class ComposerViewModel extends ViewModel { setReplyingTo(entry) { const changed = this._replyVM?.internalId !== entry?.asEventKey().toString(); if (changed) { - if (this._replyVM) { - this.untrack(this._replyVM); - this._replyVM.dispose(); - } - this._replyVM = entry && this._roomVM._createTile(entry); - if (this._replyVM) { - this.track(this._replyVM); + this._replyVM = this.disposeTracked(this._replyVM); + if (entry) { + this._replyVM = this.track(this._roomVM._createTile(entry)); } this.emitChange("replyViewModel"); } From 5a0bc55e54ce911d5fc65cf63172fb5cb753c1f4 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 6 Aug 2021 10:16:20 -0700 Subject: [PATCH 45/54] Rename reply function in reply.js --- src/matrix/room/timeline/entries/BaseEventEntry.js | 4 ++-- src/matrix/room/timeline/entries/reply.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index b6850540..ecbacb6e 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -18,7 +18,7 @@ import {BaseEntry} from "./BaseEntry.js"; import {REDACTION_TYPE} from "../../common.js"; import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; import {PendingAnnotation} from "../PendingAnnotation.js"; -import {reply} from "./reply.js" +import {createReplyContent} from "./reply.js" /** Deals mainly with local echo for relations and redactions, * so it is shared between PendingEventEntry and EventEntry */ @@ -157,7 +157,7 @@ export class BaseEventEntry extends BaseEntry { } reply(msgtype, body) { - return reply(this, msgtype, body); + return createReplyContent(this, msgtype, body); } /** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */ diff --git a/src/matrix/room/timeline/entries/reply.js b/src/matrix/room/timeline/entries/reply.js index 0120f332..2e180c11 100644 --- a/src/matrix/room/timeline/entries/reply.js +++ b/src/matrix/room/timeline/entries/reply.js @@ -36,7 +36,7 @@ function fallbackPrefix(msgtype) { return msgtype === "m.emote" ? "* " : ""; } -function createReply(targetId, msgtype, body, formattedBody) { +function _createReplyContent(targetId, msgtype, body, formattedBody) { return { msgtype, body, @@ -50,7 +50,7 @@ function createReply(targetId, msgtype, body, formattedBody) { }; } -export function reply(entry, msgtype, body) { +export function createReplyContent(entry, msgtype, body) { // TODO check for absense of sender / body / msgtype / etc? const nonTextual = fallbackForNonTextualMessage(entry.content.msgtype); const prefix = fallbackPrefix(entry.content.msgtype); @@ -70,5 +70,5 @@ export function reply(entry, msgtype, body) { const newBody = plainFallback + '\n\n' + body; const newFormattedBody = formattedFallback + htmlEscape(body); - return createReply(entry.id, msgtype, newBody, newFormattedBody); + return _createReplyContent(entry.id, msgtype, newBody, newFormattedBody); } From 1207203b00857b9fda94e255c0414ee3a63626ab Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 30 Jul 2021 14:53:00 -0700 Subject: [PATCH 46/54] Prefer relations from encrypted content --- src/matrix/room/timeline/entries/EventEntry.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index f98801f9..55219cd6 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -16,7 +16,7 @@ limitations under the License. import {BaseEventEntry} from "./BaseEventEntry.js"; import {getPrevContentFromStateEvent, isRedacted} from "../../common.js"; -import {getRelatedEventId} from "../relations.js"; +import {getRelationFromContent, getRelatedEventId} from "../relations.js"; export class EventEntry extends BaseEventEntry { constructor(eventEntry, fragmentIdComparer) { @@ -139,6 +139,12 @@ export class EventEntry extends BaseEventEntry { get annotations() { return this._eventEntry.annotations; } + + get relation() { + const originalContent = this._eventEntry.event.content; + const originalRelation = originalContent && getRelationFromContent(originalContent); + return originalRelation || getRelationFromContent(this.content); + } } import {withTextBody, withContent, createEvent} from "../../../../mocks/event.js"; From ac044cb5c2cfc3780e15c29d434c1c490295b49b Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 6 Aug 2021 10:27:17 -0700 Subject: [PATCH 47/54] Rename pending event fields --- src/matrix/room/sending/PendingEvent.js | 8 ++++---- src/matrix/room/sending/SendQueue.js | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index a6f53059..01874178 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -28,7 +28,7 @@ export const SendStatus = createEnum( "Error", ); -const preservedContentFields = [ "m.relates_to" ]; +const unencryptedContentFields = [ "m.relates_to" ]; export class PendingEvent { constructor({data, remove, emitUpdate, attachments}) { @@ -98,9 +98,9 @@ export class PendingEvent { this._emitUpdate("status"); } - get cleanedContent() { + get contentForEncryption() { const content = Object.assign({}, this._data.content); - for (const field of preservedContentFields) { + for (const field of unencryptedContentFields) { delete content[field]; } return content; @@ -108,7 +108,7 @@ export class PendingEvent { _preserveContentFields(into) { const content = this._data.content; - for (const field of preservedContentFields) { + for (const field of unencryptedContentFields) { if (content[field] !== undefined) { into[field] = content[field]; } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index f9cb19f9..fec40397 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -97,9 +97,9 @@ export class SendQueue { } if (pendingEvent.needsEncryption) { pendingEvent.setEncrypting(); - const cleanedContent = pendingEvent.cleanedContent; + const encryptionContent = pendingEvent.contentForEncryption; const {type, content} = await log.wrap("encrypt", log => this._roomEncryption.encrypt( - pendingEvent.eventType, cleanedContent, this._hsApi, log)); + pendingEvent.eventType, encryptionContent, this._hsApi, log)); pendingEvent.setEncrypted(type, content); await this._tryUpdateEvent(pendingEvent); } From 8dc80e68a7825814974f03f53f3c564e81105998 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 6 Aug 2021 10:30:50 -0700 Subject: [PATCH 48/54] Remove out-of-date comment --- src/domain/session/room/timeline/TilesCollection.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 83d3aace..0acb2859 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -181,8 +181,6 @@ export class TilesCollection extends BaseObservableList { } _replaceTile(tileIdx, existingTile, newTile, updateParams) { - // TODO What happens with a tile that's being replied to? Can we have - // reference counting of some sort? existingTile.dispose(); const prevTile = this._getTileAtIdx(tileIdx - 1); const nextTile = this._getTileAtIdx(tileIdx + 1); From 4c1aeb342a1904f39cf45a05ba9bc810a9d887f1 Mon Sep 17 00:00:00 2001 From: Danila Fedorin Date: Fri, 6 Aug 2021 10:35:45 -0700 Subject: [PATCH 49/54] Add two new tests for replies --- .../session/room/timeline/deserialize.js | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/timeline/deserialize.js b/src/domain/session/room/timeline/deserialize.js index e937bc7b..ce690732 100644 --- a/src/domain/session/room/timeline/deserialize.js +++ b/src/domain/session/room/timeline/deserialize.js @@ -397,8 +397,8 @@ export function tests() { parseHTML: (html) => new HTMLParseResult(parse(html)) }; - function test(assert, input, output) { - assert.deepEqual(parseHTMLBody(platform, null, true, input), new MessageBody(input, output)); + function test(assert, input, output, replies=true) { + assert.deepEqual(parseHTMLBody(platform, null, replies, input), new MessageBody(input, output)); } return { @@ -495,6 +495,24 @@ export function tests() { new CodeBlock(null, code) ]; test(assert, input, output); + }, + "Replies are inserted when allowed": assert => { + const input = 'Hello, World!'; + const output = [ + new TextPart('Hello, '), + new FormatPart("em", [new TextPart('World')]), + new TextPart('!'), + ]; + test(assert, input, output); + }, + "Replies are stripped when not allowed": assert => { + const input = 'Hello, World!'; + const output = [ + new TextPart('Hello, '), + new FormatPart("em", []), + new TextPart('!'), + ]; + test(assert, input, output, false); } /* Doesnt work: HTML library doesn't handle
 properly.
         "Text with code block": assert => {

From bf1f288a92f04f77762733904aad9093a6933424 Mon Sep 17 00:00:00 2001
From: Danila Fedorin 
Date: Fri, 6 Aug 2021 10:40:25 -0700
Subject: [PATCH 50/54] Make RoomViewModel's room public and stop feeding it to
 tileCreator

---
 src/domain/session/room/RoomViewModel.js             | 5 ++++-
 src/domain/session/room/timeline/tiles/SimpleTile.js | 2 +-
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js
index 3a420b16..ace933fb 100644
--- a/src/domain/session/room/RoomViewModel.js
+++ b/src/domain/session/room/RoomViewModel.js
@@ -46,7 +46,6 @@ export class RoomViewModel extends ViewModel {
         try {
             const timeline = await this._room.openTimeline();
             this._tilesCreator = tilesCreator(this.childOptions({
-                room: this._room,
                 roomVM: this,
                 timeline,
             }));
@@ -299,6 +298,10 @@ export class RoomViewModel extends ViewModel {
         }
     }
 
+    get room() {
+        return this._room;
+    }
+
     get composerViewModel() {
         return this._composerVM;
     }
diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js
index efaa0a6a..68037a14 100644
--- a/src/domain/session/room/timeline/tiles/SimpleTile.js
+++ b/src/domain/session/room/timeline/tiles/SimpleTile.js
@@ -126,7 +126,7 @@ export class SimpleTile extends ViewModel {
     // TilesCollection contract above
 
     get _room() {
-        return this._options.room;
+        return this._roomVM.room;
     }
 
     get _roomVM() {

From 065b1789be284461abe20a3329697d6b71888ed3 Mon Sep 17 00:00:00 2001
From: Danila Fedorin 
Date: Fri, 6 Aug 2021 10:44:35 -0700
Subject: [PATCH 51/54] Remove interactive condition on continuation

---
 src/platform/web/ui/session/room/timeline/BaseMessageView.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js
index 0fac7bc9..f093c350 100644
--- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js
+++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js
@@ -52,10 +52,10 @@ export class BaseMessageView extends TemplateView {
         // as the avatar or sender doesn't need any bindings or event handlers.
         // don't use `t` from within the side-effect callback
         t.mapSideEffect(vm => vm.isContinuation, (isContinuation, wasContinuation) => {
-            if (isContinuation && this._interactive && wasContinuation === false) {
+            if (isContinuation && wasContinuation === false) {
                 li.removeChild(li.querySelector(".Timeline_messageAvatar"));
                 li.removeChild(li.querySelector(".Timeline_messageSender"));
-            } else if (!isContinuation || !this._interactive) {
+            } else if (!isContinuation) {
                 li.insertBefore(renderStaticAvatar(vm, 30, "Timeline_messageAvatar"), li.firstChild);
                 li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild);
             }

From a8fcf63cf9a5706e54dd24e7773bfdbbc48e1991 Mon Sep 17 00:00:00 2001
From: Danila Fedorin 
Date: Fri, 6 Aug 2021 10:46:38 -0700
Subject: [PATCH 52/54] Make the close button have a pointer cursor

---
 src/platform/web/ui/css/themes/element/theme.css | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css
index 3416c9a8..6a01e6e3 100644
--- a/src/platform/web/ui/css/themes/element/theme.css
+++ b/src/platform/web/ui/css/themes/element/theme.css
@@ -500,6 +500,7 @@ a {
     background-repeat: no-repeat;
     background-position: center;
     background-size: 18px;
+    cursor: pointer;
 }
 
 .MessageComposer_input:first-child {

From 053c94b60e076c63315d8083bf8d55987053d8e2 Mon Sep 17 00:00:00 2001
From: Danila Fedorin 
Date: Fri, 6 Aug 2021 11:02:41 -0700
Subject: [PATCH 53/54] Stop passing room to tiles in tests

---
 src/domain/session/room/timeline/ReactionsViewModel.js | 2 +-
 src/domain/session/room/timeline/tiles/GapTile.js      | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js
index 51226775..65ad391f 100644
--- a/src/domain/session/room/timeline/ReactionsViewModel.js
+++ b/src/domain/session/room/timeline/ReactionsViewModel.js
@@ -222,7 +222,7 @@ export function tests() {
         };
         const tiles = new MappedList(timeline.entries, entry => {
             if (entry.eventType === "m.room.message") {
-                return new BaseMessageTile({entry, room, timeline, platform: {logger}});
+                return new BaseMessageTile({entry, roomVM: {room}, timeline, platform: {logger}});
             }
             return null;
         }, (tile, params, entry) => tile?.updateEntry(entry, params));
diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js
index 1e227876..32d632bf 100644
--- a/src/domain/session/room/timeline/tiles/GapTile.js
+++ b/src/domain/session/room/timeline/tiles/GapTile.js
@@ -91,11 +91,11 @@ export function tests() {
                     tile.updateEntry(newEntry);
                 }
             };
-            const tile = new GapTile({entry: new FragmentBoundaryEntry(fragment, true), room});
+            const tile = new GapTile({entry: new FragmentBoundaryEntry(fragment, true), roomVM: {room}});
             await tile.fill();
             await tile.fill();
             await tile.fill();
             assert.equal(currentToken, 8);
         }
     }
-}
\ No newline at end of file
+}

From 9f0c3b9cea0004e5ad9a6e26e64b0375304cd4eb Mon Sep 17 00:00:00 2001
From: Bruno Windels 
Date: Fri, 6 Aug 2021 18:02:03 +0200
Subject: [PATCH 54/54] await sending a message before clearing composer (this
 was missing all along)

---
 src/domain/session/room/ComposerViewModel.js        | 4 ++--
 src/platform/web/ui/session/room/MessageComposer.js | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js
index 3ee7f8e3..240e7d4c 100644
--- a/src/domain/session/room/ComposerViewModel.js
+++ b/src/domain/session/room/ComposerViewModel.js
@@ -47,8 +47,8 @@ export class ComposerViewModel extends ViewModel {
         return this._roomVM.isEncrypted;
     }
 
-    sendMessage(message) {
-        const success = this._roomVM._sendMessage(message, this._replyVM);
+    async sendMessage(message) {
+        const success = await this._roomVM._sendMessage(message, this._replyVM);
         if (success) {
             this._isEmpty = true;
             this.emitChange("canSend");
diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js
index 20fd3659..5a6ba593 100644
--- a/src/platform/web/ui/session/room/MessageComposer.js
+++ b/src/platform/web/ui/session/room/MessageComposer.js
@@ -69,9 +69,9 @@ export class MessageComposer extends TemplateView {
         this.value.clearReplyingTo();
     }
 
-    _trySend() {
+    async _trySend() {
         this._input.focus();
-        if (this.value.sendMessage(this._input.value)) {
+        if (await this.value.sendMessage(this._input.value)) {
             this._input.value = "";
         }
     }