Merge pull request #612 from vector-im/threading-fallback-reply

Threading fallback - PR 2 - Support rich reply
This commit is contained in:
Bruno Windels 2022-01-14 19:10:39 +01:00 committed by GitHub
commit 46c61953f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 253 additions and 87 deletions

View File

@ -225,7 +225,7 @@ export function tests() {
return new BaseMessageTile({entry, roomVM: {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, function () {}));
return tiles; return tiles;
} }

View File

@ -150,7 +150,7 @@ export class TilesCollection extends BaseObservableList {
const tileIdx = this._findTileIdx(entry); const tileIdx = this._findTileIdx(entry);
const tile = this._findTileAtIdx(entry, tileIdx); const tile = this._findTileAtIdx(entry, tileIdx);
if (tile) { if (tile) {
const action = tile.updateEntry(entry, params); const action = tile.updateEntry(entry, params, this._tileCreator);
if (action.shouldReplace) { if (action.shouldReplace) {
const newTile = this._tileCreator(entry); const newTile = this._tileCreator(entry);
if (newTile) { if (newTile) {

View File

@ -34,8 +34,7 @@ const baseUrl = 'https://matrix.to';
const linkPrefix = `${baseUrl}/#/`; const linkPrefix = `${baseUrl}/#/`;
class Deserializer { class Deserializer {
constructor(result, mediaRepository, allowReplies) { constructor(result, mediaRepository) {
this.allowReplies = allowReplies;
this.result = result; this.result = result;
this.mediaRepository = mediaRepository; this.mediaRepository = mediaRepository;
} }
@ -289,7 +288,7 @@ class Deserializer {
} }
_isAllowedNode(node) { _isAllowedNode(node) {
return this.allowReplies || !this._ensureElement(node, "MX-REPLY"); return !this._ensureElement(node, "MX-REPLY");
} }
_parseInlineNodes(nodes, into) { _parseInlineNodes(nodes, into) {
@ -345,9 +344,9 @@ class Deserializer {
} }
} }
export function parseHTMLBody(platform, mediaRepository, allowReplies, html) { export function parseHTMLBody(platform, mediaRepository, html) {
const parseResult = platform.parseHTML(html); const parseResult = platform.parseHTML(html);
const deserializer = new Deserializer(parseResult, mediaRepository, allowReplies); const deserializer = new Deserializer(parseResult, mediaRepository);
const parts = deserializer.parseAnyNodes(parseResult.rootNodes); const parts = deserializer.parseAnyNodes(parseResult.rootNodes);
return new MessageBody(html, parts); return new MessageBody(html, parts);
} }
@ -401,8 +400,8 @@ export async function tests() {
parseHTML: (html) => new HTMLParseResult(parse(html)) parseHTML: (html) => new HTMLParseResult(parse(html))
}; };
function test(assert, input, output, replies=true) { function test(assert, input, output) {
assert.deepEqual(parseHTMLBody(platform, null, replies, input), new MessageBody(input, output)); assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output));
} }
return { return {
@ -500,23 +499,14 @@ export async function tests() {
]; ];
test(assert, input, output); test(assert, input, output);
}, },
"Replies are inserted when allowed": assert => { "Reply fallback is always stripped": 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 input = 'Hello, <em><mx-reply>World</mx-reply></em>!';
const output = [ const output = [
new TextPart('Hello, '), new TextPart('Hello, '),
new FormatPart("em", []), new FormatPart("em", []),
new TextPart('!'), new TextPart('!'),
]; ];
test(assert, input, output, false); assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output));
} }
/* 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

@ -24,15 +24,25 @@ export class BaseMessageTile extends SimpleTile {
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
this._isContinuation = false; this._isContinuation = false;
this._reactions = null; this._reactions = null;
this._replyTile = null;
if (this._entry.annotations || this._entry.pendingAnnotations) { if (this._entry.annotations || this._entry.pendingAnnotations) {
this._updateReactions(); this._updateReactions();
} }
this._updateReplyTileIfNeeded(options.tilesCreator, undefined);
} }
get _mediaRepository() { get _mediaRepository() {
return this._room.mediaRepository; return this._room.mediaRepository;
} }
get permaLink() {
return `https://matrix.to/#/${encodeURIComponent(this._room.id)}/${encodeURIComponent(this._entry.id)}`;
}
get senderProfileLink() {
return `https://matrix.to/#/${encodeURIComponent(this.sender)}`;
}
get displayName() { get displayName() {
return this._entry.displayName || this.sender; return this._entry.displayName || this.sender;
} }
@ -82,6 +92,10 @@ export class BaseMessageTile extends SimpleTile {
return this._entry.isUnverified; return this._entry.isUnverified;
} }
get isReply() {
return this._entry.isReply;
}
_getContent() { _getContent() {
return this._entry.content; return this._entry.content;
} }
@ -102,14 +116,30 @@ export class BaseMessageTile extends SimpleTile {
} }
} }
updateEntry(entry, param) { updateEntry(entry, param, tilesCreator) {
const action = super.updateEntry(entry, param); const action = super.updateEntry(entry, param, tilesCreator);
if (action.shouldUpdate) { if (action.shouldUpdate) {
this._updateReactions(); this._updateReactions();
} }
this._updateReplyTileIfNeeded(tilesCreator, param);
return action; return action;
} }
_updateReplyTileIfNeeded(tilesCreator, param) {
const replyEntry = this._entry.contextEntry;
if (replyEntry) {
// this is an update to contextEntry used for replyPreview
const action = this._replyTile?.updateEntry(replyEntry, param, tilesCreator);
if (action?.shouldReplace || !this._replyTile) {
this.disposeTracked(this._replyTile);
this._replyTile = tilesCreator(replyEntry);
}
if(action?.shouldUpdate) {
this._replyTile?.emitChange();
}
}
}
startReply() { startReply() {
this._roomVM.startReply(this._entry); this._roomVM.startReply(this._entry);
} }
@ -202,4 +232,11 @@ export class BaseMessageTile extends SimpleTile {
this._reactions.update(annotations, pendingAnnotations); this._reactions.update(annotations, pendingAnnotations);
} }
} }
get replyTile() {
if (!this._entry.contextEventId) {
return null;
}
return this._replyTile;
}
} }

View File

@ -56,4 +56,5 @@ export class BaseTextTile extends BaseMessageTile {
} }
return this._messageBody; return this._messageBody;
} }
} }

View File

@ -18,8 +18,8 @@ import {BaseTextTile} from "./BaseTextTile.js";
import {UpdateAction} from "../UpdateAction.js"; import {UpdateAction} from "../UpdateAction.js";
export class EncryptedEventTile extends BaseTextTile { export class EncryptedEventTile extends BaseTextTile {
updateEntry(entry, params) { updateEntry(entry, params, tilesCreator) {
const parentResult = super.updateEntry(entry, params); const parentResult = super.updateEntry(entry, params, tilesCreator);
// event got decrypted, recreate the tile and replace this one with it // event got decrypted, recreate the tile and replace this one with it
if (entry.eventType !== "m.room.encrypted") { if (entry.eventType !== "m.room.encrypted") {
// the "shape" parameter trigger tile recreation in TimelineView // the "shape" parameter trigger tile recreation in TimelineView

View File

@ -81,8 +81,8 @@ export class GapTile extends SimpleTile {
this._siblingChanged = true; this._siblingChanged = true;
} }
updateEntry(entry, params) { updateEntry(entry, params, tilesCreator) {
super.updateEntry(entry, params); super.updateEntry(entry, params, tilesCreator);
if (!entry.isGap) { if (!entry.isGap) {
return UpdateAction.Remove(); return UpdateAction.Remove();
} else { } else {

View File

@ -50,7 +50,7 @@ export class TextTile extends BaseTextTile {
_parseBody(body, format) { _parseBody(body, format) {
let messageBody; let messageBody;
if (format === BodyFormat.Html) { if (format === BodyFormat.Html) {
messageBody = parseHTMLBody(this.platform, this._mediaRepository, this._entry.isReply, body); messageBody = parseHTMLBody(this.platform, this._mediaRepository, body);
} else { } else {
messageBody = parsePlainBody(body); messageBody = parsePlainBody(body);
} }

View File

@ -28,8 +28,8 @@ import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js"; import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js";
export function tilesCreator(baseOptions) { export function tilesCreator(baseOptions) {
return function tilesCreator(entry, emitUpdate) { const tilesCreator = function tilesCreator(entry, emitUpdate) {
const options = Object.assign({entry, emitUpdate}, baseOptions); const options = Object.assign({entry, emitUpdate, tilesCreator}, baseOptions);
if (entry.isGap) { if (entry.isGap) {
return new GapTile(options); return new GapTile(options);
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) { } else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
@ -76,5 +76,6 @@ export function tilesCreator(baseOptions) {
return null; return null;
} }
} }
} };
return tilesCreator;
} }

View File

@ -258,8 +258,8 @@ export class Timeline {
this._addLocalRelationsToNewRemoteEntries(newEntries); this._addLocalRelationsToNewRemoteEntries(newEntries);
this._updateEntriesFetchedFromHomeserver(newEntries); this._updateEntriesFetchedFromHomeserver(newEntries);
this._moveEntryToRemoteEntries(newEntries); this._moveEntryToRemoteEntries(newEntries);
this._remoteEntries.setManySorted(newEntries);
this._loadContextEntriesWhereNeeded(newEntries); this._loadContextEntriesWhereNeeded(newEntries);
this._remoteEntries.setManySorted(newEntries);
} }
/** /**
@ -320,21 +320,42 @@ export class Timeline {
continue; continue;
} }
const id = entry.contextEventId; const id = entry.contextEventId;
let contextEvent = this._findLoadedEventById(id); // before looking into remoteEntries, check the entries
// that about to be added first
let contextEvent = entries.find(e => e.id === id);
if (!contextEvent) { if (!contextEvent) {
contextEvent = await this._getEventFromStorage(id) ?? await this._getEventFromHomeserver(id); contextEvent = this._findLoadedEventById(id);
}
if (contextEvent) {
entry.setContextEntry(contextEvent);
// we don't emit an update here, as the add or update
// that the callee will emit hasn't been emitted yet.
} else {
// we don't await here, which is not ideal,
// but one of our callers, addEntries, is not async
// so there is not much point.
// Also, we want to run the entry fetching in parallel.
this._loadContextEntryNotInTimeline(entry);
}
}
}
async _loadContextEntryNotInTimeline(entry) {
const id = entry.contextEventId;
let contextEvent = await this._getEventFromStorage(id);
if (!contextEvent) {
contextEvent = await this._getEventFromHomeserver(id);
}
if (contextEvent) { if (contextEvent) {
// this entry was created from storage/hs, so it's not tracked by remoteEntries // this entry was created from storage/hs, so it's not tracked by remoteEntries
// we track them here so that we can update reply previews later // we track them here so that we can update reply previews later
this._contextEntriesNotInTimeline.set(id, contextEvent); this._contextEntriesNotInTimeline.set(id, contextEvent);
}
}
if (contextEvent) {
entry.setContextEntry(contextEvent); entry.setContextEntry(contextEvent);
// here, we awaited something, so from now on we do have to emit
// an update if we set the context entry.
this._emitUpdateForEntry(entry, "contextEntry"); this._emitUpdateForEntry(entry, "contextEntry");
} }
} }
}
/** /**
* Fetches an entry with the given event-id from localEntries, remoteEntries or contextEntriesNotInTimeline. * Fetches an entry with the given event-id from localEntries, remoteEntries or contextEntriesNotInTimeline.
@ -469,6 +490,7 @@ import {EventEntry} from "./entries/EventEntry.js";
import {User} from "../../User.js"; import {User} from "../../User.js";
import {PendingEvent} from "../sending/PendingEvent.js"; import {PendingEvent} from "../sending/PendingEvent.js";
import {createAnnotation} from "./relations.js"; import {createAnnotation} from "./relations.js";
import {redactEvent} from "./common.js";
export function tests() { export function tests() {
const fragmentIdComparer = new FragmentIdComparer([]); const fragmentIdComparer = new FragmentIdComparer([]);
@ -742,7 +764,9 @@ export function tests() {
const entryA = new EventEntry({ event: withTextBody("foo", createEvent("m.room.message", "event_id_1", alice)) }); const entryA = new EventEntry({ event: withTextBody("foo", createEvent("m.room.message", "event_id_1", alice)) });
const entryB = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_2", bob)), eventIndex: 2 }); const entryB = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_2", bob)), eventIndex: 2 });
await timeline.load(new User(alice), "join", new NullLogItem()); await timeline.load(new User(alice), "join", new NullLogItem());
timeline.entries.subscribe({ onAdd: () => null, }); timeline.entries.subscribe({
onAdd() {},
});
timeline.addEntries([entryA, entryB]); timeline.addEntries([entryA, entryB]);
assert.deepEqual(entryB.contextEntry, entryA); assert.deepEqual(entryB.contextEntry, entryA);
}, },
@ -802,21 +826,22 @@ export function tests() {
"redaction of context entry triggers updates in other entries": async assert => { "redaction of context entry triggers updates in other entries": async assert => {
const timeline = new Timeline({roomId, storage: await createMockStorage(), closeCallback: () => {}, const timeline = new Timeline({roomId, storage: await createMockStorage(), closeCallback: () => {},
fragmentIdComparer, pendingEvents: new ObservableArray(), clock: new MockClock(), hsApi}); fragmentIdComparer, pendingEvents: new ObservableArray(), clock: new MockClock(), hsApi});
const entryB = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_2", bob)), eventIndex: 2 }); const entryA = new EventEntry({ event: withTextBody("foo", createEvent("m.room.message", "event_id_1", alice)), eventIndex: 1, fragmentId: 1 });
const entryC = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_3", bob)), eventIndex: 3 }); const entryB = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_2", bob)), eventIndex: 2, fragmentId: 1 });
const entryC = new EventEntry({ event: withReply("event_id_1", createEvent("m.room.message", "event_id_3", bob)), eventIndex: 3, fragmentId: 1 });
await timeline.load(new User(alice), "join", new NullLogItem()); await timeline.load(new User(alice), "join", new NullLogItem());
const bin = []; const bin = [];
timeline.entries.subscribe({ timeline.entries.subscribe({
onUpdate: (index) => { onUpdate: (index, e) => {
const e = timeline.remoteEntries[index];
bin.push(e.id); bin.push(e.id);
}, },
onAdd: () => null, onAdd: () => null,
}); });
timeline.addEntries([entryB, entryC]); timeline.addEntries([entryA, entryB, entryC]);
await poll(() => timeline._remoteEntries.array.length === 2 && timeline._contextEntriesNotInTimeline.get("event_id_1")); const eventAClone = JSON.parse(JSON.stringify(entryA.event));
const redactingEntry = new EventEntry({ event: withRedacts("event_id_1", "foo", createEvent("m.room.redaction", "event_id_3", alice)) }); redactEvent(withRedacts("event_id_1", "foo", createEvent("m.room.redaction", "event_id_4", alice)), eventAClone);
timeline.addEntries([redactingEntry]); const redactedEntry = new EventEntry({ event: eventAClone, eventIndex: 1, fragmentId: 1 });
timeline.replaceEntries([redactedEntry]);
assert.strictEqual(bin.includes("event_id_2"), true); assert.strictEqual(bin.includes("event_id_2"), true);
assert.strictEqual(bin.includes("event_id_3"), true); assert.strictEqual(bin.includes("event_id_3"), true);
}, },

View File

@ -16,7 +16,6 @@ limitations under the License.
*/ */
// import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay"; // import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay";
import {Client} from "../../matrix/Client.js";
import {RootViewModel} from "../../domain/RootViewModel.js"; import {RootViewModel} from "../../domain/RootViewModel.js";
import {createNavigation, createRouter} from "../../domain/navigation/index.js"; import {createNavigation, createRouter} from "../../domain/navigation/index.js";
// Don't use a default export here, as we use multiple entries during legacy build, // Don't use a default export here, as we use multiple entries during legacy build,

