diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index de5ea2c3..fa48bec0 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -225,7 +225,7 @@ export function tests() { return new BaseMessageTile({entry, roomVM: {room}, timeline, platform: {logger}}); } return null; - }, (tile, params, entry) => tile?.updateEntry(entry, params)); + }, (tile, params, entry) => tile?.updateEntry(entry, params, function () {})); return tiles; } diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 54ab5ddd..33ae4472 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -150,7 +150,7 @@ export class TilesCollection extends BaseObservableList { const tileIdx = this._findTileIdx(entry); const tile = this._findTileAtIdx(entry, tileIdx); if (tile) { - const action = tile.updateEntry(entry, params); + const action = tile.updateEntry(entry, params, this._tileCreator); if (action.shouldReplace) { const newTile = this._tileCreator(entry); if (newTile) { diff --git a/src/domain/session/room/timeline/deserialize.js b/src/domain/session/room/timeline/deserialize.js index 7da6255d..b59c2e59 100644 --- a/src/domain/session/room/timeline/deserialize.js +++ b/src/domain/session/room/timeline/deserialize.js @@ -34,8 +34,7 @@ const baseUrl = 'https://matrix.to'; const linkPrefix = `${baseUrl}/#/`; class Deserializer { - constructor(result, mediaRepository, allowReplies) { - this.allowReplies = allowReplies; + constructor(result, mediaRepository) { this.result = result; this.mediaRepository = mediaRepository; } @@ -289,7 +288,7 @@ class Deserializer { } _isAllowedNode(node) { - return this.allowReplies || !this._ensureElement(node, "MX-REPLY"); + return !this._ensureElement(node, "MX-REPLY"); } _parseInlineNodes(nodes, into) { @@ -345,9 +344,9 @@ class Deserializer { } } -export function parseHTMLBody(platform, mediaRepository, allowReplies, html) { +export function parseHTMLBody(platform, mediaRepository, html) { const parseResult = platform.parseHTML(html); - const deserializer = new Deserializer(parseResult, mediaRepository, allowReplies); + const deserializer = new Deserializer(parseResult, mediaRepository); const parts = deserializer.parseAnyNodes(parseResult.rootNodes); return new MessageBody(html, parts); } @@ -401,8 +400,8 @@ export async function tests() { parseHTML: (html) => new HTMLParseResult(parse(html)) }; - function test(assert, input, output, replies=true) { - assert.deepEqual(parseHTMLBody(platform, null, replies, input), new MessageBody(input, output)); + function test(assert, input, output) { + assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output)); } return { @@ -500,23 +499,14 @@ export async function tests() { ]; 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 => { + "Reply fallback is always stripped": assert => { const input = 'Hello, World!'; const output = [ new TextPart('Hello, '), new FormatPart("em", []), new TextPart('!'), ]; - test(assert, input, output, false); + assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output)); } /* 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 25dbfa38..d04ee495 100644
--- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js
+++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js
@@ -24,15 +24,25 @@ export class BaseMessageTile extends SimpleTile {
         this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
         this._isContinuation = false;
         this._reactions = null;
+        this._replyTile = null;
         if (this._entry.annotations || this._entry.pendingAnnotations) {
             this._updateReactions();
         }
+        this._updateReplyTileIfNeeded(options.tilesCreator, undefined);
     }
 
     get _mediaRepository() {
         return this._room.mediaRepository;
     }
 
+    get permaLink() {
+        return `https://matrix.to/#/${encodeURIComponent(this._room.id)}/${encodeURIComponent(this._entry.id)}`;
+    }
+
+    get senderProfileLink() {
+        return `https://matrix.to/#/${encodeURIComponent(this.sender)}`;
+    }
+
     get displayName() {
         return this._entry.displayName || this.sender;
     }
@@ -82,6 +92,10 @@ export class BaseMessageTile extends SimpleTile {
         return this._entry.isUnverified;
     }
 
+    get isReply() {
+        return this._entry.isReply;
+    }
+
     _getContent() {
         return this._entry.content;
     }
@@ -102,14 +116,30 @@ export class BaseMessageTile extends SimpleTile {
         }
     }
 
-    updateEntry(entry, param) {
-        const action = super.updateEntry(entry, param);
+    updateEntry(entry, param, tilesCreator) {
+        const action = super.updateEntry(entry, param, tilesCreator);
         if (action.shouldUpdate) {
             this._updateReactions();
         }
+        this._updateReplyTileIfNeeded(tilesCreator, param);
         return action;
     }
 
+    _updateReplyTileIfNeeded(tilesCreator, param) {
+        const replyEntry = this._entry.contextEntry;
+        if (replyEntry) {
+            // this is an update to contextEntry used for replyPreview
+            const action = this._replyTile?.updateEntry(replyEntry, param, tilesCreator);
+            if (action?.shouldReplace || !this._replyTile) {
+                this.disposeTracked(this._replyTile);
+                this._replyTile = tilesCreator(replyEntry);
+            }
+            if(action?.shouldUpdate) {
+                this._replyTile?.emitChange();
+            }
+        }
+    }
+
     startReply() {
         this._roomVM.startReply(this._entry);
     }
@@ -202,4 +232,11 @@ export class BaseMessageTile extends SimpleTile {
             this._reactions.update(annotations, pendingAnnotations);
         }
     }
+
+    get replyTile() {
+        if (!this._entry.contextEventId) {
+            return null;
+        }
+        return this._replyTile;
+    }
 }
diff --git a/src/domain/session/room/timeline/tiles/BaseTextTile.js b/src/domain/session/room/timeline/tiles/BaseTextTile.js
index 60024ca6..164443e3 100644
--- a/src/domain/session/room/timeline/tiles/BaseTextTile.js
+++ b/src/domain/session/room/timeline/tiles/BaseTextTile.js
@@ -56,4 +56,5 @@ export class BaseTextTile extends BaseMessageTile {
         }
         return this._messageBody;
     }
+
 }
diff --git a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js
index b96e2d85..50f507eb 100644
--- a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js
+++ b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js
@@ -18,8 +18,8 @@ import {BaseTextTile} from "./BaseTextTile.js";
 import {UpdateAction} from "../UpdateAction.js";
 
 export class EncryptedEventTile extends BaseTextTile {
-    updateEntry(entry, params) {
-        const parentResult = super.updateEntry(entry, params);
+    updateEntry(entry, params, tilesCreator) {
+        const parentResult = super.updateEntry(entry, params, tilesCreator);
         // event got decrypted, recreate the tile and replace this one with it
         if (entry.eventType !== "m.room.encrypted") {
             // the "shape" parameter trigger tile recreation in TimelineView
diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js
index c1fed69f..df0cedd9 100644
--- a/src/domain/session/room/timeline/tiles/GapTile.js
+++ b/src/domain/session/room/timeline/tiles/GapTile.js
@@ -81,8 +81,8 @@ export class GapTile extends SimpleTile {
         this._siblingChanged = true;
     }
 
-    updateEntry(entry, params) {
-        super.updateEntry(entry, params);
+    updateEntry(entry, params, tilesCreator) {
+        super.updateEntry(entry, params, tilesCreator);
         if (!entry.isGap) {
             return UpdateAction.Remove();
         } else {
diff --git a/src/domain/session/room/timeline/tiles/TextTile.js b/src/domain/session/room/timeline/tiles/TextTile.js
index 1b235e20..fd410af5 100644
--- a/src/domain/session/room/timeline/tiles/TextTile.js
+++ b/src/domain/session/room/timeline/tiles/TextTile.js
@@ -50,7 +50,7 @@ export class TextTile extends BaseTextTile {
     _parseBody(body, format) {
         let messageBody;
         if (format === BodyFormat.Html) {
-            messageBody = parseHTMLBody(this.platform, this._mediaRepository, this._entry.isReply, body);
+            messageBody = parseHTMLBody(this.platform, this._mediaRepository, body);
         } else {
             messageBody = parsePlainBody(body);
         }
diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js
index 9dde00a2..dc9a850e 100644
--- a/src/domain/session/room/timeline/tilesCreator.js
+++ b/src/domain/session/room/timeline/tilesCreator.js
@@ -28,8 +28,8 @@ import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
 import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js";
 
 export function tilesCreator(baseOptions) {
-    return function tilesCreator(entry, emitUpdate) {
-        const options = Object.assign({entry, emitUpdate}, baseOptions);
+    const tilesCreator = function tilesCreator(entry, emitUpdate) {
+        const options = Object.assign({entry, emitUpdate, tilesCreator}, baseOptions);
         if (entry.isGap) {
             return new GapTile(options);
         } else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
@@ -76,5 +76,6 @@ export function tilesCreator(baseOptions) {
                     return null;
             }
         }
-    }   
+    };
+    return tilesCreator;
 }
diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js
index e77e7d8e..90ec29eb 100644
--- a/src/matrix/room/timeline/Timeline.js
+++ b/src/matrix/room/timeline/Timeline.js
@@ -258,8 +258,8 @@ export class Timeline {
         this._addLocalRelationsToNewRemoteEntries(newEntries);
         this._updateEntriesFetchedFromHomeserver(newEntries);
         this._moveEntryToRemoteEntries(newEntries);
-        this._remoteEntries.setManySorted(newEntries);
         this._loadContextEntriesWhereNeeded(newEntries);
+        this._remoteEntries.setManySorted(newEntries);
     }
 
     /**
@@ -320,22 +320,43 @@ export class Timeline {
                 continue;
             }
             const id = entry.contextEventId;
-            let contextEvent = this._findLoadedEventById(id);
+            // before looking into remoteEntries, check the entries
+            // that about to be added first
+            let contextEvent = entries.find(e => e.id === id);
             if (!contextEvent) {
-                contextEvent = await this._getEventFromStorage(id) ?? await this._getEventFromHomeserver(id);
-                if (contextEvent) {
-                    // this entry was created from storage/hs, so it's not tracked by remoteEntries
-                    // we track them here so that we can update reply previews later
-                    this._contextEntriesNotInTimeline.set(id, contextEvent);
-                }
+                contextEvent = this._findLoadedEventById(id);
             }
             if (contextEvent) {
                 entry.setContextEntry(contextEvent);
-                this._emitUpdateForEntry(entry, "contextEntry");
+                // we don't emit an update here, as the add or update
+                // that the callee will emit hasn't been emitted yet.
+            } else {
+                // we don't await here, which is not ideal,
+                // but one of our callers, addEntries, is not async
+                // so there is not much point.
+                // Also, we want to run the entry fetching in parallel.
+                this._loadContextEntryNotInTimeline(entry);
             }
         }
     }
 
+    async _loadContextEntryNotInTimeline(entry) {
+        const id = entry.contextEventId;
+        let contextEvent = await this._getEventFromStorage(id);
+        if (!contextEvent) {
+            contextEvent = await this._getEventFromHomeserver(id);
+        }
+        if (contextEvent) {
+            // this entry was created from storage/hs, so it's not tracked by remoteEntries
+            // we track them here so that we can update reply previews later
+            this._contextEntriesNotInTimeline.set(id, contextEvent);
+            entry.setContextEntry(contextEvent);
+            // here, we awaited something, so from now on we do have to emit
+            // an update if we set the context entry.
+            this._emitUpdateForEntry(entry, "contextEntry");
+        }
+    }
+
     /**
      * Fetches an entry with the given event-id from localEntries, remoteEntries or contextEntriesNotInTimeline.
      * @param {string} eventId event-id of the entry
@@ -366,7 +387,7 @@ export class Timeline {
         }
         return eventEntry;
     }
-    
+
     // tries to prepend `amount` entries to the `entries` list.
     /**
      * [loadAtTop description]
@@ -469,6 +490,7 @@ import {EventEntry} from "./entries/EventEntry.js";
 import {User} from "../../User.js";
 import {PendingEvent} from "../sending/PendingEvent.js";
 import {createAnnotation} from "./relations.js";
+import {redactEvent} from "./common.js";
 
 export function tests() {
     const fragmentIdComparer = new FragmentIdComparer([]);
@@ -742,7 +764,9 @@ export function tests() {
             const entryA = new EventEntry({ event: withTextBody("foo", createEvent("m.room.message", "event_id_1", alice)) });
             const entryB = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_2", bob)), eventIndex: 2 });
             await timeline.load(new User(alice), "join", new NullLogItem());
-            timeline.entries.subscribe({ onAdd: () => null, });
+            timeline.entries.subscribe({
+                onAdd() {},
+            });
             timeline.addEntries([entryA, entryB]);
             assert.deepEqual(entryB.contextEntry, entryA);
         },
@@ -802,21 +826,22 @@ export function tests() {
         "redaction of context entry triggers updates in other entries": async assert => {
             const timeline = new Timeline({roomId, storage: await createMockStorage(), closeCallback: () => {},
                 fragmentIdComparer, pendingEvents: new ObservableArray(), clock: new MockClock(), hsApi});
-            const entryB = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_2", bob)), eventIndex: 2 });
-            const entryC = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_3", bob)), eventIndex: 3 });
+            const entryA = new EventEntry({ event: withTextBody("foo", createEvent("m.room.message", "event_id_1", alice)), eventIndex: 1, fragmentId: 1 });
+            const entryB = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_2", bob)), eventIndex: 2, fragmentId: 1 });
+            const entryC = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_3", bob)), eventIndex: 3, fragmentId: 1 });
             await timeline.load(new User(alice), "join", new NullLogItem());
             const bin = [];
             timeline.entries.subscribe({
-                onUpdate: (index) => {
-                    const e = timeline.remoteEntries[index];
+                onUpdate: (index, e) => {
                     bin.push(e.id);
                 },
                 onAdd: () => null,
             });
-            timeline.addEntries([entryB, entryC]);
-            await poll(() => timeline._remoteEntries.array.length === 2 && timeline._contextEntriesNotInTimeline.get("event_id_1"));
-            const redactingEntry = new EventEntry({ event: withRedacts("event_id_1", "foo", createEvent("m.room.redaction", "event_id_3", alice)) });
-            timeline.addEntries([redactingEntry]);
+            timeline.addEntries([entryA, entryB, entryC]);
+            const eventAClone = JSON.parse(JSON.stringify(entryA.event));
+            redactEvent(withRedacts("event_id_1", "foo", createEvent("m.room.redaction", "event_id_4", alice)), eventAClone);
+            const redactedEntry = new EventEntry({ event: eventAClone, eventIndex: 1, fragmentId: 1 });
+            timeline.replaceEntries([redactedEntry]);
             assert.strictEqual(bin.includes("event_id_2"), true);
             assert.strictEqual(bin.includes("event_id_3"), true);
         },
diff --git a/src/platform/web/main.js b/src/platform/web/main.js
index eb71244a..1729c17c 100644
--- a/src/platform/web/main.js
+++ b/src/platform/web/main.js
@@ -16,7 +16,6 @@ limitations under the License.
 */
 
 // import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay";
-import {Client} from "../../matrix/Client.js";
 import {RootViewModel} from "../../domain/RootViewModel.js";
 import {createNavigation, createRouter} from "../../domain/navigation/index.js";
 // Don't use a default export here, as we use multiple entries during legacy build,
diff --git a/src/platform/web/parsehtml.js b/src/platform/web/parsehtml.js
index ec30b2c9..21c8f39a 100644
--- a/src/platform/web/parsehtml.js
+++ b/src/platform/web/parsehtml.js
@@ -56,7 +56,8 @@ 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']
+    FORBID_TAGS: ['mx-reply'],
+    KEEP_CONTENT: false,
 }
 
 export function parseHTML(html) {
diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css
index 21ec660f..90fee243 100644
--- a/src/platform/web/ui/css/themes/element/timeline.css
+++ b/src/platform/web/ui/css/themes/element/timeline.css
@@ -49,6 +49,17 @@ limitations under the License.
     margin-top: 4px;
 }
 
+.ReplyPreviewView .Timeline_message {
+    display: grid;
+    grid-template: "body" auto;
+    margin-left: 0;
+    padding: 0;
+}
+
+.ReplyPreviewView .Timeline_message:not(.continuation) {
+    margin-top: 0;
+}
+
 @media screen and (max-width: 800px) {
     .Timeline_message {
         grid-template:
diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js
index 0d3d9755..bb175b7d 100644
--- a/src/platform/web/ui/session/room/MessageComposer.js
+++ b/src/platform/web/ui/session/room/MessageComposer.js
@@ -17,7 +17,7 @@ limitations under the License.
 import {TemplateView} from "../../general/TemplateView";
 import {Popup} from "../../general/Popup.js";
 import {Menu} from "../../general/Menu.js";
-import {viewClassForEntry} from "./TimelineView"
+import {viewClassForEntry} from "./common"
 
 export class MessageComposer extends TemplateView {
     constructor(viewModel) {
@@ -56,7 +56,7 @@ export class MessageComposer extends TemplateView {
                         className: "cancel",
                         onClick: () => this._clearReplyingTo()
                     }, "Close"),
-                    t.view(new View(rvm, false, "div"))
+                t.view(new View(rvm, { interactive: false }, "div"))
                 ])
         });
         const input = t.div({className: "MessageComposer_input"}, [
diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts
index 62b7656a..936b8c7c 100644
--- a/src/platform/web/ui/session/room/TimelineView.ts
+++ b/src/platform/web/ui/session/room/TimelineView.ts
@@ -14,15 +14,11 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import type {TileView} from "./common";
+import {viewClassForEntry} from "./common";
 import {ListView} from "../../general/ListView";
 import {TemplateView, Builder} from "../../general/TemplateView";
 import {IObservableValue} from "../../general/BaseUpdateView";
-import {GapView} from "./timeline/GapView.js";
-import {TextMessageView} from "./timeline/TextMessageView.js";
-import {ImageView} from "./timeline/ImageView.js";
-import {VideoView} from "./timeline/VideoView.js";
-import {FileView} from "./timeline/FileView.js";
-import {LocationView} from "./timeline/LocationView.js";
 import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
 import {AnnouncementView} from "./timeline/AnnouncementView.js";
 import {RedactedView} from "./timeline/RedactedView.js";
@@ -36,27 +32,6 @@ export interface TimelineViewModel extends IObservableValue {
     setVisibleTileRange(start?: SimpleTile, end?: SimpleTile);
 }
 
-type TileView = GapView | AnnouncementView | TextMessageView |
-    ImageView | VideoView | FileView | MissingAttachmentView | RedactedView;
-type TileViewConstructor = (this: TileView, SimpleTile) => void;
-
-export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | undefined {
-    switch (entry.shape) {
-        case "gap": return GapView;
-        case "announcement": return AnnouncementView;
-        case "message":
-        case "message-status":
-            return TextMessageView;
-        case "image": return ImageView;
-        case "video": return VideoView;
-        case "file": return FileView;
-        case "location": return LocationView;
-        case "missing-attachment": return MissingAttachmentView;
-        case "redacted":
-            return RedactedView;
-    }
-}
-
 function bottom(node: HTMLElement): number {
     return node.offsetTop + node.clientHeight;
 }
diff --git a/src/platform/web/ui/session/room/common.ts b/src/platform/web/ui/session/room/common.ts
new file mode 100644
index 00000000..5048211a
--- /dev/null
+++ b/src/platform/web/ui/session/room/common.ts
@@ -0,0 +1,55 @@
+/*
+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 {TextMessageView} from "./timeline/TextMessageView.js";
+import {ImageView} from "./timeline/ImageView.js";
+import {VideoView} from "./timeline/VideoView.js";
+import {FileView} from "./timeline/FileView.js";
+import {LocationView} from "./timeline/LocationView.js";
+import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
+import {AnnouncementView} from "./timeline/AnnouncementView.js";
+import {RedactedView} from "./timeline/RedactedView.js";
+import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
+import {GapView} from "./timeline/GapView.js";
+
+export type TileView = GapView | AnnouncementView | TextMessageView |
+    ImageView | VideoView | FileView | LocationView | MissingAttachmentView | RedactedView;
+
+// TODO: this is what works for a ctor but doesn't actually check we constrain the returned ctors to the types above
+type TileViewConstructor = (this: TileView, SimpleTile) => void;
+export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | undefined {
+    switch (entry.shape) {
+        case "gap":
+            return GapView;
+        case "announcement":
+            return AnnouncementView;
+        case "message":
+        case "message-status":
+            return TextMessageView;
+        case "image":
+            return ImageView;
+        case "video":
+            return VideoView;
+        case "file":
+            return FileView;
+        case "location":
+            return LocationView;
+        case "missing-attachment":
+            return MissingAttachmentView;
+        case "redacted":
+            return RedactedView;
+    }
+}
diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js
index 1fa14841..a6fbb9be 100644
--- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js
+++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js
@@ -24,14 +24,17 @@ import {Menu} from "../../../general/Menu.js";
 import {ReactionsView} from "./ReactionsView.js";
 
 export class BaseMessageView extends TemplateView {
-    constructor(value, interactive = true, tagName = "li") {
+    constructor(value, renderFlags, 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;
+        this._renderFlags = renderFlags;
     }
 
+    get _interactive() { return this._renderFlags?.interactive ?? true; }
+    get _isReplyPreview() { return this._renderFlags?.reply; }
+
     render(t, vm) {
         const children = [this.renderMessageBody(t, vm)];
         if (this._interactive) {
@@ -54,7 +57,7 @@ export class BaseMessageView extends TemplateView {
             if (isContinuation && wasContinuation === false) {
                 li.removeChild(li.querySelector(".Timeline_messageAvatar"));
                 li.removeChild(li.querySelector(".Timeline_messageSender"));
-            } else if (!isContinuation) {
+            } else if (!isContinuation && !this._isReplyPreview) {
                 const avatar = tag.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]);
                 const sender = tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName);
                 li.insertBefore(avatar, li.firstChild);
@@ -104,7 +107,7 @@ export class BaseMessageView extends TemplateView {
 
     createMenuOptions(vm) {
         const options = [];
-        if (vm.canReact && vm.shape !== "redacted") {
+        if (vm.canReact && vm.shape !== "redacted" && !vm.isPending) {
             options.push(new QuickReactionsMenuOption(vm));
             options.push(Menu.option(vm.i18n`Reply`, () => vm.startReply()));
         }
diff --git a/src/platform/web/ui/session/room/timeline/ReplyPreviewView.js b/src/platform/web/ui/session/room/timeline/ReplyPreviewView.js
new file mode 100644
index 00000000..3c52fc71
--- /dev/null
+++ b/src/platform/web/ui/session/room/timeline/ReplyPreviewView.js
@@ -0,0 +1,49 @@
+/*
+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 {renderStaticAvatar} from "../../../avatar";
+import {TemplateView} from "../../../general/TemplateView";
+import {viewClassForEntry} from "../common";
+
+export class ReplyPreviewView extends TemplateView {
+    render(t, vm) {
+        const viewClass = viewClassForEntry(vm);
+        if (!viewClass) {
+            throw new Error(`Shape ${vm.shape} is unrecognized.`)
+        }
+        const view = new viewClass(vm, { reply: true, interactive: false });
+        return t.div(
+            { className: "ReplyPreviewView" },
+            t.blockquote([
+                t.a({ className: "link", href: vm.permaLink }, "In reply to"),
+                t.a({ className: "pill", href: vm.senderProfileLink }, [
+                    renderStaticAvatar(vm, 12, undefined, true),
+                    vm.displayName,
+                ]),
+                t.br(),
+                t.view(view),
+            ])
+        );
+    }
+}
+
+export class ReplyPreviewError extends TemplateView {
+    render(t) {
+        return t.blockquote({ className: "ReplyPreviewView" }, [
+            t.div({ className: "Timeline_messageBody statusMessage" }, "This reply could not be found.")
+        ]);
+    }
+}
diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js
index c1674501..510a676f 100644
--- a/src/platform/web/ui/session/room/timeline/TextMessageView.js
+++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js
@@ -16,6 +16,7 @@ limitations under the License.
 
 import {tag, text} from "../../../general/html";
 import {BaseMessageView} from "./BaseMessageView.js";
+import {ReplyPreviewError, ReplyPreviewView} from "./ReplyPreviewView.js";
 
 export class TextMessageView extends BaseMessageView {
     renderMessageBody(t, vm) {
@@ -24,10 +25,27 @@ export class TextMessageView extends BaseMessageView {
             className: {
                 "Timeline_messageBody": true,
                 statusMessage: vm => vm.shape === "message-status",
-            },
-        });
+            }
+        }, t.mapView(vm => vm.replyTile, replyTile => {
+            if (this._isReplyPreview) {
+                // if this._isReplyPreview = true, this is already a reply preview, don't nest replies for now.
+                return null;
+            }
+            else if (vm.isReply && !replyTile) {
+                return new ReplyPreviewError();
+            }
+            else if (replyTile) {
+                return new ReplyPreviewView(replyTile);
+            }
+            else {
+                return null;
+            }
+        }));
+
+        const shouldRemove = (element) => element?.nodeType === Node.ELEMENT_NODE && element.className !== "ReplyPreviewView";
+
         t.mapSideEffect(vm => vm.body, body => {
-            while (container.lastChild) {
+            while (shouldRemove(container.lastChild)) {
                 container.removeChild(container.lastChild);
             }
             for (const part of body.parts) {
@@ -35,6 +53,7 @@ export class TextMessageView extends BaseMessageView {
             }
             container.appendChild(time);
         });
+
         return container;
     }
 }