forked from mystiq/hydrogen-web
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 {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";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 &&
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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";
|
||||||
|
|
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;
|
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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -118,7 +118,7 @@ a {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MessageComposer > input {
|
.MessageComposer_input > input {
|
||||||
padding: 0.8em;
|
padding: 0.8em;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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()));
|
||||||
|
|
Loading…
Reference in a new issue