Merge branch 'master' into bwindels/calls

This commit is contained in:
Bruno Windels 2022-04-11 16:14:34 +02:00
commit d734a61447
40 changed files with 890 additions and 204 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ target
lib
*.tar.gz
.eslintcache
.tmp

View file

@ -10,7 +10,7 @@
"lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts",
"lint-ci": "eslint src/",
"test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/",
"test:postcss": "impunity --entry-point scripts/postcss/test.js ",
"test:postcss": "impunity --entry-point scripts/postcss/tests/css-compile-variables.test.js scripts/postcss/tests/css-url-to-variables.test.js",
"start": "vite --port 3000",
"build": "vite build",
"build:sdk": "./scripts/sdk/build.sh"
@ -43,6 +43,7 @@
"node-html-parser": "^4.0.0",
"postcss-css-variables": "^0.18.0",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-value-parser": "^4.2.0",
"regenerator-runtime": "^0.13.7",
"text-encoding": "^0.7.0",
"typescript": "^4.4",
@ -54,7 +55,6 @@
"another-json": "^0.2.0",
"base64-arraybuffer": "^0.2.0",
"dompurify": "^2.3.0",
"off-color": "^2.0.0",
"postcss-value-parser": "^4.2.0"
"off-color": "^2.0.0"
}
}

View file

