Merge branch 'master' into bwindels/calls
This commit is contained in:
commit
d734a61447
40 changed files with 890 additions and 204 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -8,3 +8,4 @@ target
|
|||
lib
|
||||
*.tar.gz
|
||||
.eslintcache
|
||||
.tmp
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
174
scripts/build-plugins/rollup-plugin-build-themes.js
Normal file
174
scripts/build-plugins/rollup-plugin-build-themes.js
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
87
scripts/postcss/css-url-processor.js
Normal file
87
scripts/postcss/css-url-processor.js
Normal 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;
|
85
scripts/postcss/css-url-to-variables.js
Normal file
85
scripts/postcss/css-url-to-variables.js
Normal 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;
|
||||
|
45
scripts/postcss/svg-colorizer.js
Normal file
45
scripts/postcss/svg-colorizer.js
Normal 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;
|
||||
}
|
30
scripts/postcss/tests/common.js
Normal file
30
scripts/postcss/tests/common.js
Normal 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);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
};
|
71
scripts/postcss/tests/css-url-to-variables.test.js
Normal file
71
scripts/postcss/tests/css-url-to-variables.test.js
Normal 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"]);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -58,6 +58,14 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
|
|||
return this._options[name];
|
||||
}
|
||||
|
||||
observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) {
|
||||
const segmentObservable = this.navigation.observe(type);
|
||||
const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => {
|
||||
onChange(value, type);
|
||||
})
|
||||
this.track(unsubscribe);
|
||||
}
|
||||
|
||||
track<D extends Disposable>(disposable: D): D {
|
||||
if (!this.disposables) {
|
||||
this.disposables = new Disposables();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 () {}));
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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");
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 ... ?
|
||||
|
|
105
src/domain/session/room/timeline/tiles/index.ts
Normal file
105
src/domain/session/room/timeline/tiles/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
34
src/lib.ts
34
src/lib.ts
|
@ -26,7 +26,41 @@ export {SessionView} from "./platform/web/ui/session/SessionView.js";
|
|||
export {RoomViewModel} from "./domain/session/room/RoomViewModel.js";
|
||||
export {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";
|
||||
|
|
|
@ -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"}, [
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -14,9 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type {TileView} from "./common";
|
||||
import {viewClassForEntry} from "./common";
|
||||
import {ListView} from "../../general/ListView";
|
||||
import type {IView} from "../../general/types";
|
||||
import {TemplateView, Builder} from "../../general/TemplateView";
|
||||
import {IObservableValue} from "../../general/BaseUpdateView";
|
||||
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
|
||||
|
@ -25,6 +24,17 @@ import {RedactedView} from "./timeline/RedactedView.js";
|
|||
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
|
||||
import {BaseObservableList as ObservableList} from "../../../../../observable/list/BaseObservableList";
|
||||
|
||||
export interface TileView extends IView {
|
||||
readonly value: SimpleTile;
|
||||
onClick(event: UIEvent);
|
||||
}
|
||||
export type TileViewConstructor = new (
|
||||
tile: SimpleTile,
|
||||
viewClassForTile: ViewClassForEntryFn,
|
||||
renderFlags?: { reply?: boolean, interactive?: boolean }
|
||||
) => TileView;
|
||||
export type ViewClassForEntryFn = (tile: SimpleTile) => TileViewConstructor;
|
||||
|
||||
//import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js";
|
||||
export interface TimelineViewModel extends IObservableValue {
|
||||
showJumpDown: boolean;
|
||||
|
@ -55,13 +65,17 @@ export class TimelineView extends TemplateView<TimelineViewModel> {
|
|||
private tilesView?: TilesListView;
|
||||
private resizeObserver?: ResizeObserver;
|
||||
|
||||
constructor(vm: TimelineViewModel, private readonly viewClassForTile: ViewClassForEntryFn) {
|
||||
super(vm);
|
||||
}
|
||||
|
||||
render(t: Builder<TimelineViewModel>, vm: TimelineViewModel) {
|
||||
// assume this view will be mounted in the parent DOM straight away
|
||||
requestAnimationFrame(() => {
|
||||
// do initial scroll positioning
|
||||
this.restoreScrollPosition();
|
||||
});
|
||||
this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition());
|
||||
this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition(), this.viewClassForTile);
|
||||
const root = t.div({className: "Timeline"}, [
|
||||
t.div({
|
||||
className: "Timeline_scroller bottom-aligned-scroll",
|
||||
|
@ -174,16 +188,13 @@ class TilesListView extends ListView<SimpleTile, TileView> {
|
|||
|
||||
private onChanged: () => void;
|
||||
|
||||
constructor(tiles: ObservableList<SimpleTile>, onChanged: () => void) {
|
||||
const options = {
|
||||
constructor(tiles: ObservableList<SimpleTile>, onChanged: () => void, private readonly viewClassForTile: ViewClassForEntryFn) {
|
||||
super({
|
||||
list: tiles,
|
||||
onItemClick: (tileView, evt) => tileView.onClick(evt),
|
||||
};
|
||||
super(options, entry => {
|
||||
const View = viewClassForEntry(entry);
|
||||
if (View) {
|
||||
return new View(entry);
|
||||
}
|
||||
}, tile => {
|
||||
const TileView = viewClassForTile(tile);
|
||||
return new TileView(tile, viewClassForTile);
|
||||
});
|
||||
this.onChanged = onChanged;
|
||||
}
|
||||
|
@ -195,7 +206,7 @@ class TilesListView extends ListView<SimpleTile, TileView> {
|
|||
|
||||
onUpdate(index: number, value: SimpleTile, param: any) {
|
||||
if (param === "shape") {
|
||||
const ExpectedClass = viewClassForEntry(value);
|
||||
const ExpectedClass = this.viewClassForTile(value);
|
||||
const child = this.getChildInstanceByIndex(index);
|
||||
if (!ExpectedClass || !(child instanceof ExpectedClass)) {
|
||||
// shape was updated, so we need to recreate the tile view,
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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;
|
||||
|
|
Reference in a new issue