View File

@ -56,7 +56,8 @@ 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'] FORBID_TAGS: ['mx-reply'],
KEEP_CONTENT: false,
} }
export function parseHTML(html) { export function parseHTML(html) {

View File

@ -49,6 +49,17 @@ limitations under the License.
margin-top: 4px; margin-top: 4px;
} }
.ReplyPreviewView .Timeline_message {
display: grid;
grid-template: "body" auto;
margin-left: 0;
padding: 0;
}
.ReplyPreviewView .Timeline_message:not(.continuation) {
margin-top: 0;
}
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
.Timeline_message { .Timeline_message {
grid-template: grid-template:

View File

@ -17,7 +17,7 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView"; import {TemplateView} from "../../general/TemplateView";
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 {viewClassForEntry} from "./TimelineView" import {viewClassForEntry} from "./common"
export class MessageComposer extends TemplateView { export class MessageComposer extends TemplateView {
constructor(viewModel) { constructor(viewModel) {
@ -56,7 +56,7 @@ export class MessageComposer extends TemplateView {
className: "cancel", className: "cancel",
onClick: () => this._clearReplyingTo() onClick: () => this._clearReplyingTo()
}, "Close"), }, "Close"),
t.view(new View(rvm, false, "div")) t.view(new View(rvm, { interactive: false }, "div"))
]) ])
}); });
const input = t.div({className: "MessageComposer_input"}, [ const input = t.div({className: "MessageComposer_input"}, [

View File

@ -14,15 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import type {TileView} from "./common";
import {viewClassForEntry} from "./common";
import {ListView} from "../../general/ListView"; import {ListView} from "../../general/ListView";
import {TemplateView, Builder} from "../../general/TemplateView"; import {TemplateView, Builder} from "../../general/TemplateView";
import {IObservableValue} from "../../general/BaseUpdateView"; import {IObservableValue} from "../../general/BaseUpdateView";
import {GapView} from "./timeline/GapView.js";
import {TextMessageView} from "./timeline/TextMessageView.js";
import {ImageView} from "./timeline/ImageView.js";
import {VideoView} from "./timeline/VideoView.js";
import {FileView} from "./timeline/FileView.js";
import {LocationView} from "./timeline/LocationView.js";
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {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";
@ -36,27 +32,6 @@ export interface TimelineViewModel extends IObservableValue {
setVisibleTileRange(start?: SimpleTile, end?: SimpleTile); setVisibleTileRange(start?: SimpleTile, end?: SimpleTile);
} }
type TileView = GapView | AnnouncementView | TextMessageView |
ImageView | VideoView | FileView | MissingAttachmentView | RedactedView;
type TileViewConstructor = (this: TileView, SimpleTile) => void;
export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | undefined {
switch (entry.shape) {
case "gap": return GapView;
case "announcement": return AnnouncementView;
case "message":
case "message-status":
return TextMessageView;
case "image": return ImageView;
case "video": return VideoView;
case "file": return FileView;
case "location": return LocationView;
case "missing-attachment": return MissingAttachmentView;
case "redacted":
return RedactedView;
}
}
function bottom(node: HTMLElement): number { function bottom(node: HTMLElement): number {
return node.offsetTop + node.clientHeight; return node.offsetTop + node.clientHeight;
} }

View File

@ -0,0 +1,55 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {TextMessageView} from "./timeline/TextMessageView.js";
import {ImageView} from "./timeline/ImageView.js";
import {VideoView} from "./timeline/VideoView.js";
import {FileView} from "./timeline/FileView.js";
import {LocationView} from "./timeline/LocationView.js";
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js";
import {RedactedView} from "./timeline/RedactedView.js";
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
import {GapView} from "./timeline/GapView.js";
export type TileView = GapView | AnnouncementView | TextMessageView |
ImageView | VideoView | FileView | LocationView | MissingAttachmentView | RedactedView;
// TODO: this is what works for a ctor but doesn't actually check we constrain the returned ctors to the types above
type TileViewConstructor = (this: TileView, SimpleTile) => void;
export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | undefined {
switch (entry.shape) {
case "gap":
return GapView;
case "announcement":
return AnnouncementView;
case "message":
case "message-status":
return TextMessageView;
case "image":
return ImageView;
case "video":
return VideoView;
case "file":
return FileView;
case "location":
return LocationView;
case "missing-attachment":
return MissingAttachmentView;
case "redacted":
return RedactedView;
}
}

View File

@ -24,14 +24,17 @@ 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, interactive = true, tagName = "li") { constructor(value, renderFlags, tagName = "li") {
super(value); super(value);
this._menuPopup = null; this._menuPopup = null;
this._tagName = tagName; this._tagName = tagName;
// TODO An enum could be nice to make code easier to read at call sites. // TODO An enum could be nice to make code easier to read at call sites.
this._interactive = interactive; this._renderFlags = renderFlags;
} }
get _interactive() { return this._renderFlags?.interactive ?? true; }
get _isReplyPreview() { return this._renderFlags?.reply; }
render(t, vm) { render(t, vm) {
const children = [this.renderMessageBody(t, vm)]; const children = [this.renderMessageBody(t, vm)];
if (this._interactive) { if (this._interactive) {
@ -54,7 +57,7 @@ export class BaseMessageView extends TemplateView {
if (isContinuation && wasContinuation === false) { if (isContinuation && wasContinuation === false) {
li.removeChild(li.querySelector(".Timeline_messageAvatar")); li.removeChild(li.querySelector(".Timeline_messageAvatar"));
li.removeChild(li.querySelector(".Timeline_messageSender")); li.removeChild(li.querySelector(".Timeline_messageSender"));
} else if (!isContinuation) { } else if (!isContinuation && !this._isReplyPreview) {
const avatar = tag.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]); const avatar = tag.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]);
const sender = tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName); const sender = tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName);
li.insertBefore(avatar, li.firstChild); li.insertBefore(avatar, li.firstChild);
@ -104,7 +107,7 @@ export class BaseMessageView extends TemplateView {
createMenuOptions(vm) { createMenuOptions(vm) {
const options = []; const options = [];
if (vm.canReact && vm.shape !== "redacted") { if (vm.canReact && vm.shape !== "redacted" && !vm.isPending) {
options.push(new QuickReactionsMenuOption(vm)); options.push(new QuickReactionsMenuOption(vm));
options.push(Menu.option(vm.i18n`Reply`, () => vm.startReply())); options.push(Menu.option(vm.i18n`Reply`, () => vm.startReply()));
} }

View File

@ -0,0 +1,49 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {renderStaticAvatar} from "../../../avatar";
import {TemplateView} from "../../../general/TemplateView";
import {viewClassForEntry} from "../common";
export class ReplyPreviewView extends TemplateView {
render(t, vm) {
const viewClass = viewClassForEntry(vm);
if (!viewClass) {
throw new Error(`Shape ${vm.shape} is unrecognized.`)
}
const view = new viewClass(vm, { reply: true, interactive: false });
return t.div(
{ className: "ReplyPreviewView" },
t.blockquote([
t.a({ className: "link", href: vm.permaLink }, "In reply to"),
t.a({ className: "pill", href: vm.senderProfileLink }, [
renderStaticAvatar(vm, 12, undefined, true),
vm.displayName,
]),
t.br(),
t.view(view),
])
);
}
}
export class ReplyPreviewError extends TemplateView {
render(t) {
return t.blockquote({ className: "ReplyPreviewView" }, [
t.div({ className: "Timeline_messageBody statusMessage" }, "This reply could not be found.")
]);
}
}

View File

@ -16,6 +16,7 @@ limitations under the License.
import {tag, text} from "../../../general/html"; import {tag, text} from "../../../general/html";
import {BaseMessageView} from "./BaseMessageView.js"; import {BaseMessageView} from "./BaseMessageView.js";
import {ReplyPreviewError, ReplyPreviewView} from "./ReplyPreviewView.js";
export class TextMessageView extends BaseMessageView { export class TextMessageView extends BaseMessageView {
renderMessageBody(t, vm) { renderMessageBody(t, vm) {
@ -24,10 +25,27 @@ export class TextMessageView extends BaseMessageView {
className: { className: {
"Timeline_messageBody": true, "Timeline_messageBody": true,
statusMessage: vm => vm.shape === "message-status", statusMessage: vm => vm.shape === "message-status",
}, }
}); }, t.mapView(vm => vm.replyTile, replyTile => {
if (this._isReplyPreview) {
// if this._isReplyPreview = true, this is already a reply preview, don't nest replies for now.
return null;
}
else if (vm.isReply && !replyTile) {
return new ReplyPreviewError();
}
else if (replyTile) {
return new ReplyPreviewView(replyTile);
}
else {
return null;
}
}));
const shouldRemove = (element) => element?.nodeType === Node.ELEMENT_NODE && element.className !== "ReplyPreviewView";
t.mapSideEffect(vm => vm.body, body => { t.mapSideEffect(vm => vm.body, body => {
while (container.lastChild) { while (shouldRemove(container.lastChild)) {
container.removeChild(container.lastChild); container.removeChild(container.lastChild);
} }
for (const part of body.parts) { for (const part of body.parts) {
@ -35,6 +53,7 @@ export class TextMessageView extends BaseMessageView {
} }
container.appendChild(time); container.appendChild(time);
}); });
return container; return container;
} }
} }