@ -0,0 +1,174 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
async function readCSSSource(location) {
const fs = require("fs").promises;
const path = require("path");
const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`);
const data = await fs.readFile(resolvedLocation);
return data;
}
async function appendVariablesToCSS(variables, cssSource) {
return cssSource + `:root{\n${Object.entries(variables).reduce((acc, [key, value]) => acc + `--${key}: ${value};\n`, "")} }\n\n`;
}
function parseBundle(bundle) {
const chunkMap = new Map();
const assetMap = new Map();
let runtimeThemeChunk;
for (const [fileName, info] of Object.entries(bundle)) {
if (!fileName.endsWith(".css")) {
continue;
}
if (info.type === "asset") {
/**
* So this is the css assetInfo that contains the asset hashed file name.
* We'll store it in a separate map indexed via fileName (unhashed) to avoid
* searching through the bundle array later.
*/
assetMap.set(info.name, info);
continue;
}
if (info.facadeModuleId?.includes("type=runtime")) {
/**
* We have a separate field in manifest.source just for the runtime theme,
* so store this separately.
*/
runtimeThemeChunk = info;
continue;
}
const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1];
if (!location) {
throw new Error("Cannot find location of css chunk!");
}
const array = chunkMap.get(location);
if (!array) {
chunkMap.set(location, [info]);
}
else {
array.push(info);
}
}
return { chunkMap, assetMap, runtimeThemeChunk };
}
module.exports = function buildThemes(options) {
let manifest, variants, defaultDark, defaultLight;
return {
name: "build-themes",
enforce: "pre",
async buildStart() {
const { manifestLocations } = options;
for (const location of manifestLocations) {
manifest = require(`${location}/manifest.json`);
variants = manifest.values.variants;
const themeName = manifest.name;
for (const [variant, details] of Object.entries(variants)) {
const fileName = `theme-${themeName}-${variant}.css`;
if (details.default) {
// This is one of the default variants for this theme.
if (details.dark) {
defaultDark = fileName;
}
else {
defaultLight = fileName;
}
}
// emit the css as built theme bundle
this.emitFile({
type: "chunk",
id: `${location}/theme.css?variant=${variant}`,
fileName,
});
}
// emit the css as runtime theme bundle
this.emitFile({
type: "chunk",
id: `${location}/theme.css?type=runtime`,
fileName: `theme-${themeName}-runtime.css`,
});
}
},
async load(id) {
const result = id.match(/(.+)\/theme.css\?variant=(.+)/);
if (result) {
const [, location, variant] = result;
const cssSource = await readCSSSource(location);
const config = variants[variant];
return await appendVariablesToCSS(config.variables, cssSource);
}
return null;
},
transformIndexHtml(_, ctx) {
let darkThemeLocation, lightThemeLocation;
for (const [, bundle] of Object.entries(ctx.bundle)) {
if (bundle.name === defaultDark) {
darkThemeLocation = bundle.fileName;
}
if (bundle.name === defaultLight) {
lightThemeLocation = bundle.fileName;
}
}
return [
{
tag: "link",
attrs: {
rel: "stylesheet",
type: "text/css",
media: "(prefers-color-scheme: dark)",
href: `./${darkThemeLocation}`,
}
},
{
tag: "link",
attrs: {
rel: "stylesheet",
type: "text/css",
media: "(prefers-color-scheme: light)",
href: `./${lightThemeLocation}`,
}
},
];
},
generateBundle(_, bundle) {
const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle);
for (const [location, chunkArray] of chunkMap) {
const manifest = require(`${location}/manifest.json`);
const compiledVariables = options.compiledVariables.get(location);
const derivedVariables = compiledVariables["derived-variables"];
const icon = compiledVariables["icon"];
manifest.source = {
"built-asset": chunkArray.map(chunk => assetMap.get(chunk.fileName).fileName),
"runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName,
"derived-variables": derivedVariables,
"icon": icon
};
const name = `theme-${manifest.name}.json`;
this.emitFile({
type: "asset",
name,
source: JSON.stringify(manifest),
});
}
}
}
}

View file

@ -43,17 +43,32 @@ function parseDeclarationValue(value) {
const parsed = valueParser(value);
const variables = [];
parsed.walk(node => {
if (node.type !== "function" && node.value !== "var") {
if (node.type !== "function") {
return;
}
switch (node.value) {
case "var": {
const variable = node.nodes[0];
variables.push(variable.value);
break;
}
case "url": {
const url = node.nodes[0].value;
// resolve url with some absolute url so that we get the query params without using regex
const params = new URL(url, "file://foo/bar/").searchParams;
const primary = params.get("primary");
const secondary = params.get("secondary");
if (primary) { variables.push(primary); }
if (secondary) { variables.push(secondary); }
break;
}
}
});
return variables;
}
function resolveDerivedVariable(decl, derive) {
const RE_VARIABLE_VALUE = /--((.+)--(.+)-(.+))/;
const RE_VARIABLE_VALUE = /(?:--)?((.+)--(.+)-(.+))/;
const variableCollection = parseDeclarationValue(decl.value);
for (const variable of variableCollection) {
const matches = variable.match(RE_VARIABLE_VALUE);
@ -94,6 +109,15 @@ function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) {
root.append(newRule);
}
function populateMapWithDerivedVariables(map, cssFileLocation) {
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
const derivedVariables = [
...([...resolvedMap.keys()].filter(v => !aliasMap.has(v))),
...([...aliasMap.entries()].map(([alias, variable]) => `${alias}=${variable}`))
];
map.set(location, { "derived-variables": derivedVariables });
}
/**
* @callback derive
* @param {string} value - The base value on which an operation is applied
@ -104,6 +128,7 @@ function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) {
*
* @param {Object} opts - Options for the plugin
* @param {derive} opts.derive - The callback which contains the logic for resolving derived variables
* @param {Map} opts.compiledVariables - A map that stores derived variables so that manifest source sections can be produced
*/
module.exports = (opts = {}) => {
aliasMap = new Map();
@ -112,7 +137,12 @@ module.exports = (opts = {}) => {
return {
postcssPlugin: "postcss-compile-variables",
Once(root, {Rule, Declaration}) {
Once(root, {Rule, Declaration, result}) {
const cssFileLocation = root.source.input.from;
if (cssFileLocation.includes("type=runtime")) {
// If this is a runtime theme, don't derive variables.
return;
}
/*
Go through the CSS file once to extract all aliases and base variables.
We use these when resolving derived variables later.
@ -120,6 +150,16 @@ module.exports = (opts = {}) => {
root.walkDecls(decl => extract(decl));
root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive));
addResolvedVariablesToRootSelector(root, {Rule, Declaration});
if (opts.compiledVariables){
populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation);
}
// Publish both the base-variables and derived-variables to the other postcss-plugins
const combinedMap = new Map([...baseVariables, ...resolvedMap]);
result.messages.push({
type: "resolved-variable-map",
plugin: "postcss-compile-variables",
colorMap: combinedMap,
});
},
};
};

View file

@ -0,0 +1,87 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const valueParser = require("postcss-value-parser");
const resolve = require("path").resolve;
function colorsFromURL(url, colorMap) {
const params = new URL(`file://${url}`).searchParams;
const primary = params.get("primary");
if (!primary) {
return null;
}
const secondary = params.get("secondary");
const primaryColor = colorMap.get(primary);
const secondaryColor = colorMap.get(secondary);
if (!primaryColor) {
throw new Error(`Variable ${primary} not found in resolved color variables!`);
}
if (secondary && !secondaryColor) {
throw new Error(`Variable ${secondary} not found in resolved color variables!`);
}
return [primaryColor, secondaryColor];
}
function processURL(decl, replacer, colorMap) {
const value = decl.value;
const parsed = valueParser(value);
parsed.walk(async node => {
if (node.type !== "function" || node.value !== "url") {
return;
}
const urlStringNode = node.nodes[0];
const oldURL = urlStringNode.value;
const cssPath = decl.source?.input.file.replace(/[^/]*$/, "");
const oldURLAbsolute = resolve(cssPath, oldURL);
const colors = colorsFromURL(oldURLAbsolute, colorMap);
if (!colors) {
// If no primary color is provided via url params, then this url need not be handled.
return;
}
const newURL = replacer(oldURLAbsolute.replace(/\?.+/, ""), ...colors);
if (!newURL) {
throw new Error("Replacer failed to produce a replacement URL!");
}
urlStringNode.value = newURL;
});
decl.assign({prop: decl.prop, value: parsed.toString()})
}
/* *
* @type {import('postcss').PluginCreator}
*/
module.exports = (opts = {}) => {
return {
postcssPlugin: "postcss-url-to-variable",
Once(root, {result}) {
/*
postcss-compile-variables should have sent the list of resolved colours down via results
*/
const {colorMap} = result.messages.find(m => m.type === "resolved-variable-map");
if (!colorMap) {
throw new Error("Postcss results do not contain resolved colors!");
}
/*
Go through each declaration and if it contains an URL, replace the url with the result
of running replacer(url)
*/
root.walkDecls(decl => processURL(decl, opts.replacer, colorMap));
},
};
};
module.exports.postcss = true;

