diff --git a/.gitignore b/.gitignore index 7f6220cf..089600eb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ bundle.js target lib *.tar.gz -.eslintcache \ No newline at end of file +.eslintcache +.tmp diff --git a/package.json b/package.json index e356347b..875766f8 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js new file mode 100644 index 00000000..74fe4daf --- /dev/null +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -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), + }); + } + } + } +} diff --git a/scripts/postcss/css-compile-variables.js b/scripts/postcss/css-compile-variables.js index 3ed34513..fa584caa 100644 --- a/scripts/postcss/css-compile-variables.js +++ b/scripts/postcss/css-compile-variables.js @@ -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; } - const variable = node.nodes[0]; - variables.push(variable.value); + 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, + }); }, }; }; diff --git a/scripts/postcss/css-url-processor.js b/scripts/postcss/css-url-processor.js new file mode 100644 index 00000000..1a0e4fdf --- /dev/null +++ b/scripts/postcss/css-url-processor.js @@ -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; diff --git a/scripts/postcss/css-url-to-variables.js b/scripts/postcss/css-url-to-variables.js new file mode 100644 index 00000000..1d4666f4 --- /dev/null +++ b/scripts/postcss/css-url-to-variables.js @@ -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; + diff --git a/scripts/postcss/svg-colorizer.js b/scripts/postcss/svg-colorizer.js new file mode 100644 index 00000000..7d527ddb --- /dev/null +++ b/scripts/postcss/svg-colorizer.js @@ -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; +} diff --git a/scripts/postcss/tests/common.js b/scripts/postcss/tests/common.js new file mode 100644 index 00000000..78ae847e --- /dev/null +++ b/scripts/postcss/tests/common.js @@ -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); + }; +} + + diff --git a/scripts/postcss/test.js b/scripts/postcss/tests/css-compile-variables.test.js similarity index 64% rename from scripts/postcss/test.js rename to scripts/postcss/tests/css-compile-variables.test.js index 36ff9282..e40751db 100644 --- a/scripts/postcss/test.js +++ b/scripts/postcss/tests/css-compile-variables.test.js @@ -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); } }; }; diff --git a/scripts/postcss/tests/css-url-to-variables.test.js b/scripts/postcss/tests/css-url-to-variables.test.js new file mode 100644 index 00000000..f406a38a --- /dev/null +++ b/scripts/postcss/tests/css-url-to-variables.test.js @@ -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"]); + } + }; +}; + diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index ba0e1f4f..7730bbac 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -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" } diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index cfe22326..8b8581ae 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -58,6 +58,14 @@ export class ViewModel 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(disposable: D): D { if (!this.disposables) { this.disposables = new Disposables(); diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index c12ed3b8..e7d7dc9a 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -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) { diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 214ec17f..c6055d20 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -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 () {})); diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 33ae4472..173b0cf6 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -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) { diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 2408146d..cf36fce4 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -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; diff --git a/src/domain/session/room/timeline/tiles/BaseMediaTile.js b/src/domain/session/room/timeline/tiles/BaseMediaTile.js index a927d766..0ba5b9a9 100644 --- a/src/domain/session/room/timeline/tiles/BaseMediaTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMediaTile.js @@ -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; diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index c2041a2c..5a28e1b3 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -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(); diff --git a/src/domain/session/room/timeline/tiles/BaseTextTile.js b/src/domain/session/room/timeline/tiles/BaseTextTile.js index 164443e3..8e78c95f 100644 --- a/src/domain/session/room/timeline/tiles/BaseTextTile.js +++ b/src/domain/session/room/timeline/tiles/BaseTextTile.js @@ -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 } diff --git a/src/domain/session/room/timeline/tiles/CallTile.js b/src/domain/session/room/timeline/tiles/CallTile.js index 3e3918b6..32807958 100644 --- a/src/domain/session/room/timeline/tiles/CallTile.js +++ b/src/domain/session/room/timeline/tiles/CallTile.js @@ -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; diff --git a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js index 50f507eb..b96e2d85 100644 --- a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js +++ b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js @@ -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 diff --git a/src/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js index 1007d28c..3f7b539b 100644 --- a/src/domain/session/room/timeline/tiles/FileTile.js +++ b/src/domain/session/room/timeline/tiles/FileTile.js @@ -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; } diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index df0cedd9..6caa4b9b 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -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(); diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index eae2b926..dd959b28 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -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), diff --git a/src/domain/session/room/timeline/tiles/RoomMemberTile.js b/src/domain/session/room/timeline/tiles/RoomMemberTile.js index ce41f031..ca9cd9b7 100644 --- a/src/domain/session/room/timeline/tiles/RoomMemberTile.js +++ b/src/domain/session/room/timeline/tiles/RoomMemberTile.js @@ -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"); }, }; diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index d70a0a37..cf954ac8 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -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 ... ? diff --git a/src/domain/session/room/timeline/tiles/index.ts b/src/domain/session/room/timeline/tiles/index.ts new file mode 100644 index 00000000..c41f90a6 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/index.ts @@ -0,0 +1,105 @@ +/* +Copyright 2020 Bruno Windels + +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; + } + } +} diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js deleted file mode 100644 index ad562df0..00000000 --- a/src/domain/session/room/timeline/tilesCreator.js +++ /dev/null @@ -1,86 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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; -} diff --git a/src/lib.ts b/src/lib.ts index cb939949..e2da5e16 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -26,7 +26,41 @@ export {SessionView} from "./platform/web/ui/session/SessionView.js"; export {RoomViewModel} from "./domain/session/room/RoomViewModel.js"; export {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"; diff --git a/src/platform/web/ui/session/RoomGridView.js b/src/platform/web/ui/session/RoomGridView.js index 79fc3d21..65289bea 100644 --- a/src/platform/web/ui/session/RoomGridView.js +++ b/src/platform/web/ui/session/RoomGridView.js @@ -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"}, [ diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index e7cc406a..ef63b29b 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -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 { diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index 9c67fa9f..6ce8148f 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -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, diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 2936adf3..4f9e40d9 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -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), diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 936b8c7c..5a04991f 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -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 { private tilesView?: TilesListView; private resizeObserver?: ResizeObserver; + constructor(vm: TimelineViewModel, private readonly viewClassForTile: ViewClassForEntryFn) { + super(vm); + } + render(t: Builder, 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 { private onChanged: () => void; - constructor(tiles: ObservableList, onChanged: () => void) { - const options = { + constructor(tiles: ObservableList, 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 { 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, diff --git a/src/platform/web/ui/session/room/common.ts b/src/platform/web/ui/session/room/common.ts index 28543bcf..74007cee 100644 --- a/src/platform/web/ui/session/room/common.ts +++ b/src/platform/web/ui/session/room/common.ts @@ -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`); } } diff --git a/src/platform/web/ui/session/room/timeline/AnnouncementView.js b/src/platform/web/ui/session/room/timeline/AnnouncementView.js index 268bf0fa..5ae92daa 100644 --- a/src/platform/web/ui/session/room/timeline/AnnouncementView.js +++ b/src/platform/web/ui/session/room/timeline/AnnouncementView.js @@ -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)); } diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index 7356cd2b..74b96ecf 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -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; } diff --git a/src/platform/web/ui/session/room/timeline/GapView.js b/src/platform/web/ui/session/room/timeline/GapView.js index 2d3bd6e8..db6cda59 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -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, diff --git a/src/platform/web/ui/session/room/timeline/ReplyPreviewView.js b/src/platform/web/ui/session/room/timeline/ReplyPreviewView.js index 3c52fc71..219e4357 100644 --- a/src/platform/web/ui/session/room/timeline/ReplyPreviewView.js +++ b/src/platform/web/ui/session/room/timeline/ReplyPreviewView.js @@ -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([ diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index c0c0cfb0..8d6cb4dc 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -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;