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? 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 diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js new file mode 100644 index 00000000..240e7d4c --- /dev/null +++ b/src/domain/session/room/ComposerViewModel.js @@ -0,0 +1,90 @@ +/* +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 { + constructor(roomVM) { + super(); + this._roomVM = roomVM; + this._isEmpty = true; + this._replyVM = null; + } + + setReplyingTo(entry) { + const changed = this._replyVM?.internalId !== entry?.asEventKey().toString(); + if (changed) { + this._replyVM = this.disposeTracked(this._replyVM); + if (entry) { + this._replyVM = this.track(this._roomVM._createTile(entry)); + } + this.emitChange("replyViewModel"); + } + } + + clearReplyingTo() { + this.setReplyingTo(null); + } + + get replyViewModel() { + return this._replyVM; + } + + get isEncrypted() { + return this._roomVM.isEncrypted; + } + + async sendMessage(message) { + const success = await 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 3b04f83a..ace933fb 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -16,7 +16,9 @@ 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 { @@ -25,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; @@ -42,11 +45,14 @@ 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({ - room: this._room, + this._tilesCreator = tilesCreator(this.childOptions({ + 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}`); @@ -153,8 +159,12 @@ export class RoomViewModel extends ViewModel { rejoinRoom() { this._room.join(); } + + _createTile(entry) { + return this._tilesCreator(entry); + } - async _sendMessage(message) { + async _sendMessage(message, replyingTo) { if (!this._room.isArchived && message) { try { let msgtype = "m.text"; @@ -162,7 +172,11 @@ export class RoomViewModel extends ViewModel { message = message.substr(4).trim(); msgtype = "m.emote"; } - await this._room.sendEvent("m.room.message", {msgtype, body: message}); + if (replyingTo) { + await replyingTo.reply(msgtype, message); + } else { + await this._room.sendEvent("m.room.message", {msgtype, body: message}); + } } catch (err) { console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); this._sendError = err; @@ -284,6 +298,10 @@ export class RoomViewModel extends ViewModel { } } + get room() { + return this._room; + } + get composerViewModel() { return this._composerVM; } @@ -294,57 +312,11 @@ export class RoomViewModel extends ViewModel { path = path.with(this.navigation.segment("details", true)); this.navigation.applyPath(path); } -} -class ComposerViewModel extends ViewModel { - constructor(roomVM) { - super(); - this._roomVM = roomVM; - this._isEmpty = true; - } - - get isEncrypted() { - return this._roomVM.isEncrypted; - } - - sendMessage(message) { - const success = this._roomVM._sendMessage(message); - if (success) { - this._isEmpty = true; - this.emitChange("canSend"); + startReply(entry) { + if (!this._room.isArchived) { + this._composerVM.setReplyingTo(entry); } - 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/timeline/MessageBody.js b/src/domain/session/room/timeline/MessageBody.js index d6a61e69..b3cf532e 100644 --- a/src/domain/session/room/timeline/MessageBody.js +++ b/src/domain/session/room/timeline/MessageBody.js @@ -137,11 +137,24 @@ 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; 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 < this.parts.length && isBlockquote(this.parts[i]); i++); + this.parts.splice(i, 0, new TextPart(string)); + } } export function tests() { diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 051e4de9..3fd9f15f 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/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index d91b9acb..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} = options; + const {timeline, tilesCreator} = options; this._timeline = this.track(timeline); - this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, timeline}))); + this._tiles = new TilesCollection(timeline.entries, tilesCreator); } /** diff --git a/src/domain/session/room/timeline/deserialize.js b/src/domain/session/room/timeline/deserialize.js index d7f81316..ce690732 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); } @@ -388,8 +397,8 @@ export function tests() { parseHTML: (html) => new HTMLParseResult(parse(html)) }; - function test(assert, input, output) { - assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output)); + function test(assert, input, output, replies=true) { + assert.deepEqual(parseHTMLBody(platform, null, replies, input), new MessageBody(input, output)); } return { @@ -486,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 => {
diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js
index 9bd9f0ea..25dbfa38 100644
--- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js
+++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js
@@ -110,6 +110,14 @@ export class BaseMessageTile extends SimpleTile {
         return action;
     }
 
+    startReply() {
+        this._roomVM.startReply(this._entry);
+    }
+
+    reply(msgtype, body, log = null) {
+        return this._room.sendEvent("m.room.message", this._entry.reply(msgtype, body), null, log);
+    }
+
     redact(reason, log) {
         return this._room.sendRedaction(this._entry.id, reason, log);
     }
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
+}
diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js
index 6ec913c0..68037a14 100644
--- a/src/domain/session/room/timeline/tiles/SimpleTile.js
+++ b/src/domain/session/room/timeline/tiles/SimpleTile.js
@@ -126,7 +126,11 @@ export class SimpleTile extends ViewModel {
     // TilesCollection contract above
 
     get _room() {
-        return this._options.room;
+        return this._roomVM.room;
+    }
+
+    get _roomVM() {
+        return this._options.roomVM;
     }
 
     get _timeline() {
diff --git a/src/domain/session/room/timeline/tiles/TextTile.js b/src/domain/session/room/timeline/tiles/TextTile.js
index ffd06c1a..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, 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;
     }
 }
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();
     }
diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js
index 7738847f..01874178 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",
@@ -28,6 +28,8 @@ export const SendStatus = createEnum(
     "Error",
 );
 
+const unencryptedContentFields = [ "m.relates_to" ];
+
 export class PendingEvent {
     constructor({data, remove, emitUpdate, attachments}) {
         this._data = data;
@@ -54,7 +56,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 +65,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;
         }
@@ -96,7 +98,25 @@ export class PendingEvent {
         this._emitUpdate("status");
     }
 
+    get contentForEncryption() {
+        const content = Object.assign({}, this._data.content);
+        for (const field of unencryptedContentFields) {
+            delete content[field];
+        }
+        return content;
+    }
+
+    _preserveContentFields(into) {
+        const content = this._data.content;
+        for (const field of unencryptedContentFields) {
+            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;
diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js
index d6b16ac1..fec40397 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}) {
@@ -97,8 +97,9 @@ export class SendQueue {
         }
         if (pendingEvent.needsEncryption) {
             pendingEvent.setEncrypting();
+            const encryptionContent = pendingEvent.contentForEncryption;
             const {type, content} = await log.wrap("encrypt", log => this._roomEncryption.encrypt(
-                pendingEvent.eventType, pendingEvent.content, this._hsApi, log));
+                pendingEvent.eventType, encryptionContent, this._hsApi, log));
             pendingEvent.setEncrypted(type, content);
             await this._tryUpdateEvent(pendingEvent);
         }
@@ -206,11 +207,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/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js
index 2e681104..ecbacb6e 100644
--- a/src/matrix/room/timeline/entries/BaseEventEntry.js
+++ b/src/matrix/room/timeline/entries/BaseEventEntry.js
@@ -18,6 +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 {createReplyContent} from "./reply.js"
 
 /** Deals mainly with local echo for relations and redactions,
  * so it is shared between PendingEventEntry and EventEntry */