View file

@ -0,0 +1,85 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const valueParser = require("postcss-value-parser");
/**
* This plugin extracts content inside url() into css variables and adds the variables to the root section.
* This plugin is used in conjunction with css-url-processor plugin to colorize svg icons.
*/
let counter;
let urlVariables;
const idToPrepend = "icon-url";
function findAndReplaceUrl(decl) {
const value = decl.value;
const parsed = valueParser(value);
parsed.walk(node => {
if (node.type !== "function" || node.value !== "url") {
return;
}
const url = node.nodes[0].value;
if (!url.match(/\.svg\?primary=.+/)) {
return;
}
const variableName = `${idToPrepend}-${counter++}`;
urlVariables.set(variableName, url);
node.value = "var";
node.nodes = [{ type: "word", value: `--${variableName}` }];
});
decl.assign({prop: decl.prop, value: parsed.toString()})
}
function addResolvedVariablesToRootSelector(root, { Rule, Declaration }) {
const newRule = new Rule({ selector: ":root", source: root.source });
// Add derived css variables to :root
urlVariables.forEach((value, key) => {
const declaration = new Declaration({ prop: `--${key}`, value: `url("${value}")`});
newRule.append(declaration);
});
root.append(newRule);
}
function populateMapWithIcons(map, cssFileLocation) {
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
const sharedObject = map.get(location);
sharedObject["icon"] = Object.fromEntries(urlVariables);
}
/* *
* @type {import('postcss').PluginCreator}
*/
module.exports = (opts = {}) => {
urlVariables = new Map();
counter = 0;
return {
postcssPlugin: "postcss-url-to-variable",
Once(root, { Rule, Declaration }) {
root.walkDecls(decl => findAndReplaceUrl(decl));
if (urlVariables.size) {
addResolvedVariablesToRootSelector(root, { Rule, Declaration });
}
if (opts.compiledVariables){
const cssFileLocation = root.source.input.from;
populateMapWithIcons(opts.compiledVariables, cssFileLocation);
}
},
};
};
module.exports.postcss = true;

