Merge pull request #418 from vector-im/replies

Sending replies
This commit is contained in:
Bruno Windels 2021-08-06 21:28:42 +00:00 committed by GitHub
commit c3177b06bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 470 additions and 113 deletions

View file

@ -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:
```
<mx-reply>
<blockquote>
<a href="https://matrix.to/#/!somewhere:example.org/$event:example.org">In reply to</a>
<a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a>
<br />
<!-- This is where the related event's HTML would be. -->
</blockquote>
</mx-reply>
```
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?

View file

@ -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

View file

@ -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";
}
}

View file

@ -16,7 +16,9 @@ limitations under the License.
*/ */
import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; import {TimelineViewModel} from "./timeline/TimelineViewModel.js";
import {ComposerViewModel} from "./ComposerViewModel.js"
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
import {tilesCreator} from "./timeline/tilesCreator.js";
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel.js";
export class RoomViewModel extends ViewModel { export class RoomViewModel extends ViewModel {
@ -25,6 +27,7 @@ export class RoomViewModel extends ViewModel {
const {room} = options; const {room} = options;
this._room = room; this._room = room;
this._timelineVM = null; this._timelineVM = null;
this._tilesCreator = null;
this._onRoomChange = this._onRoomChange.bind(this); this._onRoomChange = this._onRoomChange.bind(this);
this._timelineError = null; this._timelineError = null;
this._sendError = null; this._sendError = null;
@ -42,11 +45,14 @@ export class RoomViewModel extends ViewModel {
this._room.on("change", this._onRoomChange); this._room.on("change", this._onRoomChange);
try { try {
const timeline = await this._room.openTimeline(); const timeline = await this._room.openTimeline();
const timelineVM = this.track(new TimelineViewModel(this.childOptions({ this._tilesCreator = tilesCreator(this.childOptions({
room: this._room, roomVM: this,
timeline,
}));
this._timelineVM = this.track(new TimelineViewModel(this.childOptions({
tilesCreator: this._tilesCreator,
timeline, timeline,
}))); })));
this._timelineVM = timelineVM;
this.emitChange("timelineViewModel"); this.emitChange("timelineViewModel");
} catch (err) { } catch (err) {
console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`); console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`);
@ -153,8 +159,12 @@ export class RoomViewModel extends ViewModel {
rejoinRoom() { rejoinRoom() {
this._room.join(); this._room.join();
} }
_createTile(entry) {
return this._tilesCreator(entry);
}
async _sendMessage(message) { async _sendMessage(message, replyingTo) {
if (!this._room.isArchived && message) { if (!this._room.isArchived && message) {
try { try {
let msgtype = "m.text"; let msgtype = "m.text";
@ -162,7 +172,11 @@ export class RoomViewModel extends ViewModel {
message = message.substr(4).trim(); message = message.substr(4).trim();
msgtype = "m.emote"; 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) { } catch (err) {
console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`);
this._sendError = err; this._sendError = err;
@ -284,6 +298,10 @@ export class RoomViewModel extends ViewModel {
} }
} }
get room() {
return this._room;
}
get composerViewModel() { get composerViewModel() {
return this._composerVM; return this._composerVM;
} }
@ -294,57 +312,11 @@ export class RoomViewModel extends ViewModel {
path = path.with(this.navigation.segment("details", true)); path = path.with(this.navigation.segment("details", true));
this.navigation.applyPath(path); this.navigation.applyPath(path);
} }
}
class ComposerViewModel extends ViewModel { startReply(entry) {
constructor(roomVM) { if (!this._room.isArchived) {
super(); this._composerVM.setReplyingTo(entry);
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");
} }
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";
} }
} }

View file

@ -137,11 +137,24 @@ export class TextPart {
get type() { return "text"; } get type() { return "text"; }
} }
function isBlockquote(part){
return part.type === "format" && part.format === "blockquote";
}
export class MessageBody { export class MessageBody {
constructor(sourceString, parts) { constructor(sourceString, parts) {
this.sourceString = sourceString; this.sourceString = sourceString;
this.parts = parts; 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() { export function tests() {

View file

@ -222,7 +222,7 @@ export function tests() {
}; };
const tiles = new MappedList(timeline.entries, entry => { const tiles = new MappedList(timeline.entries, entry => {
if (entry.eventType === "m.room.message") { 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; return null;
}, (tile, params, entry) => tile?.updateEntry(entry, params)); }, (tile, params, entry) => tile?.updateEntry(entry, params));

View file

@ -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... when loading, it just reads events from a sortkey backwards or forwards...
*/ */
import {TilesCollection} from "./TilesCollection.js"; import {TilesCollection} from "./TilesCollection.js";
import {tilesCreator} from "./tilesCreator.js";
import {ViewModel} from "../../../ViewModel.js"; import {ViewModel} from "../../../ViewModel.js";
export class TimelineViewModel extends ViewModel { export class TimelineViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {room, timeline} = options; const {timeline, tilesCreator} = options;
this._timeline = this.track(timeline); this._timeline = this.track(timeline);
this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, timeline}))); this._tiles = new TilesCollection(timeline.entries, tilesCreator);
} }
/** /**

View file

@ -34,7 +34,8 @@ const baseUrl = 'https://matrix.to';
const linkPrefix = `${baseUrl}/#/`; const linkPrefix = `${baseUrl}/#/`;
class Deserializer { class Deserializer {
constructor(result, mediaRepository) { constructor(result, mediaRepository, allowReplies) {
this.allowReplies = allowReplies;
this.result = result; this.result = result;
this.mediaRepository = mediaRepository; this.mediaRepository = mediaRepository;
} }
@ -287,6 +288,10 @@ class Deserializer {
return true; return true;
} }
_isAllowedNode(node) {
return this.allowReplies || !this._ensureElement(node, "MX-REPLY");
}
_parseInlineNodes(nodes, into) { _parseInlineNodes(nodes, into) {
for (const htmlNode of nodes) { for (const htmlNode of nodes) {
if (this._parseTextParts(htmlNode, into)) { if (this._parseTextParts(htmlNode, into)) {
@ -301,7 +306,9 @@ class Deserializer {
} }
// Node is either block or unrecognized. In // Node is either block or unrecognized. In
// both cases, just move on to its children. // 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; continue;
} }
// Node is unrecognized. Just move on to its children. // 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 parseResult = platform.parseHTML(html);
const deserializer = new Deserializer(parseResult, mediaRepository); const deserializer = new Deserializer(parseResult, mediaRepository, allowReplies);
const parts = deserializer.parseAnyNodes(parseResult.rootNodes); const parts = deserializer.parseAnyNodes(parseResult.rootNodes);
return new MessageBody(html, parts); return new MessageBody(html, parts);
} }
@ -388,8 +397,8 @@ export function tests() {
parseHTML: (html) => new HTMLParseResult(parse(html)) parseHTML: (html) => new HTMLParseResult(parse(html))
}; };
function test(assert, input, output) { function test(assert, input, output, replies=true) {
assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output)); assert.deepEqual(parseHTMLBody(platform, null, replies, input), new MessageBody(input, output));
} }
return { return {
@ -486,6 +495,24 @@ export function tests() {
new CodeBlock(null, code) new CodeBlock(null, code)
]; ];
test(assert, input, output); test(assert, input, output);
},
"Replies are inserted when allowed": assert => {
const input = 'Hello, <em><mx-reply>World</mx-reply></em>!';
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, <em><mx-reply>World</mx-reply></em>!';
const output = [
new TextPart('Hello, '),
new FormatPart("em", []),
new TextPart('!'),
];
test(assert, input, output, false);
} }
/* Doesnt work: HTML library doesn't handle <pre><code> properly. /* Doesnt work: HTML library doesn't handle <pre><code> properly.
"Text with code block": assert => { "Text with code block": assert => {

View file

@ -110,6 +110,14 @@ export class BaseMessageTile extends SimpleTile {
return action; 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) { redact(reason, log) {
return this._room.sendRedaction(this._entry.id, reason, log); return this._room.sendRedaction(this._entry.id, reason, log);
} }

View file

@ -91,11 +91,11 @@ export function tests() {
tile.updateEntry(newEntry); 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(); await tile.fill();
await tile.fill(); await tile.fill();
assert.equal(currentToken, 8); assert.equal(currentToken, 8);
} }
} }
} }

View file

@ -126,7 +126,11 @@ export class SimpleTile extends ViewModel {
// TilesCollection contract above // TilesCollection contract above
get _room() { get _room() {
return this._options.room; return this._roomVM.room;
}
get _roomVM() {
return this._options.roomVM;
} }
get _timeline() { get _timeline() {

View file

@ -20,12 +20,7 @@ import {parseHTMLBody} from "../deserialize.js";
export class TextTile extends BaseTextTile { export class TextTile extends BaseTextTile {
_getContentString(key) { _getContentString(key) {
const content = this._getContent(); return this._getContent()?.[key] || "";
let val = content?.[key] || "";
if (content.msgtype === "m.emote") {
val = `* ${this.displayName} ${val}`;
}
return val;
} }
_getPlainBody() { _getPlainBody() {
@ -53,10 +48,15 @@ export class TextTile extends BaseTextTile {
} }
_parseBody(body, format) { _parseBody(body, format) {
let messageBody;
if (format === BodyFormat.Html) { if (format === BodyFormat.Html) {
return parseHTMLBody(this.platform, this._mediaRepository, body); messageBody = parseHTMLBody(this.platform, this._mediaRepository, this._entry.isReply, body);
} else { } else {
return parsePlainBody(body); messageBody = parsePlainBody(body);
} }
if (this._getContent()?.msgtype === "m.emote") {
messageBody.insertEmote(`* ${this.displayName} `);
}
return messageBody;
} }
} }

View file

@ -25,7 +25,7 @@ export class ObservedEventMap {
observe(eventId, eventEntry = null) { observe(eventId, eventEntry = null) {
let observable = this._map.get(eventId); let observable = this._map.get(eventId);
if (!observable) { if (!observable) {
observable = new ObservedEvent(this, eventEntry); observable = new ObservedEvent(this, eventEntry, eventId);
this._map.set(eventId, observable); this._map.set(eventId, observable);
} }
return observable; return observable;
@ -39,8 +39,8 @@ export class ObservedEventMap {
} }
} }
_remove(observable) { _remove(id) {
this._map.delete(observable.get().id); this._map.delete(id);
if (this._map.size === 0) { if (this._map.size === 0) {
this._notifyEmpty(); this._notifyEmpty();
} }
@ -48,16 +48,17 @@ export class ObservedEventMap {
} }
class ObservedEvent extends BaseObservableValue { class ObservedEvent extends BaseObservableValue {
constructor(eventMap, entry) { constructor(eventMap, entry, id) {
super(); super();
this._eventMap = eventMap; this._eventMap = eventMap;
this._entry = entry; this._entry = entry;
this._id = id;
// remove subscription in microtask after creating it // remove subscription in microtask after creating it
// otherwise ObservedEvents would easily never get // otherwise ObservedEvents would easily never get
// removed if you never subscribe // removed if you never subscribe
Promise.resolve().then(() => { Promise.resolve().then(() => {
if (!this.hasSubscriptions) { if (!this.hasSubscriptions) {
this._eventMap.remove(this); this._eventMap._remove(this._id);
this._eventMap = null; this._eventMap = null;
} }
}); });
@ -71,7 +72,7 @@ class ObservedEvent extends BaseObservableValue {
} }
onUnsubscribeLast() { onUnsubscribeLast() {
this._eventMap._remove(this); this._eventMap._remove(this._id);
this._eventMap = null; this._eventMap = null;
super.onUnsubscribeLast(); super.onUnsubscribeLast();
} }

View file

@ -16,7 +16,7 @@ limitations under the License.
import {createEnum} from "../../../utils/enum.js"; import {createEnum} from "../../../utils/enum.js";
import {AbortError} from "../../../utils/error.js"; import {AbortError} from "../../../utils/error.js";
import {REDACTION_TYPE} from "../common.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( export const SendStatus = createEnum(
"Waiting", "Waiting",
@ -28,6 +28,8 @@ export const SendStatus = createEnum(
"Error", "Error",
); );
const unencryptedContentFields = [ "m.relates_to" ];
export class PendingEvent { export class PendingEvent {
constructor({data, remove, emitUpdate, attachments}) { constructor({data, remove, emitUpdate, attachments}) {
this._data = data; this._data = data;
@ -54,7 +56,7 @@ export class PendingEvent {
const relation = getRelationFromContent(this.content); const relation = getRelationFromContent(this.content);
if (relation) { if (relation) {
// may be null when target is not sent yet, is intended // may be null when target is not sent yet, is intended
return relation.event_id; return getRelationTarget(relation);
} else { } else {
return this._data.relatedEventId; return this._data.relatedEventId;
} }
@ -63,7 +65,7 @@ export class PendingEvent {
setRelatedEventId(eventId) { setRelatedEventId(eventId) {
const relation = getRelationFromContent(this.content); const relation = getRelationFromContent(this.content);
if (relation) { if (relation) {
relation.event_id = eventId; setRelationTarget(relation, eventId);
} else { } else {
this._data.relatedEventId = eventId; this._data.relatedEventId = eventId;
} }
@ -96,7 +98,25 @@ export class PendingEvent {
this._emitUpdate("status"); 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) { setEncrypted(type, content) {
this._preserveContentFields(content);
this._data.encryptedEventType = type; this._data.encryptedEventType = type;
this._data.encryptedContent = content; this._data.encryptedContent = content;
this._data.needsEncryption = false; this._data.needsEncryption = false;

View file

@ -19,7 +19,7 @@ import {ConnectionError} from "../../error.js";
import {PendingEvent, SendStatus} from "./PendingEvent.js"; import {PendingEvent, SendStatus} from "./PendingEvent.js";
import {makeTxnId, isTxnId} from "../../common.js"; import {makeTxnId, isTxnId} from "../../common.js";
import {REDACTION_TYPE} 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 { export class SendQueue {
constructor({roomId, storage, hsApi, pendingEvents}) { constructor({roomId, storage, hsApi, pendingEvents}) {
@ -97,8 +97,9 @@ export class SendQueue {
} }
if (pendingEvent.needsEncryption) { if (pendingEvent.needsEncryption) {
pendingEvent.setEncrypting(); pendingEvent.setEncrypting();
const encryptionContent = pendingEvent.contentForEncryption;
const {type, content} = await log.wrap("encrypt", log => this._roomEncryption.encrypt( 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); pendingEvent.setEncrypted(type, content);
await this._tryUpdateEvent(pendingEvent); await this._tryUpdateEvent(pendingEvent);
} }
@ -206,11 +207,13 @@ export class SendQueue {
const relation = getRelationFromContent(content); const relation = getRelationFromContent(content);
let relatedTxnId = null; let relatedTxnId = null;
if (relation) { if (relation) {
if (isTxnId(relation.event_id)) { const relationTarget = getRelationTarget(relation);
relatedTxnId = relation.event_id; if (isTxnId(relationTarget)) {
relation.event_id = null; relatedTxnId = relationTarget;
setRelationTarget(relation, null);
} }
if (relation.rel_type === ANNOTATION_RELATION_TYPE) { 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 isAlreadyAnnotating = this._pendingEvents.array.some(pe => {
const r = getRelationFromContent(pe.content); const r = getRelationFromContent(pe.content);
return pe.eventType === eventType && r && r.key === relation.key && return pe.eventType === eventType && r && r.key === relation.key &&

View file

@ -18,6 +18,7 @@ import {BaseEntry} from "./BaseEntry.js";
import {REDACTION_TYPE} from "../../common.js"; import {REDACTION_TYPE} from "../../common.js";
import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js";
import {PendingAnnotation} from "../PendingAnnotation.js"; import {PendingAnnotation} from "../PendingAnnotation.js";
import {createReplyContent} from "./reply.js"
/** Deals mainly with local echo for relations and redactions, /** Deals mainly with local echo for relations and redactions,
* so it is shared between PendingEventEntry and EventEntry */ * so it is shared between PendingEventEntry and EventEntry */
@ -28,6 +29,10 @@ export class BaseEventEntry extends BaseEntry {
this._pendingAnnotations = null; this._pendingAnnotations = null;
} }
get isReply() {
return !!this.relation?.["m.in_reply_to"];
}
get isRedacting() { get isRedacting() {
return !!this._pendingRedactions; return !!this._pendingRedactions;
} }
@ -151,6 +156,10 @@ export class BaseEventEntry extends BaseEntry {
return createAnnotation(this.id, key); 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 */ /** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */
isRelatedToId(id) { isRelatedToId(id) {
return id && this.relatedEventId === id; return id && this.relatedEventId === id;

View file

@ -16,7 +16,7 @@ limitations under the License.
import {BaseEventEntry} from "./BaseEventEntry.js"; import {BaseEventEntry} from "./BaseEventEntry.js";
import {getPrevContentFromStateEvent, isRedacted} from "../../common.js"; import {getPrevContentFromStateEvent, isRedacted} from "../../common.js";
import {getRelatedEventId} from "../relations.js"; import {getRelationFromContent, getRelatedEventId} from "../relations.js";
export class EventEntry extends BaseEventEntry { export class EventEntry extends BaseEventEntry {
constructor(eventEntry, fragmentIdComparer) { constructor(eventEntry, fragmentIdComparer) {
@ -139,6 +139,12 @@ export class EventEntry extends BaseEventEntry {
get annotations() { get annotations() {
return this._eventEntry.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"; import {withTextBody, withContent, createEvent} from "../../../../mocks/event.js";

View file

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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 = `<mx-reply><blockquote>In reply to ${prefix}` +
`<a href="https://matrix.to/#/${sender}">${name}</a><br />` +
`${formattedBody}</blockquote></mx-reply>`;
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);
}

View file

@ -30,7 +30,8 @@ export class RelationWriter {
const {relatedEventId} = sourceEntry; const {relatedEventId} = sourceEntry;
if (relatedEventId) { if (relatedEventId) {
const relation = getRelation(sourceEntry.event); 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); txn.timelineRelations.add(this._roomId, relation.event_id, relation.rel_type, sourceEntry.id);
} }
const target = await txn.timelineEvents.getByEventId(this._roomId, relatedEventId); const target = await txn.timelineEvents.getByEventId(this._roomId, relatedEventId);
@ -120,7 +121,7 @@ export class RelationWriter {
log.set("id", redactedEvent.event_id); log.set("id", redactedEvent.event_id);
const relation = getRelation(redactedEvent); 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); 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 // check if we're the target of a relation and remove all relations then as well

View file

@ -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) { export function getRelatedEventId(event) {
if (event.type === REDACTION_TYPE) { if (event.type === REDACTION_TYPE) {
return event.redacts; return event.redacts;
} else { } else {
const relation = getRelation(event); const relation = getRelation(event);
if (relation) { if (relation) {
return relation.event_id; return getRelationTarget(relation);
} }
} }
return null; return null;

View file

@ -56,6 +56,7 @@ class HTMLParseResult {
const sanitizeConfig = { const sanitizeConfig = {
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx|mxc):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i, 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) { export function parseHTML(html) {

View file

@ -50,12 +50,21 @@ limitations under the License.
margin: 0; margin: 0;
} }
.MessageComposer { .MessageComposer_replyPreview {
display: grid;
grid-template-columns: 1fr auto;
}
.MessageComposer_replyPreview .Timeline_message {
grid-column: 1/-1;
}
.MessageComposer_input {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.MessageComposer > input { .MessageComposer_input > input {
display: block; display: block;
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View file

@ -118,7 +118,7 @@ a {
color: red; color: red;
} }
.MessageComposer > input { .MessageComposer_input > input {
padding: 0.8em; padding: 0.8em;
border: none; border: none;
} }

View file

@ -462,16 +462,56 @@ a {
color: red; color: red;
} }
.MessageComposer { .MessageComposer_replyPreview .Timeline_message {
border-top: 1px solid rgba(245, 245, 245, 0.90); 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; 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; margin-left: 12px;
} }
.MessageComposer > input { .MessageComposer_input > input {
padding: 0 16px; padding: 0 16px;
border: none; border: none;
border-radius: 24px; border-radius: 24px;
@ -481,7 +521,7 @@ a {
font-family: "Inter", sans-serif; font-family: "Inter", sans-serif;
} }
.MessageComposer > button.send { .MessageComposer_input > button.send {
width: 32px; width: 32px;
height: 32px; height: 32px;
display: block; display: block;
@ -496,7 +536,7 @@ a {
background-position: center; background-position: center;
} }
.MessageComposer > button.sendFile { .MessageComposer_input > button.sendFile {
width: 32px; width: 32px;
height: 32px; height: 32px;
display: block; display: block;
@ -510,7 +550,7 @@ a {
background-position: center; background-position: center;
} }
.MessageComposer > button.send:disabled { .MessageComposer_input > button.send:disabled {
background-color: #E3E8F0; background-color: #E3E8F0;
} }

View file

@ -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); background-color: rgba(141, 151, 165, 0.1);
border-radius: 4px; border-radius: 4px;
} }

View file

@ -17,6 +17,8 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {Popup} from "../../general/Popup.js"; import {Popup} from "../../general/Popup.js";
import {Menu} from "../../general/Menu.js"; import {Menu} from "../../general/Menu.js";
import {TextMessageView} from "./timeline/TextMessageView.js";
import {viewClassForEntry} from "./TimelineList.js"
export class MessageComposer extends TemplateView { export class MessageComposer extends TemplateView {
constructor(viewModel) { constructor(viewModel) {
@ -32,7 +34,21 @@ export class MessageComposer extends TemplateView {
onKeydown: e => this._onKeyDown(e), onKeydown: e => this._onKeyDown(e),
onInput: () => vm.setInput(this._input.value), 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, this._input,
t.button({ t.button({
className: "sendFile", className: "sendFile",
@ -46,11 +62,16 @@ export class MessageComposer extends TemplateView {
onClick: () => this._trySend(), onClick: () => this._trySend(),
}, vm.i18n`Send`), }, vm.i18n`Send`),
]); ]);
return t.div({ className: "MessageComposer" }, [replyPreview, input]);
} }
_trySend() { _clearReplyingTo() {
this.value.clearReplyingTo();
}
async _trySend() {
this._input.focus(); this._input.focus();
if (this.value.sendMessage(this._input.value)) { if (await this.value.sendMessage(this._input.value)) {
this._input.value = ""; this._input.value = "";
} }
} }

View file

@ -24,7 +24,7 @@ import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js";
import {RedactedView} from "./timeline/RedactedView.js"; import {RedactedView} from "./timeline/RedactedView.js";
function viewClassForEntry(entry) { export function viewClassForEntry(entry) {
switch (entry.shape) { switch (entry.shape) {
case "gap": return GapView; case "gap": return GapView;
case "announcement": return AnnouncementView; case "announcement": return AnnouncementView;

View file

@ -24,23 +24,27 @@ import {Menu} from "../../../general/Menu.js";
import {ReactionsView} from "./ReactionsView.js"; import {ReactionsView} from "./ReactionsView.js";
export class BaseMessageView extends TemplateView { export class BaseMessageView extends TemplateView {
constructor(value) { constructor(value, interactive = true, tagName = "li") {
super(value); super(value);
this._menuPopup = null; 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) { render(t, vm) {
const li = t.li({className: { const li = t.el(this._tagName, {className: {
"Timeline_message": true, "Timeline_message": true,
own: vm.isOwn, own: vm.isOwn,
unsent: vm.isUnsent, unsent: vm.isUnsent,
unverified: vm.isUnverified, unverified: vm.isUnverified,
disabled: !this._interactive,
continuation: vm => vm.isContinuation, continuation: vm => vm.isContinuation,
}}, [ }}, [
// dynamically added and removed nodes are handled below // dynamically added and removed nodes are handled below
this.renderMessageBody(t, vm), this.renderMessageBody(t, vm),
// should be after body as it is overlayed on top // 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)]); const avatar = t.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]);
// given that there can be many tiles, we don't add // 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 // but that adds a comment node to all messages without reactions
let reactionsView = null; let reactionsView = null;
t.mapSideEffect(vm => vm.reactions, reactions => { t.mapSideEffect(vm => vm.reactions, reactions => {
if (reactions && !reactionsView) { if (reactions && this._interactive && !reactionsView) {
reactionsView = new ReactionsView(vm.reactions); reactionsView = new ReactionsView(vm.reactions);
this.addSubView(reactionsView); this.addSubView(reactionsView);
li.appendChild(mountView(reactionsView)); li.appendChild(mountView(reactionsView));
@ -113,6 +117,7 @@ export class BaseMessageView extends TemplateView {
const options = []; const options = [];
if (vm.canReact && vm.shape !== "redacted") { if (vm.canReact && vm.shape !== "redacted") {
options.push(new QuickReactionsMenuOption(vm)); options.push(new QuickReactionsMenuOption(vm));
options.push(Menu.option(vm.i18n`Reply`, () => vm.startReply()));
} }
if (vm.canAbortSending) { if (vm.canAbortSending) {
options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending())); options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending()));