commit
c3177b06bf
28 changed files with 470 additions and 113 deletions
25
doc/impl-thoughts/PENDING_REPLIES.md
Normal file
25
doc/impl-thoughts/PENDING_REPLIES.md
Normal 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?
|
17
doc/impl-thoughts/REPLIES.md
Normal file
17
doc/impl-thoughts/REPLIES.md
Normal 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
|
90
src/domain/session/room/ComposerViewModel.js
Normal file
90
src/domain/session/room/ComposerViewModel.js
Normal 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";
|
||||
}
|
||||
}
|
|
@ -16,7 +16,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {TimelineViewModel} from "./timeline/TimelineViewModel.js";
|
||||
import {ComposerViewModel} from "./ComposerViewModel.js"
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
|
||||
import {tilesCreator} from "./timeline/tilesCreator.js";
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
|
||||
export class RoomViewModel extends ViewModel {
|
||||
|
@ -25,6 +27,7 @@ export class RoomViewModel extends ViewModel {
|
|||
const {room} = options;
|
||||
this._room = room;
|
||||
this._timelineVM = null;
|
||||
this._tilesCreator = null;
|
||||
this._onRoomChange = this._onRoomChange.bind(this);
|
||||
this._timelineError = null;
|
||||
this._sendError = null;
|
||||
|
@ -42,11 +45,14 @@ export class RoomViewModel extends ViewModel {
|
|||
this._room.on("change", this._onRoomChange);
|
||||
try {
|
||||
const timeline = await this._room.openTimeline();
|
||||
const timelineVM = this.track(new TimelineViewModel(this.childOptions({
|
||||
room: this._room,
|
||||
this._tilesCreator = tilesCreator(this.childOptions({
|
||||
roomVM: this,
|
||||
timeline,
|
||||
}));
|
||||
this._timelineVM = this.track(new TimelineViewModel(this.childOptions({
|
||||
tilesCreator: this._tilesCreator,
|
||||
timeline,
|
||||
})));
|
||||
this._timelineVM = timelineVM;
|
||||
this.emitChange("timelineViewModel");
|
||||
} catch (err) {
|
||||
console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`);
|
||||
|
@ -154,7 +160,11 @@ export class RoomViewModel extends ViewModel {
|
|||
this._room.join();
|
||||
}
|
||||
|
||||
async _sendMessage(message) {
|
||||
_createTile(entry) {
|
||||
return this._tilesCreator(entry);
|
||||
}
|
||||
|
||||
async _sendMessage(message, replyingTo) {
|
||||
if (!this._room.isArchived && message) {
|
||||
try {
|
||||
let msgtype = "m.text";
|
||||
|
@ -162,7 +172,11 @@ export class RoomViewModel extends ViewModel {
|
|||
message = message.substr(4).trim();
|
||||
msgtype = "m.emote";
|
||||
}
|
||||
if (replyingTo) {
|
||||
await replyingTo.reply(msgtype, message);
|
||||
} else {
|
||||
await this._room.sendEvent("m.room.message", {msgtype, body: message});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`);
|
||||
this._sendError = err;
|
||||
|
@ -284,6 +298,10 @@ export class RoomViewModel extends ViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
get room() {
|
||||
return this._room;
|
||||
}
|
||||
|
||||
get composerViewModel() {
|
||||
return this._composerVM;
|
||||
}
|
||||
|
@ -294,57 +312,11 @@ export class RoomViewModel extends ViewModel {
|
|||
path = path.with(this.navigation.segment("details", true));
|
||||
this.navigation.applyPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
class ComposerViewModel extends ViewModel {
|
||||
constructor(roomVM) {
|
||||
super();
|
||||
this._roomVM = roomVM;
|
||||
this._isEmpty = true;
|
||||
startReply(entry) {
|
||||
if (!this._room.isArchived) {
|
||||
this._composerVM.setReplyingTo(entry);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -137,11 +137,24 @@ export class TextPart {
|
|||
get type() { return "text"; }
|
||||
}
|
||||
|
||||
function isBlockquote(part){
|
||||
return part.type === "format" && part.format === "blockquote";
|
||||
}
|
||||
|
||||
export class MessageBody {
|
||||
constructor(sourceString, parts) {
|
||||
this.sourceString = sourceString;
|
||||
this.parts = parts;
|
||||
}
|
||||
|
||||
insertEmote(string) {
|
||||
// We want to skip quotes introduced by replies when emoting.
|
||||
// We assume that such quotes are not TextParts, because replies
|
||||
// must have a formatted body.
|
||||
let i = 0;
|
||||
for (; i < this.parts.length && isBlockquote(this.parts[i]); i++);
|
||||
this.parts.splice(i, 0, new TextPart(string));
|
||||
}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
|
|
|
@ -222,7 +222,7 @@ export function tests() {
|
|||
};
|
||||
const tiles = new MappedList(timeline.entries, entry => {
|
||||
if (entry.eventType === "m.room.message") {
|
||||
return new BaseMessageTile({entry, room, timeline, platform: {logger}});
|
||||
return new BaseMessageTile({entry, roomVM: {room}, timeline, platform: {logger}});
|
||||
}
|
||||
return null;
|
||||
}, (tile, params, entry) => tile?.updateEntry(entry, params));
|
||||
|
|
|
@ -32,15 +32,14 @@ to the room timeline, which unload entries from memory.
|
|||
when loading, it just reads events from a sortkey backwards or forwards...
|
||||
*/
|
||||
import {TilesCollection} from "./TilesCollection.js";
|
||||
import {tilesCreator} from "./tilesCreator.js";
|
||||
import {ViewModel} from "../../../ViewModel.js";
|
||||
|
||||
export class TimelineViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {room, timeline} = options;
|
||||
const {timeline, tilesCreator} = options;
|
||||
this._timeline = this.track(timeline);
|
||||
this._tiles = new TilesCollection(timeline.entries, tilesCreator(this.childOptions({room, timeline})));
|
||||
this._tiles = new TilesCollection(timeline.entries, tilesCreator);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -34,7 +34,8 @@ const baseUrl = 'https://matrix.to';
|
|||
const linkPrefix = `${baseUrl}/#/`;
|
||||
|
||||
class Deserializer {
|
||||
constructor(result, mediaRepository) {
|
||||
constructor(result, mediaRepository, allowReplies) {
|
||||
this.allowReplies = allowReplies;
|
||||
this.result = result;
|
||||
this.mediaRepository = mediaRepository;
|
||||
}
|
||||
|
@ -287,6 +288,10 @@ class Deserializer {
|
|||
return true;
|
||||
}
|
||||
|
||||
_isAllowedNode(node) {
|
||||
return this.allowReplies || !this._ensureElement(node, "MX-REPLY");
|
||||
}
|
||||
|
||||
_parseInlineNodes(nodes, into) {
|
||||
for (const htmlNode of nodes) {
|
||||
if (this._parseTextParts(htmlNode, into)) {
|
||||
|
@ -301,9 +306,11 @@ class Deserializer {
|
|||
}
|
||||
// Node is either block or unrecognized. In
|
||||
// both cases, just move on to its children.
|
||||
if (this._isAllowedNode(htmlNode)) {
|
||||
this._parseInlineNodes(this.result.getChildNodes(htmlNode), into);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseInlineNodes(nodes) {
|
||||
const into = [];
|
||||
|
@ -325,9 +332,11 @@ class Deserializer {
|
|||
continue;
|
||||
}
|
||||
// Node is unrecognized. Just move on to its children.
|
||||
if (this._isAllowedNode(htmlNode)) {
|
||||
this._parseAnyNodes(this.result.getChildNodes(htmlNode), into);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseAnyNodes(nodes) {
|
||||
const into = [];
|
||||
|
@ -336,9 +345,9 @@ class Deserializer {
|
|||
}
|
||||
}
|
||||
|
||||
export function parseHTMLBody(platform, mediaRepository, html) {
|
||||
export function parseHTMLBody(platform, mediaRepository, allowReplies, html) {
|
||||
const parseResult = platform.parseHTML(html);
|
||||
const deserializer = new Deserializer(parseResult, mediaRepository);
|
||||
const deserializer = new Deserializer(parseResult, mediaRepository, allowReplies);
|
||||
const parts = deserializer.parseAnyNodes(parseResult.rootNodes);
|
||||
return new MessageBody(html, parts);
|
||||
}
|
||||
|
@ -388,8 +397,8 @@ export function tests() {
|
|||
parseHTML: (html) => new HTMLParseResult(parse(html))
|
||||
};
|
||||
|
||||
function test(assert, input, output) {
|
||||
assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output));
|
||||
function test(assert, input, output, replies=true) {
|
||||
assert.deepEqual(parseHTMLBody(platform, null, replies, input), new MessageBody(input, output));
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -486,6 +495,24 @@ export function tests() {
|
|||
new CodeBlock(null, code)
|
||||
];
|
||||
test(assert, input, output);
|
||||
},
|
||||
"Replies are inserted when allowed": assert => {
|
||||
const input = 'Hello, <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.
|
||||
"Text with code block": assert => {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -91,7 +91,7 @@ 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();
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
74
src/matrix/room/timeline/entries/reply.js
Normal file
74
src/matrix/room/timeline/entries/reply.js
Normal 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, "&").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 = `<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);
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -118,7 +118,7 @@ a {
|
|||
color: red;
|
||||
}
|
||||
|
||||
.MessageComposer > input {
|
||||
.MessageComposer_input > input {
|
||||
padding: 0.8em;
|
||||
border: none;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 = "";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()));
|
||||
|
|
Reference in a new issue