View file

@ -0,0 +1,45 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const fs = require("fs");
const path = require("path");
/**
* Builds a new svg with the colors replaced and returns its location.
* @param {string} svgLocation The location of the input svg file
* @param {string} primaryColor Primary color for the new svg
* @param {string} secondaryColor Secondary color for the new svg
*/
module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondaryColor) {
const svgCode = fs.readFileSync(svgLocation, { encoding: "utf8"});
let coloredSVGCode = svgCode.replaceAll("#ff00ff", primaryColor);
coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor);
if (svgCode === coloredSVGCode) {
throw new Error("svg-colorizer made no color replacements! The input svg should only contain colors #ff00ff (primary, case-sensitive) and #00ffff (secondary, case-sensitive).");
}
const fileName = svgLocation.match(/.+\/(.+\.svg)/)[1];
const outputPath = path.resolve(__dirname, "../../.tmp");
try {
fs.mkdirSync(outputPath);
}
catch (e) {
if (e.code !== "EEXIST") {
throw e;
}
}
const outputFile = `${outputPath}/${fileName}`;
fs.writeFileSync(outputFile, coloredSVGCode);
return outputFile;
}

View file

@ -0,0 +1,30 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const postcss = require("postcss");
module.exports.createTestRunner = function (plugin) {
return async function run(input, output, opts = {}, assert) {
let result = await postcss([plugin(opts)]).process(input, { from: undefined, });
assert.strictEqual(
result.css.replaceAll(/\s/g, ""),
output.replaceAll(/\s/g, "")
);
assert.strictEqual(result.warnings().length, 0);
};
}

View file

