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
+
+
+
+
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()));