forked from mystiq/hydrogen-web
Merge pull request #714 from vector-im/bwindels/custom-tiles
Allow custom timeline tiles for SDK usage
This commit is contained in:
commit
e977a6829b
19 changed files with 232 additions and 151 deletions
|
@ -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) {
|
||||||
|
|
|
@ -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 () {}));
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 ... ?
|
||||||
|
|
94
src/domain/session/room/timeline/tiles/index.ts
Normal file
94
src/domain/session/room/timeline/tiles/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
34
src/lib.ts
34
src/lib.ts
|
@ -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";
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue