Merge pull request #714 from vector-im/bwindels/custom-tiles

Allow custom timeline tiles for SDK usage
This commit is contained in:
Bruno Windels 2022-04-08 14:29:54 +02:00 committed by GitHub
commit e977a6829b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 232 additions and 151 deletions

View file

@ -18,17 +18,20 @@ limitations under the License.
import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; import {TimelineViewModel} from "./timeline/TimelineViewModel.js";
import {ComposerViewModel} from "./ComposerViewModel.js" import {ComposerViewModel} from "./ComposerViewModel.js"
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {tilesCreator} from "./timeline/tilesCreator.js";
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel";
import {imageToInfo} from "../common.js"; import {imageToInfo} from "../common.js";
// TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry
// this is a breaking SDK change though to make this option mandatory
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
export class RoomViewModel extends ViewModel { export class RoomViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {room} = options; const {room, tileClassForEntry} = options;
this._room = room; this._room = room;
this._timelineVM = null; this._timelineVM = null;
this._tilesCreator = null; this._tileClassForEntry = tileClassForEntry ?? defaultTileClassForEntry;
this._tileOptions = undefined;
this._onRoomChange = this._onRoomChange.bind(this); this._onRoomChange = this._onRoomChange.bind(this);
this._timelineError = null; this._timelineError = null;
this._sendError = null; this._sendError = null;
@ -46,12 +49,13 @@ 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();
this._tilesCreator = tilesCreator(this.childOptions({ this._tileOptions = this.childOptions({
roomVM: this, roomVM: this,
timeline, timeline,
})); tileClassForEntry: this._tileClassForEntry,
});
this._timelineVM = this.track(new TimelineViewModel(this.childOptions({ this._timelineVM = this.track(new TimelineViewModel(this.childOptions({
tilesCreator: this._tilesCreator, tileOptions: this._tileOptions,
timeline, timeline,
}))); })));
this.emitChange("timelineViewModel"); this.emitChange("timelineViewModel");
@ -161,7 +165,12 @@ export class RoomViewModel extends ViewModel {
} }
_createTile(entry) { _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) { async _sendMessage(message, replyingTo) {

View file

@ -222,7 +222,7 @@ export function tests() {
}; };
const tiles = new MappedList(timeline.entries, entry => { const tiles = new MappedList(timeline.entries, entry => {
if (entry.eventType === "m.room.message") { if (entry.eventType === "m.room.message") {
return new BaseMessageTile({entry, 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, function () {})); }, (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"; 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 // 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 // e.g. the decision to create a tile or not should be based on properties
// not updated later on (e.g. event type) // not updated later on (e.g. event type)
// also see big comment in onUpdate // also see big comment in onUpdate
export class TilesCollection extends BaseObservableList { export class TilesCollection extends BaseObservableList {
constructor(entries, tileCreator) { constructor(entries, tileOptions) {
super(); super();
this._entries = entries; this._entries = entries;
this._tiles = null; this._tiles = null;
this._entrySubscription = null; this._entrySubscription = null;
this._tileCreator = tileCreator; this._tileOptions = tileOptions;
this._emitSpontanousUpdate = this._emitSpontanousUpdate.bind(this); 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) { _emitSpontanousUpdate(tile, params) {
const entry = tile.lowerEntry; const entry = tile.lowerEntry;
const tileIdx = this._findTileIdx(entry); const tileIdx = this._findTileIdx(entry);
@ -48,7 +55,7 @@ export class TilesCollection extends BaseObservableList {
let currentTile = null; let currentTile = null;
for (let entry of this._entries) { for (let entry of this._entries) {
if (!currentTile || !currentTile.tryIncludeEntry(entry)) { if (!currentTile || !currentTile.tryIncludeEntry(entry)) {
currentTile = this._tileCreator(entry); currentTile = this._createTile(entry);
if (currentTile) { if (currentTile) {
this._tiles.push(currentTile); this._tiles.push(currentTile);
} }
@ -121,7 +128,7 @@ export class TilesCollection extends BaseObservableList {
return; return;
} }
const newTile = this._tileCreator(entry); const newTile = this._createTile(entry);
if (newTile) { if (newTile) {
if (prevTile) { if (prevTile) {
prevTile.updateNextSibling(newTile); prevTile.updateNextSibling(newTile);
@ -150,9 +157,9 @@ 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, this._tileCreator); const action = tile.updateEntry(entry, params);
if (action.shouldReplace) { if (action.shouldReplace) {
const newTile = this._tileCreator(entry); const newTile = this._createTile(entry);
if (newTile) { if (newTile) {
this._replaceTile(tileIdx, tile, newTile, action.updateParams); this._replaceTile(tileIdx, tile, newTile, action.updateParams);
newTile.setUpdateEmit(this._emitSpontanousUpdate); newTile.setUpdateEmit(this._emitSpontanousUpdate);
@ -303,7 +310,10 @@ export function tests() {
} }
} }
const entries = new ObservableArray([{n: 5}, {n: 10}]); 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; let receivedAdd = false;
tiles.subscribe({ tiles.subscribe({
onAdd(idx, tile) { onAdd(idx, tile) {
@ -326,7 +336,10 @@ export function tests() {
} }
} }
const entries = new ObservableArray([{n: 5}, {n: 10}, {n: 15}]); 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 = []; const events = [];
tiles.subscribe({ tiles.subscribe({
onUpdate(idx, tile) { onUpdate(idx, tile) {

View file

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

View file

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

View file

@ -19,8 +19,8 @@ import {ReactionsViewModel} from "../ReactionsViewModel.js";
import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar"; import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar";
export class BaseMessageTile extends SimpleTile { export class BaseMessageTile extends SimpleTile {
constructor(options) { constructor(entry, options) {
super(options); super(entry, options);
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;
@ -28,7 +28,7 @@ export class BaseMessageTile extends SimpleTile {
if (this._entry.annotations || this._entry.pendingAnnotations) { if (this._entry.annotations || this._entry.pendingAnnotations) {
this._updateReactions(); this._updateReactions();
} }
this._updateReplyTileIfNeeded(options.tilesCreator, undefined); this._updateReplyTileIfNeeded(undefined);
} }
notifyVisible() { notifyVisible() {
@ -122,23 +122,27 @@ export class BaseMessageTile extends SimpleTile {
} }
} }
updateEntry(entry, param, tilesCreator) { updateEntry(entry, param) {
const action = super.updateEntry(entry, param, tilesCreator); const action = super.updateEntry(entry, param);
if (action.shouldUpdate) { if (action.shouldUpdate) {
this._updateReactions(); this._updateReactions();
} }
this._updateReplyTileIfNeeded(tilesCreator, param); this._updateReplyTileIfNeeded(param);
return action; return action;
} }
_updateReplyTileIfNeeded(tilesCreator, param) { _updateReplyTileIfNeeded(param) {
const replyEntry = this._entry.contextEntry; const replyEntry = this._entry.contextEntry;
if (replyEntry) { if (replyEntry) {
// this is an update to contextEntry used for replyPreview // 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) { if (action?.shouldReplace || !this._replyTile) {
this.disposeTracked(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) { if(action?.shouldUpdate) {
this._replyTile?.emitChange(); this._replyTile?.emitChange();

View file

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

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, tilesCreator) { updateEntry(entry, params) {
const parentResult = super.updateEntry(entry, params, tilesCreator); const parentResult = super.updateEntry(entry, params);
// 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

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

View file

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

View file

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

View file

@ -66,23 +66,25 @@ export class RoomMemberTile extends SimpleTile {
export function tests() { export function tests() {
return { return {
"user removes display name": (assert) => { "user removes display name": (assert) => {
const tile = new RoomMemberTile({ const tile = new RoomMemberTile(
entry: { {
prevContent: {displayname: "foo", membership: "join"}, prevContent: {displayname: "foo", membership: "join"},
content: {membership: "join"}, content: {membership: "join"},
stateKey: "foo@bar.com", stateKey: "foo@bar.com",
}, },
}); {}
);
assert.strictEqual(tile.announcement, "foo@bar.com removed their name (foo)"); assert.strictEqual(tile.announcement, "foo@bar.com removed their name (foo)");
}, },
"user without display name sets a new display name": (assert) => { "user without display name sets a new display name": (assert) => {
const tile = new RoomMemberTile({ const tile = new RoomMemberTile(
entry: { {
prevContent: {membership: "join"}, prevContent: {membership: "join"},
content: {displayname: "foo", membership: "join" }, content: {displayname: "foo", membership: "join" },
stateKey: "foo@bar.com", stateKey: "foo@bar.com",
}, },
}); {}
);
assert.strictEqual(tile.announcement, "foo@bar.com changed their name to foo"); 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"; import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
export class SimpleTile extends ViewModel { export class SimpleTile extends ViewModel {
constructor(options) { constructor(entry, options) {
super(options); super(options);
this._entry = options.entry; this._entry = entry;
} }
// view model props for all subclasses // view model props for all subclasses
// hmmm, could also do instanceof ... ? // 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

@ -26,7 +26,41 @@ export {SessionView} from "./platform/web/ui/session/SessionView.js";
export {RoomViewModel} from "./domain/session/room/RoomViewModel.js"; export {RoomViewModel} from "./domain/session/room/RoomViewModel.js";
export {RoomView} from "./platform/web/ui/session/room/RoomView.js"; export {RoomView} from "./platform/web/ui/session/room/RoomView.js";
export {TimelineViewModel} from "./domain/session/room/timeline/TimelineViewModel.js"; export {TimelineViewModel} from "./domain/session/room/timeline/TimelineViewModel.js";
export {tileClassForEntry} from "./domain/session/room/timeline/tiles/index";
export type {TimelineEntry, TileClassForEntryFn, Options, TileConstructor} from "./domain/session/room/timeline/tiles/index";
// export timeline tile view models
export {GapTile} from "./domain/session/room/timeline/tiles/GapTile.js";
export {TextTile} from "./domain/session/room/timeline/tiles/TextTile.js";
export {RedactedTile} from "./domain/session/room/timeline/tiles/RedactedTile.js";
export {ImageTile} from "./domain/session/room/timeline/tiles/ImageTile.js";
export {VideoTile} from "./domain/session/room/timeline/tiles/VideoTile.js";
export {FileTile} from "./domain/session/room/timeline/tiles/FileTile.js";
export {LocationTile} from "./domain/session/room/timeline/tiles/LocationTile.js";
export {RoomNameTile} from "./domain/session/room/timeline/tiles/RoomNameTile.js";
export {RoomMemberTile} from "./domain/session/room/timeline/tiles/RoomMemberTile.js";
export {EncryptedEventTile} from "./domain/session/room/timeline/tiles/EncryptedEventTile.js";
export {EncryptionEnabledTile} from "./domain/session/room/timeline/tiles/EncryptionEnabledTile.js";
export {MissingAttachmentTile} from "./domain/session/room/timeline/tiles/MissingAttachmentTile.js";
export {SimpleTile} from "./domain/session/room/timeline/tiles/SimpleTile.js";
export {TimelineView} from "./platform/web/ui/session/room/TimelineView"; export {TimelineView} from "./platform/web/ui/session/room/TimelineView";
export {viewClassForEntry} from "./platform/web/ui/session/room/common";
export type {TileViewConstructor, ViewClassForEntryFn} from "./platform/web/ui/session/room/TimelineView";
// export timeline tile views
export {AnnouncementView} from "./platform/web/ui/session/room/timeline/AnnouncementView.js";
export {BaseMediaView} from "./platform/web/ui/session/room/timeline/BaseMediaView.js";
export {BaseMessageView} from "./platform/web/ui/session/room/timeline/BaseMessageView.js";
export {FileView} from "./platform/web/ui/session/room/timeline/FileView.js";
export {GapView} from "./platform/web/ui/session/room/timeline/GapView.js";
export {ImageView} from "./platform/web/ui/session/room/timeline/ImageView.js";
export {LocationView} from "./platform/web/ui/session/room/timeline/LocationView.js";
export {MissingAttachmentView} from "./platform/web/ui/session/room/timeline/MissingAttachmentView.js";
export {ReactionsView} from "./platform/web/ui/session/room/timeline/ReactionsView.js";
export {RedactedView} from "./platform/web/ui/session/room/timeline/RedactedView.js";
export {ReplyPreviewView} from "./platform/web/ui/session/room/timeline/ReplyPreviewView.js";
export {TextMessageView} from "./platform/web/ui/session/room/timeline/TextMessageView.js";
export {VideoView} from "./platform/web/ui/session/room/timeline/VideoView.js";
export {Navigation} from "./domain/navigation/Navigation.js"; export {Navigation} from "./domain/navigation/Navigation.js";
export {ComposerViewModel} from "./domain/session/room/ComposerViewModel.js"; export {ComposerViewModel} from "./domain/session/room/ComposerViewModel.js";
export {MessageComposer} from "./platform/web/ui/session/room/MessageComposer.js"; export {MessageComposer} from "./platform/web/ui/session/room/MessageComposer.js";

View file

@ -23,6 +23,7 @@ import {TimelineLoadingView} from "./TimelineLoadingView.js";
import {MessageComposer} from "./MessageComposer.js"; import {MessageComposer} from "./MessageComposer.js";
import {RoomArchivedView} from "./RoomArchivedView.js"; import {RoomArchivedView} from "./RoomArchivedView.js";
import {AvatarView} from "../../AvatarView.js"; import {AvatarView} from "../../AvatarView.js";
import {viewClassForEntry} from "./common";
export class RoomView extends TemplateView { export class RoomView extends TemplateView {
constructor(options) { constructor(options) {
@ -54,7 +55,7 @@ export class RoomView extends TemplateView {
t.div({className: "RoomView_error"}, vm => vm.error), t.div({className: "RoomView_error"}, vm => vm.error),
t.mapView(vm => vm.timelineViewModel, timelineViewModel => { t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
return timelineViewModel ? return timelineViewModel ?
new TimelineView(timelineViewModel) : new TimelineView(timelineViewModel, viewClassForEntry) :
new TimelineLoadingView(vm); // vm is just needed for i18n new TimelineLoadingView(vm); // vm is just needed for i18n
}), }),
t.view(bottomView), t.view(bottomView),

View file

@ -14,9 +14,8 @@ 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 type {IView} from "../../general/types";
import {TemplateView, Builder} from "../../general/TemplateView"; import {TemplateView, Builder} from "../../general/TemplateView";
import {IObservableValue} from "../../general/BaseUpdateView"; import {IObservableValue} from "../../general/BaseUpdateView";
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
@ -25,6 +24,13 @@ import {RedactedView} from "./timeline/RedactedView.js";
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js"; import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
import {BaseObservableList as ObservableList} from "../../../../../observable/list/BaseObservableList"; import {BaseObservableList as ObservableList} from "../../../../../observable/list/BaseObservableList";
export interface TileView extends IView {
readonly value: SimpleTile;
onClick(event: UIEvent);
}
export type TileViewConstructor = new (tile: SimpleTile) => TileView;
export type ViewClassForEntryFn = (tile: SimpleTile) => TileViewConstructor;
//import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js"; //import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js";
export interface TimelineViewModel extends IObservableValue { export interface TimelineViewModel extends IObservableValue {
showJumpDown: boolean; showJumpDown: boolean;
@ -55,13 +61,17 @@ export class TimelineView extends TemplateView<TimelineViewModel> {
private tilesView?: TilesListView; private tilesView?: TilesListView;
private resizeObserver?: ResizeObserver; private resizeObserver?: ResizeObserver;
constructor(vm: TimelineViewModel, private readonly viewClassForEntry: ViewClassForEntryFn) {
super(vm);
}
render(t: Builder<TimelineViewModel>, vm: TimelineViewModel) { render(t: Builder<TimelineViewModel>, vm: TimelineViewModel) {
// assume this view will be mounted in the parent DOM straight away // assume this view will be mounted in the parent DOM straight away
requestAnimationFrame(() => { requestAnimationFrame(() => {
// do initial scroll positioning // do initial scroll positioning
this.restoreScrollPosition(); this.restoreScrollPosition();
}); });
this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition()); this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition(), this.viewClassForEntry);
const root = t.div({className: "Timeline"}, [ const root = t.div({className: "Timeline"}, [
t.div({ t.div({
className: "Timeline_scroller bottom-aligned-scroll", className: "Timeline_scroller bottom-aligned-scroll",
@ -174,16 +184,13 @@ class TilesListView extends ListView<SimpleTile, TileView> {
private onChanged: () => void; private onChanged: () => void;
constructor(tiles: ObservableList<SimpleTile>, onChanged: () => void) { constructor(tiles: ObservableList<SimpleTile>, onChanged: () => void, private readonly viewClassForEntry: ViewClassForEntryFn) {
const options = { super({
list: tiles, list: tiles,
onItemClick: (tileView, evt) => tileView.onClick(evt), onItemClick: (tileView, evt) => tileView.onClick(evt),
}; }, entry => {
super(options, entry => {
const View = viewClassForEntry(entry); const View = viewClassForEntry(entry);
if (View) {
return new View(entry); return new View(entry);
}
}); });
this.onChanged = onChanged; this.onChanged = onChanged;
} }
@ -195,7 +202,7 @@ class TilesListView extends ListView<SimpleTile, TileView> {
onUpdate(index: number, value: SimpleTile, param: any) { onUpdate(index: number, value: SimpleTile, param: any) {
if (param === "shape") { if (param === "shape") {
const ExpectedClass = viewClassForEntry(value); const ExpectedClass = this.viewClassForEntry(value);
const child = this.getChildInstanceByIndex(index); const child = this.getChildInstanceByIndex(index);
if (!ExpectedClass || !(child instanceof ExpectedClass)) { if (!ExpectedClass || !(child instanceof ExpectedClass)) {
// shape was updated, so we need to recreate the tile view, // shape was updated, so we need to recreate the tile view,

View file

@ -24,14 +24,10 @@ import {AnnouncementView} from "./timeline/AnnouncementView.js";
import {RedactedView} from "./timeline/RedactedView.js"; import {RedactedView} from "./timeline/RedactedView.js";
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js"; import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
import {GapView} from "./timeline/GapView.js"; import {GapView} from "./timeline/GapView.js";
import type {TileViewConstructor, ViewClassForEntryFn} from "./TimelineView";
export type TileView = GapView | AnnouncementView | TextMessageView | export function viewClassForEntry(vm: SimpleTile): TileViewConstructor {
ImageView | VideoView | FileView | LocationView | MissingAttachmentView | RedactedView; switch (vm.shape) {
// 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": case "gap":
return GapView; return GapView;
case "announcement": case "announcement":
@ -51,5 +47,7 @@ export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | unde
return MissingAttachmentView; return MissingAttachmentView;
case "redacted": case "redacted":
return RedactedView; return RedactedView;
default:
throw new Error(`Tiles of shape "${vm.shape}" are not supported, check the tileClassForEntry function in the view model`);
} }
} }