allow injecting the tilesCreator from the Root/Session/RoomViewModel

this changes the API slightly to be more future-proof,
as we'll expose it in the SDK now.

The function now returns a SimpleTile constructor, rather than an
instance. This allows us to test if an entry would render in the
timeline without creating a tile, which is something we might want in
the matrix layer later on.

The function is now called tileClassForEntry, analogue to what we
do in TimelineView.
This commit is contained in:
Bruno Windels 2022-04-08 12:52:30 +02:00
parent 220f35ae03
commit 5445db2a42
17 changed files with 174 additions and 133 deletions

View File

@ -18,17 +18,17 @@ limitations under the License.
import {TimelineViewModel} from "./timeline/TimelineViewModel.js";
import {ComposerViewModel} from "./ComposerViewModel.js"
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {tilesCreator} from "./timeline/tilesCreator.js";
import {ViewModel} from "../../ViewModel";
import {imageToInfo} from "../common.js";
export class RoomViewModel extends ViewModel {
constructor(options) {
super(options);
const {room} = options;
const {room, tileClassForEntry} = options;
this._room = room;
this._timelineVM = null;
this._tilesCreator = null;
this._tileClassForEntry = tileClassForEntry;
this._tileOptions = undefined;
this._onRoomChange = this._onRoomChange.bind(this);
this._timelineError = null;
this._sendError = null;
@ -46,12 +46,13 @@ export class RoomViewModel extends ViewModel {
this._room.on("change", this._onRoomChange);
try {
const timeline = await this._room.openTimeline();
this._tilesCreator = tilesCreator(this.childOptions({
this._tileOptions = this.childOptions({
roomVM: this,
timeline,
}));
tileClassForEntry: this._tileClassForEntry,
});
this._timelineVM = this.track(new TimelineViewModel(this.childOptions({
tilesCreator: this._tilesCreator,
tileOptions: this._tileOptions,
timeline,
})));
this.emitChange("timelineViewModel");
@ -161,7 +162,12 @@ export class RoomViewModel extends ViewModel {
}
_createTile(entry) {
return this._tilesCreator(entry);
if (this._tileOptions) {
const Tile = this._tileOptions.tileClassForEntry(entry);
if (Tile) {
return new Tile(entry, this._tileOptions);
}
}
}
async _sendMessage(message, replyingTo) {

View File

@ -222,7 +222,7 @@ export function tests() {
};
const tiles = new MappedList(timeline.entries, entry => {
if (entry.eventType === "m.room.message") {
return new BaseMessageTile({entry, roomVM: {room}, timeline, platform: {logger}});
return new BaseMessageTile(entry, {roomVM: {room}, timeline, platform: {logger}});
}
return null;
}, (tile, params, entry) => tile?.updateEntry(entry, params, function () {}));

View File

@ -18,20 +18,27 @@ import {BaseObservableList} from "../../../../observable/list/BaseObservableList
import {sortedIndex} from "../../../../utils/sortedIndex";
// maps 1..n entries to 0..1 tile. Entries are what is stored in the timeline, either an event or fragmentboundary
// for now, tileCreator should be stable in whether it returns a tile or not.
// for now, tileClassForEntry should be stable in whether it returns a tile or not.
// e.g. the decision to create a tile or not should be based on properties
// not updated later on (e.g. event type)
// also see big comment in onUpdate
export class TilesCollection extends BaseObservableList {
constructor(entries, tileCreator) {
constructor(entries, tileOptions) {
super();
this._entries = entries;
this._tiles = null;
this._entrySubscription = null;
this._tileCreator = tileCreator;
this._tileOptions = tileOptions;
this._emitSpontanousUpdate = this._emitSpontanousUpdate.bind(this);
}
_createTile(entry) {
const Tile = this._tileOptions.tileClassForEntry(entry);
if (Tile) {
return new Tile(entry, this._tileOptions);
}
}
_emitSpontanousUpdate(tile, params) {
const entry = tile.lowerEntry;
const tileIdx = this._findTileIdx(entry);
@ -48,7 +55,7 @@ export class TilesCollection extends BaseObservableList {
let currentTile = null;
for (let entry of this._entries) {
if (!currentTile || !currentTile.tryIncludeEntry(entry)) {
currentTile = this._tileCreator(entry);
currentTile = this._createTile(entry);
if (currentTile) {
this._tiles.push(currentTile);
}
@ -121,7 +128,7 @@ export class TilesCollection extends BaseObservableList {
return;
}
const newTile = this._tileCreator(entry);
const newTile = this._createTile(entry);
if (newTile) {
if (prevTile) {
prevTile.updateNextSibling(newTile);
@ -150,9 +157,9 @@ export class TilesCollection extends BaseObservableList {
const tileIdx = this._findTileIdx(entry);
const tile = this._findTileAtIdx(entry, tileIdx);
if (tile) {
const action = tile.updateEntry(entry, params, this._tileCreator);
const action = tile.updateEntry(entry, params);
if (action.shouldReplace) {
const newTile = this._tileCreator(entry);
const newTile = this._createTile(entry);
if (newTile) {
this._replaceTile(tileIdx, tile, newTile, action.updateParams);
newTile.setUpdateEmit(this._emitSpontanousUpdate);
@ -303,7 +310,10 @@ export function tests() {
}
}
const entries = new ObservableArray([{n: 5}, {n: 10}]);
const tiles = new TilesCollection(entries, entry => new UpdateOnSiblingTile(entry));
const tileOptions = {
tileClassForEntry: entry => UpdateOnSiblingTile,
};
const tiles = new TilesCollection(entries, tileOptions);
let receivedAdd = false;
tiles.subscribe({
onAdd(idx, tile) {
@ -326,7 +336,10 @@ export function tests() {
}
}
const entries = new ObservableArray([{n: 5}, {n: 10}, {n: 15}]);
const tiles = new TilesCollection(entries, entry => new UpdateOnSiblingTile(entry));
const tileOptions = {
tileClassForEntry: entry => UpdateOnSiblingTile,
};
const tiles = new TilesCollection(entries, tileOptions);
const events = [];
tiles.subscribe({
onUpdate(idx, tile) {

View File

@ -37,9 +37,9 @@ import {ViewModel} from "../../../ViewModel";
export class TimelineViewModel extends ViewModel {
constructor(options) {
super(options);
const {timeline, tilesCreator} = options;
const {timeline, tileOptions} = options;
this._timeline = this.track(timeline);
this._tiles = new TilesCollection(timeline.entries, tilesCreator);
this._tiles = new TilesCollection(timeline.entries, tileOptions);
this._startTile = null;
this._endTile = null;
this._topLoadingPromise = null;

View File

@ -21,8 +21,8 @@ const MAX_HEIGHT = 300;
const MAX_WIDTH = 400;
export class BaseMediaTile extends BaseMessageTile {
constructor(options) {
super(options);
constructor(entry, options) {
super(entry, options);
this._decryptedThumbnail = null;
this._decryptedFile = null;
this._isVisible = false;

View File

@ -19,8 +19,8 @@ import {ReactionsViewModel} from "../ReactionsViewModel.js";
import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar";
export class BaseMessageTile extends SimpleTile {
constructor(options) {
super(options);
constructor(entry, options) {
super(entry, options);
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
this._isContinuation = false;
this._reactions = null;
@ -28,7 +28,7 @@ export class BaseMessageTile extends SimpleTile {
if (this._entry.annotations || this._entry.pendingAnnotations) {
this._updateReactions();
}
this._updateReplyTileIfNeeded(options.tilesCreator, undefined);
this._updateReplyTileIfNeeded(undefined);
}
notifyVisible() {
@ -122,23 +122,27 @@ export class BaseMessageTile extends SimpleTile {
}
}
updateEntry(entry, param, tilesCreator) {
const action = super.updateEntry(entry, param, tilesCreator);
updateEntry(entry, param) {
const action = super.updateEntry(entry, param);
if (action.shouldUpdate) {
this._updateReactions();
}
this._updateReplyTileIfNeeded(tilesCreator, param);
this._updateReplyTileIfNeeded(param);
return action;
}
_updateReplyTileIfNeeded(tilesCreator, param) {
_updateReplyTileIfNeeded(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);
const action = this._replyTile?.updateEntry(replyEntry, param);
if (action?.shouldReplace || !this._replyTile) {
this.disposeTracked(this._replyTile);
this._replyTile = tilesCreator(replyEntry);
const tileClassForEntry = this._options.tileClassForEntry;
const ReplyTile = tileClassForEntry(replyEntry);
if (ReplyTile) {
this._replyTile = new ReplyTile(replyEntry, this._options);
}
}
if(action?.shouldUpdate) {
this._replyTile?.emitChange();

View File

@ -21,8 +21,8 @@ import {createEnum} from "../../../../../utils/enum";
export const BodyFormat = createEnum("Plain", "Html");
export class BaseTextTile extends BaseMessageTile {
constructor(options) {
super(options);
constructor(entry, options) {
super(entry, options);
this._messageBody = null;
this._format = null
}

View File

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

View File

@ -20,8 +20,8 @@ import {formatSize} from "../../../../../utils/formatSize";
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
export class FileTile extends BaseMessageTile {
constructor(options) {
super(options);
constructor(entry, options) {
super(entry, options);
this._downloadError = null;
this._downloading = false;
}

View File

@ -18,8 +18,8 @@ import {SimpleTile} from "./SimpleTile.js";
import {UpdateAction} from "../UpdateAction.js";
export class GapTile extends SimpleTile {
constructor(options) {
super(options);
constructor(entry, options) {
super(entry, options);
this._loading = false;
this._error = null;
this._isAtTop = true;
@ -81,8 +81,8 @@ export class GapTile extends SimpleTile {
this._siblingChanged = true;
}
updateEntry(entry, params, tilesCreator) {
super.updateEntry(entry, params, tilesCreator);
updateEntry(entry, params) {
super.updateEntry(entry, params);
if (!entry.isGap) {
return UpdateAction.Remove();
} else {
@ -125,7 +125,7 @@ export function tests() {
tile.updateEntry(newEntry);
}
};
const tile = new GapTile({entry: new FragmentBoundaryEntry(fragment, true), roomVM: {room}});
const tile = new GapTile(new FragmentBoundaryEntry(fragment, true), {roomVM: {room}});
await tile.fill();
await tile.fill();
await tile.fill();

View File

@ -18,8 +18,8 @@ limitations under the License.
import {BaseMediaTile} from "./BaseMediaTile.js";
export class ImageTile extends BaseMediaTile {
constructor(options) {
super(options);
constructor(entry, options) {
super(entry, options);
this._lightboxUrl = this.urlCreator.urlForSegments([
// ensure the right room is active if in grid view
this.navigation.segment("room", this._room.id),

View File

@ -66,23 +66,25 @@ export class RoomMemberTile extends SimpleTile {
export function tests() {
return {
"user removes display name": (assert) => {
const tile = new RoomMemberTile({
entry: {
const tile = new RoomMemberTile(
{
prevContent: {displayname: "foo", membership: "join"},
content: {membership: "join"},
stateKey: "foo@bar.com",
},
});
{}
);
assert.strictEqual(tile.announcement, "foo@bar.com removed their name (foo)");
},
"user without display name sets a new display name": (assert) => {
const tile = new RoomMemberTile({
entry: {
const tile = new RoomMemberTile(
{
prevContent: {membership: "join"},
content: {displayname: "foo", membership: "join" },
stateKey: "foo@bar.com",
},
});
{}
);
assert.strictEqual(tile.announcement, "foo@bar.com changed their name to foo");
},
};

View File

@ -19,9 +19,9 @@ import {ViewModel} from "../../../../ViewModel";
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
export class SimpleTile extends ViewModel {
constructor(options) {
constructor(entry, options) {
super(options);
this._entry = options.entry;
this._entry = entry;
}
// view model props for all subclasses
// hmmm, could also do instanceof ... ?

View File

@ -0,0 +1,94 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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 {GapTile} from "./GapTile.js";
import {TextTile} from "./TextTile.js";
import {RedactedTile} from "./RedactedTile.js";
import {ImageTile} from "./ImageTile.js";
import {VideoTile} from "./VideoTile.js";
import {FileTile} from "./FileTile.js";
import {LocationTile} from "./LocationTile.js";
import {RoomNameTile} from "./RoomNameTile.js";
import {RoomMemberTile} from "./RoomMemberTile.js";
import {EncryptedEventTile} from "./EncryptedEventTile.js";
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
import {MissingAttachmentTile} from "./MissingAttachmentTile.js";
import type {SimpleTile} from "./SimpleTile.js";
import type {Room} from "../../../../../matrix/room/Room";
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry";
import type {PendingEventEntry} from "../../../../../matrix/room/timeline/entries/PendingEventEntry";
import type {Options as ViewModelOptions} from "../../../../ViewModel";
export type TimelineEntry = FragmentBoundaryEntry | EventEntry | PendingEventEntry;
export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined;
export type Options = ViewModelOptions & {
room: Room,
timeline: Timeline
tileClassForEntry: TileClassForEntryFn;
};
export type TileConstructor = new (entry: TimelineEntry, options: Options) => SimpleTile;
export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undefined {
if (entry.isGap) {
return GapTile;
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
return MissingAttachmentTile;
} else if (entry.eventType) {
switch (entry.eventType) {
case "m.room.message": {
if (entry.isRedacted) {
return RedactedTile;
}
const content = entry.content;
const msgtype = content && content.msgtype;
switch (msgtype) {
case "m.text":
case "m.notice":
case "m.emote":
return TextTile;
case "m.image":
return ImageTile;
case "m.video":
return VideoTile;
case "m.file":
return FileTile;
case "m.location":
return LocationTile;
default:
// unknown msgtype not rendered
return undefined;
}
}
case "m.room.name":
return RoomNameTile;
case "m.room.member":
return RoomMemberTile;
case "m.room.encrypted":
if (entry.isRedacted) {
return RedactedTile;
}
return EncryptedEventTile;
case "m.room.encryption":
return EncryptionEnabledTile;
default:
// unknown type not rendered
return undefined;
}
}
}

View File

@ -1,81 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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 {GapTile} from "./tiles/GapTile.js";
import {TextTile} from "./tiles/TextTile.js";
import {RedactedTile} from "./tiles/RedactedTile.js";
import {ImageTile} from "./tiles/ImageTile.js";
import {VideoTile} from "./tiles/VideoTile.js";
import {FileTile} from "./tiles/FileTile.js";
import {LocationTile} from "./tiles/LocationTile.js";
import {RoomNameTile} from "./tiles/RoomNameTile.js";
import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js";
export function tilesCreator(baseOptions) {
const tilesCreator = function tilesCreator(entry, emitUpdate) {
const options = Object.assign({entry, emitUpdate, tilesCreator}, baseOptions);
if (entry.isGap) {
return new GapTile(options);
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
return new MissingAttachmentTile(options);
} else if (entry.eventType) {
switch (entry.eventType) {
case "m.room.message": {
if (entry.isRedacted) {
return new RedactedTile(options);
}
const content = entry.content;
const msgtype = content && content.msgtype;
switch (msgtype) {
case "m.text":
case "m.notice":
case "m.emote":
return new TextTile(options);
case "m.image":
return new ImageTile(options);
case "m.video":
return new VideoTile(options);
case "m.file":
return new FileTile(options);
case "m.location":
return new LocationTile(options);
default:
// unknown msgtype not rendered
return null;
}
}
case "m.room.name":
return new RoomNameTile(options);
case "m.room.member":
return new RoomMemberTile(options);
case "m.room.encrypted":
if (entry.isRedacted) {
return new RedactedTile(options);
}
return new EncryptedEventTile(options);
case "m.room.encryption":
return new EncryptionEnabledTile(options);
default:
// unknown type not rendered
return null;
}
}
};
return tilesCreator;
}

View File

@ -18,6 +18,7 @@ limitations under the License.
// import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay";
import {RootViewModel} from "../../domain/RootViewModel.js";
import {createNavigation, createRouter} from "../../domain/navigation/index.js";
import {tileClassForEntry} from "../../domain/session/room/timeline/tiles/index";
// Don't use a default export here, as we use multiple entries during legacy build,
// which does not support default exports,
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry
@ -42,6 +43,8 @@ export async function main(platform) {
// so we call it that in the view models
urlCreator: urlRouter,
navigation,
// which tiles are supported by the timeline
tileClassForEntry
});
await vm.load();
platform.createAndMountRootView(vm);

View File

@ -48,6 +48,6 @@ export function viewClassForEntry(vm: SimpleTile): TileViewConstructor {
case "redacted":
return RedactedView;
default:
throw new Error(`Tiles of shape "${vm.shape}" are not supported, check the tilesCreator function in the view model`);
throw new Error(`Tiles of shape "${vm.shape}" are not supported, check the tileClassForEntry function in the view model`);
}
}