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 lib
*.tar.gz *.tar.gz
.eslintcache .eslintcache
.tmp

View file

@ -10,7 +10,7 @@
"lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts", "lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts",
"lint-ci": "eslint src/", "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": "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", "start": "vite --port 3000",
"build": "vite build", "build": "vite build",
"build:sdk": "./scripts/sdk/build.sh" "build:sdk": "./scripts/sdk/build.sh"
@ -43,6 +43,7 @@
"node-html-parser": "^4.0.0", "node-html-parser": "^4.0.0",
"postcss-css-variables": "^0.18.0", "postcss-css-variables": "^0.18.0",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-value-parser": "^4.2.0",
"regenerator-runtime": "^0.13.7", "regenerator-runtime": "^0.13.7",
"text-encoding": "^0.7.0", "text-encoding": "^0.7.0",
"typescript": "^4.4", "typescript": "^4.4",
@ -54,7 +55,6 @@
"another-json": "^0.2.0", "another-json": "^0.2.0",
"base64-arraybuffer": "^0.2.0", "base64-arraybuffer": "^0.2.0",
"dompurify": "^2.3.0", "dompurify": "^2.3.0",
"off-color": "^2.0.0", "off-color": "^2.0.0"
"postcss-value-parser": "^4.2.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 parsed = valueParser(value);
const variables = []; const variables = [];
parsed.walk(node => { parsed.walk(node => {
if (node.type !== "function" && node.value !== "var") { if (node.type !== "function") {
return; return;
} }
switch (node.value) {
case "var": {
const variable = node.nodes[0]; const variable = node.nodes[0];
variables.push(variable.value); 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; return variables;
} }
function resolveDerivedVariable(decl, derive) { function resolveDerivedVariable(decl, derive) {
const RE_VARIABLE_VALUE = /--((.+)--(.+)-(.+))/; const RE_VARIABLE_VALUE = /(?:--)?((.+)--(.+)-(.+))/;
const variableCollection = parseDeclarationValue(decl.value); const variableCollection = parseDeclarationValue(decl.value);
for (const variable of variableCollection) { for (const variable of variableCollection) {
const matches = variable.match(RE_VARIABLE_VALUE); const matches = variable.match(RE_VARIABLE_VALUE);
@ -94,6 +109,15 @@ function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) {
root.append(newRule); 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 * @callback derive
* @param {string} value - The base value on which an operation is applied * @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 {Object} opts - Options for the plugin
* @param {derive} opts.derive - The callback which contains the logic for resolving derived variables * @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 = {}) => { module.exports = (opts = {}) => {
aliasMap = new Map(); aliasMap = new Map();
@ -112,7 +137,12 @@ module.exports = (opts = {}) => {
return { return {
postcssPlugin: "postcss-compile-variables", 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. Go through the CSS file once to extract all aliases and base variables.
We use these when resolving derived variables later. We use these when resolving derived variables later.
@ -120,6 +150,16 @@ module.exports = (opts = {}) => {
root.walkDecls(decl => extract(decl)); root.walkDecls(decl => extract(decl));
root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive)); root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive));
addResolvedVariablesToRootSelector(root, {Rule, Declaration}); 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 offColor = require("off-color").offColor;
const postcss = require("postcss"); const postcss = require("postcss");
const plugin = require("./css-compile-variables"); const plugin = require("../css-compile-variables");
const derive = require("./color").derive; const derive = require("../color").derive;
const run = require("./common").createTestRunner(plugin);
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);
}
module.exports.tests = function tests() { module.exports.tests = function tests() {
return { return {
@ -46,7 +38,7 @@ module.exports.tests = function tests() {
--foo-color--lighter-50: ${transformedColor.hex()}; --foo-color--lighter-50: ${transformedColor.hex()};
} }
`; `;
await run( inputCSS, outputCSS, {}, assert); await run( inputCSS, outputCSS, {derive}, assert);
}, },
"derived variables work with alias": async (assert) => { "derived variables work with alias": async (assert) => {
@ -66,7 +58,7 @@ module.exports.tests = function tests() {
--my-alias--lighter-15: ${aliasLighter}; --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) => { "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()}; --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) => { "multiple aliased-derived variable in single declaration is parsed correctly": async (assert) => {
const inputCSS = ` const inputCSS = `
:root { :root {
@ -115,7 +108,49 @@ module.exports.tests = function tests() {
--my-alias--darker-20: ${transformedColor2.hex()}; --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", "name": "hydrogen-view-sdk",
"description": "Embeddable matrix client library, including view components", "description": "Embeddable matrix client library, including view components",
"version": "0.0.9", "version": "0.0.10",
"main": "./hydrogen.es.js", "main": "./hydrogen.es.js",
"type": "module" "type": "module"
} }

View file

@ -58,6 +58,14 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this._options[name]; 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 { track<D extends Disposable>(disposable: D): D {
if (!this.disposables) { if (!this.disposables) {
this.disposables = new Disposables(); this.disposables = new Disposables();

View file

@ -20,18 +20,21 @@ import {ComposerViewModel} from "./ComposerViewModel.js"
import {CallViewModel} from "./CallViewModel" import {CallViewModel} from "./CallViewModel"
import {PickMapObservableValue} from "../../../observable/value/PickMapObservableValue"; import {PickMapObservableValue} from "../../../observable/value/PickMapObservableValue";
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";
import {LocalMedia} from "../../../matrix/calls/LocalMedia"; 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 { 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;
@ -73,13 +76,14 @@ 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({
session: this.getOption("session"), session: this.getOption("session"),
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");
@ -189,7 +193,12 @@ export class RoomViewModel extends ViewModel {
} }
_createTile(entry) { _createTile(entry) {
return this._tilesCreator(entry); if (this._tileOptions) {
const Tile = this._tileOptions.tileClassForEntry(entry);
if (Tile) {
return new Tile(entry, this._tileOptions);
}
}
} }
async _sendMessage(message, replyingTo) { async _sendMessage(message, replyingTo) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 // alternatively, we could just subscribe to the GroupCall and spontanously emit an update when it updates
export class CallTile extends SimpleTile { export class CallTile extends SimpleTile {
constructor(entry, options) {
constructor(options) { super(entry, options);
super(options);
const calls = this.getOption("session").callHandler.calls; const calls = this.getOption("session").callHandler.calls;
this._call = calls.get(this._entry.stateKey); this._call = calls.get(this._entry.stateKey);
this._callSubscription = undefined; this._callSubscription = undefined;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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 {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 {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 {Navigation} from "./domain/navigation/Navigation.js";
export {ComposerViewModel} from "./domain/session/room/ComposerViewModel.js"; export {ComposerViewModel} from "./domain/session/room/ComposerViewModel.js";
export {MessageComposer} from "./platform/web/ui/session/room/MessageComposer.js"; export {MessageComposer} from "./platform/web/ui/session/room/MessageComposer.js";

View file

@ -21,6 +21,11 @@ import {TemplateView} from "../general/TemplateView";
import {StaticView} from "../general/StaticView.js"; import {StaticView} from "../general/StaticView.js";
export class RoomGridView extends TemplateView { export class RoomGridView extends TemplateView {
constructor(vm, viewClassForTile) {
super(vm);
this._viewClassForTile = viewClassForTile;
}
render(t, vm) { render(t, vm) {
const children = []; const children = [];
for (let i = 0; i < (vm.height * vm.width); i+=1) { 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") { } else if (roomVM.kind === "invite") {
return new InviteView(roomVM); return new InviteView(roomVM);
} else { } else {
return new RoomView(roomVM); return new RoomView(roomVM, this._viewClassForTile);
} }
} else { } else {
return new StaticView(t => t.div({className: "room-placeholder"}, [ 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 {SettingsView} from "./settings/SettingsView.js";
import {CreateRoomView} from "./CreateRoomView.js"; import {CreateRoomView} from "./CreateRoomView.js";
import {RightPanelView} from "./rightpanel/RightPanelView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js";
import {viewClassForTile} from "./room/common";
export class SessionView extends TemplateView { export class SessionView extends TemplateView {
render(t, vm) { render(t, vm) {
@ -42,7 +43,7 @@ export class SessionView extends TemplateView {
t.view(new LeftPanelView(vm.leftPanelViewModel)), t.view(new LeftPanelView(vm.leftPanelViewModel)),
t.mapView(vm => vm.activeMiddleViewModel, () => { t.mapView(vm => vm.activeMiddleViewModel, () => {
if (vm.roomGridViewModel) { if (vm.roomGridViewModel) {
return new RoomGridView(vm.roomGridViewModel); return new RoomGridView(vm.roomGridViewModel, viewClassForTile);
} else if (vm.settingsViewModel) { } else if (vm.settingsViewModel) {
return new SettingsView(vm.settingsViewModel); return new SettingsView(vm.settingsViewModel);
} else if (vm.createRoomViewModel) { } else if (vm.createRoomViewModel) {
@ -51,7 +52,7 @@ export class SessionView extends TemplateView {
if (vm.currentRoomViewModel.kind === "invite") { if (vm.currentRoomViewModel.kind === "invite") {
return new InviteView(vm.currentRoomViewModel); return new InviteView(vm.currentRoomViewModel);
} else if (vm.currentRoomViewModel.kind === "room") { } else if (vm.currentRoomViewModel.kind === "room") {
return new RoomView(vm.currentRoomViewModel); return new RoomView(vm.currentRoomViewModel, viewClassForTile);
} else if (vm.currentRoomViewModel.kind === "roomBeingCreated") { } else if (vm.currentRoomViewModel.kind === "roomBeingCreated") {
return new RoomBeingCreatedView(vm.currentRoomViewModel); return new RoomBeingCreatedView(vm.currentRoomViewModel);
} else { } else {

View file

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

View file

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

View file

@ -14,9 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import type {TileView} from "./common";
import {viewClassForEntry} from "./common";
import {ListView} from "../../general/ListView"; import {ListView} from "../../general/ListView";
import type {IView} from "../../general/types";
import {TemplateView, Builder} from "../../general/TemplateView"; import {TemplateView, Builder} from "../../general/TemplateView";
import {IObservableValue} from "../../general/BaseUpdateView"; import {IObservableValue} from "../../general/BaseUpdateView";
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
@ -25,6 +24,17 @@ 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,
viewClassForTile: ViewClassForEntryFn,
renderFlags?: { reply?: boolean, interactive?: boolean }
) => 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 +65,17 @@ export class TimelineView extends TemplateView<TimelineViewModel> {
private tilesView?: TilesListView; private tilesView?: TilesListView;
private resizeObserver?: ResizeObserver; private resizeObserver?: ResizeObserver;
constructor(vm: TimelineViewModel, private readonly viewClassForTile: 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.viewClassForTile);
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 +188,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 viewClassForTile: ViewClassForEntryFn) {
const options = { super({
list: tiles, list: tiles,
onItemClick: (tileView, evt) => tileView.onClick(evt), onItemClick: (tileView, evt) => tileView.onClick(evt),
}; }, tile => {
super(options, entry => { const TileView = viewClassForTile(tile);
const View = viewClassForEntry(entry); return new TileView(tile, viewClassForTile);
if (View) {
return new View(entry);
}
}); });
this.onChanged = onChanged; this.onChanged = onChanged;
} }
@ -195,7 +206,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.viewClassForTile(value);
const child = this.getChildInstanceByIndex(index); const child = this.getChildInstanceByIndex(index);
if (!ExpectedClass || !(child instanceof ExpectedClass)) { if (!ExpectedClass || !(child instanceof ExpectedClass)) {
// shape was updated, so we need to recreate the tile view, // shape was updated, so we need to recreate the tile view,

View file

@ -25,14 +25,10 @@ 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 {CallTileView} from "./timeline/CallTileView"; import {CallTileView} from "./timeline/CallTileView";
import type {TileViewConstructor, ViewClassForEntryFn} from "./TimelineView";
export type TileView = GapView | AnnouncementView | TextMessageView | export function viewClassForTile(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":
@ -53,6 +49,8 @@ export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | unde
case "redacted": case "redacted":
return RedactedView; return RedactedView;
case "call": 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"; import {TemplateView} from "../../../general/TemplateView";
export class AnnouncementView extends TemplateView { export class AnnouncementView extends TemplateView {
// ignore other arguments
constructor(vm) {
super(vm);
}
render(t) { render(t) {
return t.li({className: "AnnouncementView"}, t.div(vm => vm.announcement)); 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"; import {ReactionsView} from "./ReactionsView.js";
export class BaseMessageView extends TemplateView { export class BaseMessageView extends TemplateView {
constructor(value, renderFlags, tagName = "li") { constructor(value, viewClassForTile, renderFlags, tagName = "li") {
super(value); super(value);
this._menuPopup = null; this._menuPopup = null;
this._tagName = tagName; this._tagName = tagName;
this._viewClassForTile = viewClassForTile;
// TODO An enum could be nice to make code easier to read at call sites. // TODO An enum could be nice to make code easier to read at call sites.
this._renderFlags = renderFlags; this._renderFlags = renderFlags;
} }

View file

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

View file

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

View file

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