@ -16,17 +16,9 @@ limitations under the License.
const offColor = require("off-color").offColor;
const postcss = require("postcss");
const plugin = require("./css-compile-variables");
const derive = require("./color").derive;
async function run(input, output, opts = {}, assert) {
let result = await postcss([plugin({ ...opts, derive })]).process(input, { from: undefined, });
assert.strictEqual(
result.css.replaceAll(/\s/g, ""),
output.replaceAll(/\s/g, "")
);
assert.strictEqual(result.warnings().length, 0);
}
const plugin = require("../css-compile-variables");
const derive = require("../color").derive;
const run = require("./common").createTestRunner(plugin);
module.exports.tests = function tests() {
return {
@ -46,7 +38,7 @@ module.exports.tests = function tests() {
--foo-color--lighter-50: ${transformedColor.hex()};
}
`;
await run( inputCSS, outputCSS, {}, assert);
await run( inputCSS, outputCSS, {derive}, assert);
},
"derived variables work with alias": async (assert) => {
@ -66,7 +58,7 @@ module.exports.tests = function tests() {
--my-alias--lighter-15: ${aliasLighter};
}
`;
await run(inputCSS, outputCSS, { }, assert);
await run(inputCSS, outputCSS, {derive}, assert);
},
"derived variable throws if base not present in config": async (assert) => {
@ -94,8 +86,9 @@ module.exports.tests = function tests() {
--foo-color--darker-20: ${transformedColor2.hex()};
}
`;
await run( inputCSS, outputCSS, { }, assert);
await run( inputCSS, outputCSS, {derive}, assert);
},
"multiple aliased-derived variable in single declaration is parsed correctly": async (assert) => {
const inputCSS = `
:root {
@ -115,7 +108,49 @@ module.exports.tests = function tests() {
--my-alias--darker-20: ${transformedColor2.hex()};
}
`;
await run( inputCSS, outputCSS, { }, assert);
await run( inputCSS, outputCSS, {derive}, assert);
},
"compiledVariables map is populated": async (assert) => {
const compiledVariables = new Map();
const inputCSS = `
:root {
--icon-color: #fff;
}
div {
background: var(--icon-color--darker-20);
--my-alias: var(--icon-color--darker-20);
color: var(--my-alias--lighter-15);
}`;
await postcss([plugin({ derive, compiledVariables })]).process(inputCSS, { from: "/foo/bar/test.css", });
const actualArray = compiledVariables.get("/foo/bar")["derived-variables"];
const expectedArray = ["icon-color--darker-20", "my-alias=icon-color--darker-20", "my-alias--lighter-15"];
assert.deepStrictEqual(actualArray.sort(), expectedArray.sort());
},
"derived variable are supported in urls": async (assert) => {
const inputCSS = `
:root {
--foo-color: #ff0;
}
div {
background-color: var(--foo-color--lighter-50);
background: url("./foo/bar/icon.svg?primary=foo-color--darker-5");
}
a {
background: url("foo/bar/icon.svg");
}`;
const transformedColorLighter = offColor("#ff0").lighten(0.5);
const transformedColorDarker = offColor("#ff0").darken(0.05);
const outputCSS =
inputCSS +
`
:root {
--foo-color--lighter-50: ${transformedColorLighter.hex()};
--foo-color--darker-5: ${transformedColorDarker.hex()};
}
`;
await run( inputCSS, outputCSS, {derive}, assert);
}
};
};

View file

@ -0,0 +1,71 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const plugin = require("../css-url-to-variables");
const run = require("./common").createTestRunner(plugin);
const postcss = require("postcss");
module.exports.tests = function tests() {
return {
"url is replaced with variable": async (assert) => {
const inputCSS = `div {
background: no-repeat center/80% url("../img/image.svg?primary=main-color--darker-20");
}
button {
background: url("/home/foo/bar/cool.svg?primary=blue&secondary=green");
}`;
const outputCSS =
`div {
background: no-repeat center/80% var(--icon-url-0);
}
button {
background: var(--icon-url-1);
}`+
`
:root {
--icon-url-0: url("../img/image.svg?primary=main-color--darker-20");
--icon-url-1: url("/home/foo/bar/cool.svg?primary=blue&secondary=green");
}
`;
await run(inputCSS, outputCSS, { }, assert);
},
"non svg urls without query params are not replaced": async (assert) => {
const inputCSS = `div {
background: no-repeat url("./img/foo/bar/image.png");
}`;
await run(inputCSS, inputCSS, {}, assert);
},
"map is populated with icons": async (assert) => {
const compiledVariables = new Map();
compiledVariables.set("/foo/bar", { "derived-variables": ["background-color--darker-20", "accent-color--lighter-15"] });
const inputCSS = `div {
background: no-repeat center/80% url("../img/image.svg?primary=main-color--darker-20");
}
button {
background: url("/home/foo/bar/cool.svg?primary=blue&secondary=green");
}`;
const expectedObject = {
"icon-url-0": "../img/image.svg?primary=main-color--darker-20",
"icon-url-1": "/home/foo/bar/cool.svg?primary=blue&secondary=green",
};
await postcss([plugin({compiledVariables})]).process(inputCSS, { from: "/foo/bar/test.css", });
const sharedVariable = compiledVariables.get("/foo/bar");
assert.deepEqual(["background-color--darker-20", "accent-color--lighter-15"], sharedVariable["derived-variables"]);
assert.deepEqual(expectedObject, sharedVariable["icon"]);
}
};
};

View file

@ -1,7 +1,7 @@
{
"name": "hydrogen-view-sdk",
"description": "Embeddable matrix client library, including view components",
"version": "0.0.9",
"version": "0.0.10",
"main": "./hydrogen.es.js",
"type": "module"
}

View file

@ -58,6 +58,14 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this._options[name];
}
observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) {
const segmentObservable = this.navigation.observe(type);
const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => {
onChange(value, type);
})
this.track(unsubscribe);
}
track<D extends Disposable>(disposable: D): D {
if (!this.disposables) {
this.disposables = new Disposables();

View file

@ -20,18 +20,21 @@ import {ComposerViewModel} from "./ComposerViewModel.js"
import {CallViewModel} from "./CallViewModel"
import {PickMapObservableValue} from "../../../observable/value/PickMapObservableValue";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {tilesCreator} from "./timeline/tilesCreator.js";
import {ViewModel} from "../../ViewModel";
import {imageToInfo} from "../common.js";
import {LocalMedia} from "../../../matrix/calls/LocalMedia";
// 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 {
constructor(options) {
super(options);
const {room} = options;
const {room, tileClassForEntry} = options;
this._room = room;
this._timelineVM = null;
this._tilesCreator = null;
this._tileClassForEntry = tileClassForEntry ?? defaultTileClassForEntry;
this._tileOptions = undefined;
this._onRoomChange = this._onRoomChange.bind(this);
this._timelineError = null;
this._sendError = null;
@ -73,13 +76,14 @@ 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({
session: this.getOption("session"),
roomVM: this,
timeline,
}));
tileClassForEntry: this._tileClassForEntry,
});
this._timelineVM = this.track(new TimelineViewModel(this.childOptions({
tilesCreator: this._tilesCreator,
tileOptions: this._tileOptions,
timeline,
})));
this.emitChange("timelineViewModel");
@ -189,7 +193,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: () => 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: () => 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() {
@ -114,23 +114,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

@ -23,9 +23,8 @@ import {LocalMedia} from "../../../../../matrix/calls/LocalMedia";
// alternatively, we could just subscribe to the GroupCall and spontanously emit an update when it updates
export class CallTile extends SimpleTile {
constructor(options) {
super(options);
constructor(entry, options) {
super(entry, options);
const calls = this.getOption("session").callHandler.calls;
this._call = calls.get(this._entry.stateKey);
this._callSubscription = undefined;

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,105 @@
/*
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 {CallTile} from "./CallTile.js";
import type {SimpleTile} from "./SimpleTile.js";
import type {Room} from "../../../../../matrix/room/Room";
import type {Session} from "../../../../../matrix/Session";
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 & {
session: Session,
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;
case "org.matrix.msc3401.call": {
// if prevContent is present, it's an update to a call event, which we don't render
// as the original event is updated through the call object which receive state event updates
if (entry.stateKey && !entry.prevContent) {
return CallTile;
}
return undefined;
}
default:
// unknown type not rendered
return undefined;
}
}
}

View file

@ -1,86 +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";
import {CallTile} from "./tiles/CallTile.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);
case "org.matrix.msc3401.call":
// if prevContent is present, it's an update to a call event, which we don't render
// as the original event is updated through the call object which receive state event updates
return entry.stateKey && !entry.prevContent ? new CallTile(options) : null;
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 {RoomView} from "./platform/web/ui/session/room/RoomView.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 {viewClassForTile} 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 {ComposerViewModel} from "./domain/session/room/ComposerViewModel.js";
export {MessageComposer} from "./platform/web/ui/session/room/MessageComposer.js";

View file

@ -21,6 +21,11 @@ import {TemplateView} from "../general/TemplateView";
import {StaticView} from "../general/StaticView.js";
export class RoomGridView extends TemplateView {
constructor(vm, viewClassForTile) {
super(vm);
this._viewClassForTile = viewClassForTile;
}
render(t, vm) {
const children = [];
for (let i = 0; i < (vm.height * vm.width); i+=1) {
@ -39,7 +44,7 @@ export class RoomGridView extends TemplateView {
} else if (roomVM.kind === "invite") {
return new InviteView(roomVM);
} else {
return new RoomView(roomVM);
return new RoomView(roomVM, this._viewClassForTile);
}
} else {
return new StaticView(t => t.div({className: "room-placeholder"}, [

View file

@ -28,6 +28,7 @@ import {RoomGridView} from "./RoomGridView.js";
import {SettingsView} from "./settings/SettingsView.js";
import {CreateRoomView} from "./CreateRoomView.js";
import {RightPanelView} from "./rightpanel/RightPanelView.js";
import {viewClassForTile} from "./room/common";
export class SessionView extends TemplateView {
render(t, vm) {
@ -42,7 +43,7 @@ export class SessionView extends TemplateView {
t.view(new LeftPanelView(vm.leftPanelViewModel)),
t.mapView(vm => vm.activeMiddleViewModel, () => {
if (vm.roomGridViewModel) {
return new RoomGridView(vm.roomGridViewModel);
return new RoomGridView(vm.roomGridViewModel, viewClassForTile);
} else if (vm.settingsViewModel) {
return new SettingsView(vm.settingsViewModel);
} else if (vm.createRoomViewModel) {
@ -51,7 +52,7 @@ export class SessionView extends TemplateView {
if (vm.currentRoomViewModel.kind === "invite") {
return new InviteView(vm.currentRoomViewModel);
} else if (vm.currentRoomViewModel.kind === "room") {
return new RoomView(vm.currentRoomViewModel);
return new RoomView(vm.currentRoomViewModel, viewClassForTile);
} else if (vm.currentRoomViewModel.kind === "roomBeingCreated") {
return new RoomBeingCreatedView(vm.currentRoomViewModel);
} else {

View file

@ -17,11 +17,11 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView";
import {Popup} from "../../general/Popup.js";
import {Menu} from "../../general/Menu.js";
import {viewClassForEntry} from "./common"
export class MessageComposer extends TemplateView {
constructor(viewModel) {
constructor(viewModel, viewClassForTile) {
super(viewModel);
this._viewClassForTile = viewClassForTile;
this._input = null;
this._attachmentPopup = null;
this._focusInput = null;
@ -45,8 +45,8 @@ export class MessageComposer extends TemplateView {
this._focusInput = () => this._input.focus();
this.value.on("focus", this._focusInput);
const replyPreview = t.map(vm => vm.replyViewModel, (rvm, t) => {
const View = rvm && viewClassForEntry(rvm);
if (!View) { return null; }
const TileView = rvm && this._viewClassForTile(rvm);
if (!TileView) { return null; }
return t.div({
className: "MessageComposer_replyPreview"
}, [
@ -55,8 +55,8 @@ export class MessageComposer extends TemplateView {
className: "cancel",
onClick: () => this._clearReplyingTo()
}, "Close"),
t.view(new View(rvm, { interactive: false }, "div"))
])
t.view(new TileView(rvm, this._viewClassForTile, { interactive: false }, "div"))
]);
});
const input = t.div({className: "MessageComposer_input"}, [
this._input,

View file

@ -26,15 +26,16 @@ import {AvatarView} from "../../AvatarView.js";
import {CallView} from "./CallView";
export class RoomView extends TemplateView {
constructor(options) {
super(options);
constructor(vm, viewClassForTile) {
super(vm);
this._viewClassForTile = viewClassForTile;
this._optionsPopup = null;
}
render(t, vm) {
let bottomView;
if (vm.composerViewModel.kind === "composer") {
bottomView = new MessageComposer(vm.composerViewModel);
bottomView = new MessageComposer(vm.composerViewModel, this._viewClassForTile);
} else if (vm.composerViewModel.kind === "archived") {
bottomView = new RoomArchivedView(vm.composerViewModel);
}
@ -56,7 +57,7 @@ export class RoomView extends TemplateView {
t.mapView(vm => vm.callViewModel, callViewModel => callViewModel ? new CallView(callViewModel) : null),
t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
return timelineViewModel ?
new TimelineView(timelineViewModel) :
new TimelineView(timelineViewModel, this._viewClassForTile) :
new TimelineLoadingView(vm); // vm is just needed for i18n
}),
t.view(bottomView),

View file

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

View file

@ -25,14 +25,10 @@ import {RedactedView} from "./timeline/RedactedView.js";
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
import {GapView} from "./timeline/GapView.js";
import {CallTileView} from "./timeline/CallTileView";
import type {TileViewConstructor, ViewClassForEntryFn} from "./TimelineView";
export type TileView = GapView | AnnouncementView | TextMessageView |
ImageView | VideoView | FileView | LocationView | MissingAttachmentView | RedactedView;
// TODO: this is what works for a ctor but doesn't actually check we constrain the returned ctors to the types above
type TileViewConstructor = (this: TileView, SimpleTile) => void;
export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | undefined {
switch (entry.shape) {
export function viewClassForTile(vm: SimpleTile): TileViewConstructor {
switch (vm.shape) {
case "gap":
return GapView;
case "announcement":
@ -53,6 +49,8 @@ export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | unde
case "redacted":
return RedactedView;
case "call":
return CallTileView as any as TileViewConstructor;
return CallTileView;
default:
throw new Error(`Tiles of shape "${vm.shape}" are not supported, check the tileClassForEntry function in the view model`);
}
}

View file

@ -17,6 +17,11 @@ limitations under the License.
import {TemplateView} from "../../../general/TemplateView";
export class AnnouncementView extends TemplateView {
// ignore other arguments
constructor(vm) {
super(vm);
}
render(t) {
return t.li({className: "AnnouncementView"}, t.div(vm => vm.announcement));
}

View file

@ -24,10 +24,11 @@ import {Menu} from "../../../general/Menu.js";
import {ReactionsView} from "./ReactionsView.js";
export class BaseMessageView extends TemplateView {
constructor(value, renderFlags, tagName = "li") {
constructor(value, viewClassForTile, renderFlags, tagName = "li") {
super(value);
this._menuPopup = null;
this._tagName = tagName;
this._viewClassForTile = viewClassForTile;
// TODO An enum could be nice to make code easier to read at call sites.
this._renderFlags = renderFlags;
}

View file

@ -18,6 +18,11 @@ import {TemplateView} from "../../../general/TemplateView";
import {spinner} from "../../../common.js";
export class GapView extends TemplateView {
// ignore other argument
constructor(vm) {
super(vm);
}
render(t) {
const className = {
GapView: true,

View file

@ -16,15 +16,18 @@ limitations under the License.
import {renderStaticAvatar} from "../../../avatar";
import {TemplateView} from "../../../general/TemplateView";
import {viewClassForEntry} from "../common";
export class ReplyPreviewView extends TemplateView {
constructor(vm, viewClassForTile) {
super(vm);
this._viewClassForTile = viewClassForTile;
}
render(t, vm) {
const viewClass = viewClassForEntry(vm);
if (!viewClass) {
const TileView = this._viewClassForTile(vm);
if (!TileView) {
throw new Error(`Shape ${vm.shape} is unrecognized.`)
}
const view = new viewClass(vm, { reply: true, interactive: false });
const view = new TileView(vm, this._viewClassForTile, { reply: true, interactive: false });
return t.div(
{ className: "ReplyPreviewView" },
t.blockquote([

View file

@ -35,7 +35,7 @@ export class TextMessageView extends BaseMessageView {
return new ReplyPreviewError();
}
else if (replyTile) {
return new ReplyPreviewView(replyTile);
return new ReplyPreviewView(replyTile, this._viewClassForTile);
}
else {
return null;