@@ -28,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;
     }
@@ -151,6 +156,10 @@ export class BaseEventEntry extends BaseEntry {
         return createAnnotation(this.id, key);
     }
 
+    reply(msgtype, body) {
+        return createReplyContent(this, msgtype, body);
+    }
+
     /** 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/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";
diff --git a/src/matrix/room/timeline/entries/reply.js b/src/matrix/room/timeline/entries/reply.js
new file mode 100644
index 00000000..2e180c11
--- /dev/null
+++ b/src/matrix/room/timeline/entries/reply.js
@@ -0,0 +1,74 @@
+/*
+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.
+*/
+
+function htmlEscape(string) {
+    return string.replace(/&/g, "&").replace(//g, ">");
+}
+
+function fallbackForNonTextualMessage(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;
+}
+
+function fallbackPrefix(msgtype) {
+    return msgtype === "m.emote" ? "* " : "";
+}
+
+function _createReplyContent(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 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);
+    const sender = entry.sender;
+    const name = entry.displayName || sender;
+
+    const formattedBody = nonTextual || entry.content.formatted_body ||
+        (entry.content.body && htmlEscape(entry.content.body)) || "";
+    const formattedFallback = `
In reply to ${prefix}` + + `${name}
` + + `${formattedBody}
`; + + const plainBody = nonTextual || 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 _createReplyContent(entry.id, msgtype, newBody, newFormattedBody); +} 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; 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) { diff --git a/src/platform/web/ui/css/room.css b/src/platform/web/ui/css/room.css index 23fab8c1..746349c8 100644 --- a/src/platform/web/ui/css/room.css +++ b/src/platform/web/ui/css/room.css @@ -50,12 +50,21 @@ limitations under the License. margin: 0; } -.MessageComposer { +.MessageComposer_replyPreview { + display: grid; + grid-template-columns: 1fr auto; +} + +.MessageComposer_replyPreview .Timeline_message { + grid-column: 1/-1; +} + +.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 b1ef7ab8..08a872b8 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -462,16 +462,56 @@ a { color: red; } -.MessageComposer { - border-top: 1px solid rgba(245, 245, 245, 0.90); +.MessageComposer_replyPreview .Timeline_message { + margin: 0; + margin-top: 5px; + max-height: 30vh; + overflow: auto; +} + +.MessageComposer_replyPreview { + 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 { padding: 8px 16px; } -.MessageComposer > :not(:first-child) { +.MessageComposer_replyPreview > .replying { + display: inline-flex; + flex-direction: row; + align-items: center; + font-weight: bold; +} + +.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; + cursor: pointer; +} + +.MessageComposer_input:first-child { + border-top: 1px solid rgba(245, 245, 245, 0.90); +} + +.MessageComposer_input > :not(:first-child) { margin-left: 12px; } -.MessageComposer > input { +.MessageComposer_input > input { padding: 0 16px; border: none; border-radius: 24px; @@ -481,7 +521,7 @@ a { font-family: "Inter", sans-serif; } -.MessageComposer > button.send { +.MessageComposer_input > button.send { width: 32px; height: 32px; display: block; @@ -496,7 +536,7 @@ a { background-position: center; } -.MessageComposer > button.sendFile { +.MessageComposer_input > button.sendFile { width: 32px; height: 32px; display: block; @@ -510,7 +550,7 @@ a { background-position: center; } -.MessageComposer > button.send:disabled { +.MessageComposer_input > button.send:disabled { background-color: #E3E8F0; } diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 28314b86..408d10cc 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 95f48747..5a6ba593 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -17,6 +17,8 @@ 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"; +import {viewClassForEntry} from "./TimelineList.js" export class MessageComposer extends TemplateView { constructor(viewModel) { @@ -32,7 +34,21 @@ export class MessageComposer extends TemplateView { onKeydown: e => this._onKeyDown(e), onInput: () => vm.setInput(this._input.value), }); - return t.div({className: "MessageComposer"}, [ + 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, false, "div")) + ]) + }); + const input = t.div({className: "MessageComposer_input"}, [ this._input, t.button({ className: "sendFile", @@ -46,11 +62,16 @@ export class MessageComposer extends TemplateView { onClick: () => this._trySend(), }, vm.i18n`Send`), ]); + return t.div({ className: "MessageComposer" }, [replyPreview, input]); } - _trySend() { + _clearReplyingTo() { + this.value.clearReplyingTo(); + } + + async _trySend() { this._input.focus(); - if (this.value.sendMessage(this._input.value)) { + if (await this.value.sendMessage(this._input.value)) { this._input.value = ""; } } 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; diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index af8a6b3a..fb797f98 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -24,23 +24,27 @@ import {Menu} from "../../../general/Menu.js"; import {ReactionsView} from "./ReactionsView.js"; export class BaseMessageView extends TemplateView { - constructor(value) { + 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._interactive = interactive; } 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._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 - t.button({className: "Timeline_messageOptions"}, "⋯"), + this._interactive ? t.button({className: "Timeline_messageOptions"}, "⋯") : [], ]); const avatar = t.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]); // given that there can be many tiles, we don't add @@ -61,7 +65,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 && !reactionsView) { + if (reactions && this._interactive && !reactionsView) { reactionsView = new ReactionsView(vm.reactions); this.addSubView(reactionsView); li.appendChild(mountView(reactionsView)); @@ -113,6 +117,7 @@ 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())); } if (vm.canAbortSending) { options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending()));