Merge branch 'master' into madlittlemods/matrix-public-archive-scratch-changes

This commit is contained in:
Eric Eastwood 2022-07-05 06:00:00 -05:00
commit 6a6f22047e
37 changed files with 770 additions and 149 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
*.sublime-project *.sublime-project
*.sublime-workspace *.sublime-workspace
.DS_Store
node_modules node_modules
fetchlogs fetchlogs
sessionexports sessionexports

View file

@ -48,8 +48,8 @@ const assetPaths = {
wasmBundle: olmJsPath wasmBundle: olmJsPath
} }
}; };
import "hydrogen-view-sdk/theme-element-light.css"; import "hydrogen-view-sdk/assets/theme-element-light.css";
// OR import "hydrogen-view-sdk/theme-element-dark.css"; // OR import "hydrogen-view-sdk/assets/theme-element-dark.css";
async function main() { async function main() {
const app = document.querySelector<HTMLDivElement>('#app')! const app = document.querySelector<HTMLDivElement>('#app')!

169
doc/THEMING.md Normal file
View file

@ -0,0 +1,169 @@
# Theming Documentation
## Basic Architecture
A **theme collection** in Hydrogen is represented by a `manifest.json` file and a `theme.css` file.
The manifest specifies variants (eg: dark,light ...) each of which is a **theme** and maps to a single css file in the build output.
Each such theme is produced by changing the values of variables in the base `theme.css` file with those specified in the variant section of the manifest:
![](images/theming-architecture.png)
More in depth explanations can be found in later sections.
## Structure of `manifest.json`
[See theme.ts](../src/platform/types/theme.ts)
## Variables
CSS variables specific to a particular variant are specified in the `variants` section of the manifest:
```json=
"variants": {
"light": {
...
"variables": {
"background-color-primary": "#fff",
"text-color": "#2E2F32",
}
},
"dark": {
...
"variables": {
"background-color-primary": "#21262b",
"text-color": "#fff",
}
}
}
```
These variables will appear in the css file (theme.css):
```css=
body {
background-color: var(--background-color-primary);
color: var(--text-color);
}
```
During the build process, this would result in the creation of two css files (one for each variant) where the variables are substitued with the corresponding values specified in the manifest:
*element-light.css*:
```css=
body {
background-color: #fff;
color: #2E2F32;
}
```
*element-dark.css*:
```css=
body {
background-color: #21262b;
color: #fff;
}
```
## Derived Variables
In addition to simple substitution of variables in the stylesheet, it is also possible to instruct the build system to first produce a new value from the base variable value before the substitution.
Such derived variables have the form `base_css_variable--operation-arg` and can be read as:
apply `operation` to `base_css_variable` with argument `arg`.
Continuing with the previous example, it possible to specify:
```css=
.left-panel {
/* background color should be 20% more darker
than background-color-primary */
background-color: var(--background-color-primary--darker-20);
}
```
Currently supported operations are:
| Operation | Argument | Operates On |
| -------- | -------- | -------- |
| darker | percentage | color |
| lighter | percentage | color |
## Aliases
It is possible give aliases to variables in the `theme.css` file:
```css=
:root {
font-size: 10px;
/* Theme aliases */
--icon-color: var(--background-color-secondary--darker-40);
}
```
It is possible to further derive from these aliased variables:
```css=
div {
background: var(--icon-color--darker-20);
--my-alias: var(--icon-color--darker-20);
/* Derive from aliased variable */
color: var(--my-alias--lighter-15);
}
```
## Colorizing svgs
Along with a change in color-scheme, it may be necessary to change the colors in the svg icons and images.
This can be done by supplying the preferred colors with query parameters:
`my-awesome-logo.svg?primary=base-variable-1&secondary=base-variable-2`
This instructs the build system to colorize the svg with the given primary and secondary colors.
`base-variable-1` and `base-variable-2` are the css-variables specified in the `variables` section of the manifest.
For colorizing svgs, the source svg must use `#ff00ff` as the primary color and `#00ffff` as the secondary color:
| ![](images/svg-icon-example.png) | ![](images/coloring-process.png) |
| :--: |:--: |
| **original source image** | **transformation process** |
## Creating your own theme variant in Hydrogen
If you're looking to change the color-scheme of the existing Element theme, you only need to add your own variant to the existing `manifest.json`.
The steps are fairly simple:
1. Copy over an existing variant to the variants section of the manifest.
2. Change `dark`, `default` and `name` fields.
3. Give new values to each variable in the `variables` section.
4. Build hydrogen.
## Creating your own theme collection in Hydrogen
If a theme variant does not solve your needs, you can create a new theme collection with a different base `theme.css` file.
1. Create a directory for your new theme-collection under `src/platform/web/ui/css/themes/`.
2. Create `manifest.json` and `theme.css` files within the newly created directory.
3. Populate `manifest.json` with the base css variables you wish to use.
4. Write styles in your `theme.css` file using the base variables, derived variables and colorized svg icons.
5. Tell the build system where to find this theme-collection by providing the location of this directory to the `themeBuilder` plugin in `vite.config.js`:
```json=
...
themeBuilder({
themeConfig: {
themes: {
element: "./src/platform/web/ui/css/themes/element",
awesome: "path/to/theme-directory"
},
default: "element",
},
compiledVariables,
}),
...
```
6. Build Hydrogen.
## Changing the default theme
To change the default theme used in Hydrogen, modify the `defaultTheme` field in `config.json` file (which can be found in the build output):
```json=
"defaultTheme": {
"light": theme-id,
"dark": theme-id
}
```
Here *theme-id* is of the form `theme-variant` where `theme` is the key used when specifying the manifest location of the theme collection in `vite.config.js` and `variant` is the key used in variants section of the manifest.
Some examples of theme-ids are `element-dark` and `element-light`.
To find the theme-id of some theme, you can look at the built-asset section of the manifest in the build output.
This default theme will render as "Default" option in the theme-chooser dropdown. If the device preference is for dark theme, the dark default is selected and vice versa.
**You'll need to reload twice so that Hydrogen picks up the config changes!**

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,10 +1,13 @@
{ {
"name": "hydrogen-web", "name": "hydrogen-web",
"version": "0.2.29", "version": "0.2.33",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"directories": { "directories": {
"doc": "doc" "doc": "doc"
}, },
"enginesStrict": {
"node": ">=15"
},
"scripts": { "scripts": {
"lint": "eslint --cache src/", "lint": "eslint --cache src/",
"lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts", "lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts",
@ -53,7 +56,7 @@
"xxhashjs": "^0.2.2" "xxhashjs": "^0.2.2"
}, },
"dependencies": { "dependencies": {
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"another-json": "^0.2.0", "another-json": "^0.2.0",
"base64-arraybuffer": "^0.2.0", "base64-arraybuffer": "^0.2.0",
"dompurify": "^2.3.0", "dompurify": "^2.3.0",

View file

@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
const path = require('path'); const path = require('path').posix;
async function readCSSSource(location) { async function readCSSSource(location) {
const fs = require("fs").promises; const fs = require("fs").promises;
@ -246,6 +246,9 @@ module.exports = function buildThemes(options) {
}, },
generateBundle(_, bundle) { generateBundle(_, bundle) {
// assetMap: Mapping from asset-name (eg: element-dark.css) to AssetInfo
// chunkMap: Mapping from theme-location (eg: hydrogen-web/src/.../css/themes/element) to a list of ChunkInfo
// types of AssetInfo and ChunkInfo can be found at https://rollupjs.org/guide/en/#generatebundle
const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle); const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle);
const manifestLocations = []; const manifestLocations = [];
for (const [location, chunkArray] of chunkMap) { for (const [location, chunkArray] of chunkMap) {
@ -254,10 +257,6 @@ module.exports = function buildThemes(options) {
const derivedVariables = compiledVariables["derived-variables"]; const derivedVariables = compiledVariables["derived-variables"];
const icon = compiledVariables["icon"]; const icon = compiledVariables["icon"];
const builtAssets = {}; const builtAssets = {};
/**
* Generate a mapping from theme name to asset hashed location of said theme in build output.
* This can be used to enumerate themes during runtime.
*/
for (const chunk of chunkArray) { for (const chunk of chunkArray) {
const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/); const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/);
builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName; builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName;

View file

@ -30,12 +30,7 @@ const valueParser = require("postcss-value-parser");
* The actual derivation is done outside the plugin in a callback. * The actual derivation is done outside the plugin in a callback.
*/ */
let aliasMap; function getValueFromAlias(alias, {aliasMap, baseVariables, resolvedMap}) {
let resolvedMap;
let baseVariables;
let isDark;
function getValueFromAlias(alias) {
const derivedVariable = aliasMap.get(alias); const derivedVariable = aliasMap.get(alias);
return baseVariables.get(derivedVariable) ?? resolvedMap.get(derivedVariable); return baseVariables.get(derivedVariable) ?? resolvedMap.get(derivedVariable);
} }
@ -68,14 +63,15 @@ function parseDeclarationValue(value) {
return variables; return variables;
} }
function resolveDerivedVariable(decl, derive) { function resolveDerivedVariable(decl, derive, maps, isDark) {
const { baseVariables, resolvedMap } = maps;
const RE_VARIABLE_VALUE = /(?:--)?((.+)--(.+)-(.+))/; const RE_VARIABLE_VALUE = /(?:--)?((.+)--(.+)-(.+))/;
const variableCollection = parseDeclarationValue(decl.value); const variableCollection = parseDeclarationValue(decl.value);
for (const variable of variableCollection) { for (const variable of variableCollection) {
const matches = variable.match(RE_VARIABLE_VALUE); const matches = variable.match(RE_VARIABLE_VALUE);
if (matches) { if (matches) {
const [, wholeVariable, baseVariable, operation, argument] = matches; const [, wholeVariable, baseVariable, operation, argument] = matches;
const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable); const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable, maps);
if (!value) { if (!value) {
throw new Error(`Cannot derive from ${baseVariable} because it is neither defined in config nor is it an alias!`); throw new Error(`Cannot derive from ${baseVariable} because it is neither defined in config nor is it an alias!`);
} }
@ -85,7 +81,7 @@ function resolveDerivedVariable(decl, derive) {
} }
} }
function extract(decl) { function extract(decl, {aliasMap, baseVariables}) {
if (decl.variable) { if (decl.variable) {
// see if right side is of form "var(--foo)" // see if right side is of form "var(--foo)"
const wholeVariable = decl.value.match(/var\(--(.+)\)/)?.[1]; const wholeVariable = decl.value.match(/var\(--(.+)\)/)?.[1];
@ -100,7 +96,7 @@ function extract(decl) {
} }
} }
function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) { function addResolvedVariablesToRootSelector(root, {Rule, Declaration}, {resolvedMap}) {
const newRule = new Rule({ selector: ":root", source: root.source }); const newRule = new Rule({ selector: ":root", source: root.source });
// Add derived css variables to :root // Add derived css variables to :root
resolvedMap.forEach((value, key) => { resolvedMap.forEach((value, key) => {
@ -110,7 +106,7 @@ function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) {
root.append(newRule); root.append(newRule);
} }
function populateMapWithDerivedVariables(map, cssFileLocation) { function populateMapWithDerivedVariables(map, cssFileLocation, {resolvedMap, aliasMap}) {
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1]; const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
const derivedVariables = [ const derivedVariables = [
...([...resolvedMap.keys()].filter(v => !aliasMap.has(v))), ...([...resolvedMap.keys()].filter(v => !aliasMap.has(v))),
@ -133,10 +129,10 @@ function populateMapWithDerivedVariables(map, cssFileLocation) {
* @param {Map} opts.compiledVariables - A map that stores derived variables so that manifest source sections can be produced * @param {Map} opts.compiledVariables - A map that stores derived variables so that manifest source sections can be produced
*/ */
module.exports = (opts = {}) => { module.exports = (opts = {}) => {
aliasMap = new Map(); const aliasMap = new Map();
resolvedMap = new Map(); const resolvedMap = new Map();
baseVariables = new Map(); const baseVariables = new Map();
isDark = false; const maps = { aliasMap, resolvedMap, baseVariables };
return { return {
postcssPlugin: "postcss-compile-variables", postcssPlugin: "postcss-compile-variables",
@ -147,16 +143,16 @@ module.exports = (opts = {}) => {
// If this is a runtime theme, don't derive variables. // If this is a runtime theme, don't derive variables.
return; return;
} }
isDark = cssFileLocation.includes("dark=true"); const isDark = cssFileLocation.includes("dark=true");
/* /*
Go through the CSS file once to extract all aliases and base variables. Go through the CSS file once to extract all aliases and base variables.
We use these when resolving derived variables later. We use these when resolving derived variables later.
*/ */
root.walkDecls(decl => extract(decl)); root.walkDecls(decl => extract(decl, maps));
root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive)); root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive, maps, isDark));
addResolvedVariablesToRootSelector(root, {Rule, Declaration}); addResolvedVariablesToRootSelector(root, {Rule, Declaration}, maps);
if (opts.compiledVariables){ if (opts.compiledVariables){
populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation); populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation, maps);
} }
// Also produce a mapping from alias to completely resolved color // Also produce a mapping from alias to completely resolved color
const resolvedAliasMap = new Map(); const resolvedAliasMap = new Map();

View file

@ -16,7 +16,6 @@ limitations under the License.
const valueParser = require("postcss-value-parser"); const valueParser = require("postcss-value-parser");
const resolve = require("path").resolve; const resolve = require("path").resolve;
let cssPath;
function colorsFromURL(url, colorMap) { function colorsFromURL(url, colorMap) {
const params = new URL(`file://${url}`).searchParams; const params = new URL(`file://${url}`).searchParams;
@ -36,7 +35,7 @@ function colorsFromURL(url, colorMap) {
return [primaryColor, secondaryColor]; return [primaryColor, secondaryColor];
} }
function processURL(decl, replacer, colorMap) { function processURL(decl, replacer, colorMap, cssPath) {
const value = decl.value; const value = decl.value;
const parsed = valueParser(value); const parsed = valueParser(value);
parsed.walk(node => { parsed.walk(node => {
@ -84,8 +83,8 @@ module.exports = (opts = {}) => {
Go through each declaration and if it contains an URL, replace the url with the result Go through each declaration and if it contains an URL, replace the url with the result
of running replacer(url) of running replacer(url)
*/ */
cssPath = root.source?.input.file.replace(/[^/]*$/, ""); const cssPath = root.source?.input.file.replace(/[^/]*$/, "");
root.walkDecls(decl => processURL(decl, opts.replacer, colorMap)); root.walkDecls(decl => processURL(decl, opts.replacer, colorMap, cssPath));
}, },
}; };
}; };

View file

@ -20,11 +20,9 @@ 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 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. * This plugin is used in conjunction with css-url-processor plugin to colorize svg icons.
*/ */
let counter;
let urlVariables;
const idToPrepend = "icon-url"; const idToPrepend = "icon-url";
function findAndReplaceUrl(decl) { function findAndReplaceUrl(decl, urlVariables, counter) {
const value = decl.value; const value = decl.value;
const parsed = valueParser(value); const parsed = valueParser(value);
parsed.walk(node => { parsed.walk(node => {
@ -35,7 +33,8 @@ function findAndReplaceUrl(decl) {
if (!url.match(/\.svg\?primary=.+/)) { if (!url.match(/\.svg\?primary=.+/)) {
return; return;
} }
const variableName = `${idToPrepend}-${counter++}`; const count = counter.next().value;
const variableName = `${idToPrepend}-${count}`;
urlVariables.set(variableName, url); urlVariables.set(variableName, url);
node.value = "var"; node.value = "var";
node.nodes = [{ type: "word", value: `--${variableName}` }]; node.nodes = [{ type: "word", value: `--${variableName}` }];
@ -43,7 +42,7 @@ function findAndReplaceUrl(decl) {
decl.assign({prop: decl.prop, value: parsed.toString()}) decl.assign({prop: decl.prop, value: parsed.toString()})
} }
function addResolvedVariablesToRootSelector(root, { Rule, Declaration }) { function addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables) {
const newRule = new Rule({ selector: ":root", source: root.source }); const newRule = new Rule({ selector: ":root", source: root.source });
// Add derived css variables to :root // Add derived css variables to :root
urlVariables.forEach((value, key) => { urlVariables.forEach((value, key) => {
@ -53,29 +52,35 @@ function addResolvedVariablesToRootSelector(root, { Rule, Declaration }) {
root.append(newRule); root.append(newRule);
} }
function populateMapWithIcons(map, cssFileLocation) { function populateMapWithIcons(map, cssFileLocation, urlVariables) {
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1]; const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
const sharedObject = map.get(location); const sharedObject = map.get(location);
sharedObject["icon"] = Object.fromEntries(urlVariables); sharedObject["icon"] = Object.fromEntries(urlVariables);
} }
function *createCounter() {
for (let i = 0; ; ++i) {
yield i;
}
}
/* * /* *
* @type {import('postcss').PluginCreator} * @type {import('postcss').PluginCreator}
*/ */
module.exports = (opts = {}) => { module.exports = (opts = {}) => {
urlVariables = new Map();
counter = 0;
return { return {
postcssPlugin: "postcss-url-to-variable", postcssPlugin: "postcss-url-to-variable",
Once(root, { Rule, Declaration }) { Once(root, { Rule, Declaration }) {
root.walkDecls(decl => findAndReplaceUrl(decl)); const urlVariables = new Map();
const counter = createCounter();
root.walkDecls(decl => findAndReplaceUrl(decl, urlVariables, counter));
if (urlVariables.size) { if (urlVariables.size) {
addResolvedVariablesToRootSelector(root, { Rule, Declaration }); addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables);
} }
if (opts.compiledVariables){ if (opts.compiledVariables){
const cssFileLocation = root.source.input.from; const cssFileLocation = root.source.input.from;
populateMapWithIcons(opts.compiledVariables, cssFileLocation); populateMapWithIcons(opts.compiledVariables, cssFileLocation, urlVariables);
} }
}, },
}; };

View file

@ -1,3 +1,4 @@
set -e
if [ -z "$1" ]; then if [ -z "$1" ]; then
echo "provide a new version, current version is $(jq '.version' package.json)" echo "provide a new version, current version is $(jq '.version' package.json)"
exit 1 exit 1

View file

@ -1,7 +1,7 @@
{ {
"name": "hydrogen-view-sdk", "name": "hydrogen-view-sdk",
"description": "Embeddable matrix client library, including view components", "description": "Embeddable matrix client library, including view components",
"version": "0.0.12", "version": "0.0.13",
"main": "./lib-build/hydrogen.cjs.js", "main": "./lib-build/hydrogen.cjs.js",
"exports": { "exports": {
".": { ".": {

View file

@ -13,7 +13,7 @@ const assetPaths = {
wasmBundle: olmJsPath wasmBundle: olmJsPath
} }
}; };
import "hydrogen-view-sdk/theme-element-light.css"; import "hydrogen-view-sdk/assets/theme-element-light.css";
console.log('hydrogenViewSdk', hydrogenViewSdk); console.log('hydrogenViewSdk', hydrogenViewSdk);
console.log('assetPaths', assetPaths); console.log('assetPaths', assetPaths);

View file

@ -6,7 +6,7 @@ const hydrogenViewSdk = require('hydrogen-view-sdk');
// Worker // Worker
require.resolve('hydrogen-view-sdk/main.js'); require.resolve('hydrogen-view-sdk/main.js');
// Styles // Styles
require.resolve('hydrogen-view-sdk/theme-element-light.css'); require.resolve('hydrogen-view-sdk/assets/theme-element-light.css');
// Can access files in the assets/* directory // Can access files in the assets/* directory
require.resolve('hydrogen-view-sdk/assets/main.js'); require.resolve('hydrogen-view-sdk/assets/main.js');

65
src/domain/rageshake.ts Normal file
View file

@ -0,0 +1,65 @@
/*
Copyright 2022 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.
*/
import type {BlobHandle} from "../platform/web/dom/BlobHandle";
import type {RequestFunction} from "../platform/types/types";
// see https://github.com/matrix-org/rageshake#readme
type RageshakeData = {
// A textual description of the problem. Included in the details.log.gz file.
text: string | undefined;
// Application user-agent. Included in the details.log.gz file.
userAgent: string;
// Identifier for the application (eg 'riot-web'). Should correspond to a mapping configured in the configuration file for github issue reporting to work.
app: string;
// Application version. Included in the details.log.gz file.
version: string;
// Label to attach to the github issue, and include in the details file.
label: string | undefined;
};
export async function submitLogsToRageshakeServer(data: RageshakeData, logsBlob: BlobHandle, submitUrl: string, request: RequestFunction): Promise<void> {
const formData = new Map<string, string | {name: string, blob: BlobHandle}>();
if (data.text) {
formData.set("text", data.text);
}
formData.set("user_agent", data.userAgent);
formData.set("app", data.app);
formData.set("version", data.version);
if (data.label) {
formData.set("label", data.label);
}
formData.set("file", {name: "logs.json", blob: logsBlob});
const headers: Map<string, string> = new Map();
headers.set("Accept", "application/json");
const result = request(submitUrl, {
method: "POST",
body: formData,
headers
});
let response;
try {
response = await result.response();
} catch (err) {
throw new Error(`Could not submit logs to ${submitUrl}, got error ${err.message}`);
}
const {status, body} = response;
if (status < 200 || status >= 300) {
throw new Error(`Could not submit logs to ${submitUrl}, got status code ${status} with body ${body}`);
}
// we don't bother with reading report_url from the body as the rageshake server doesn't always return it
// and would have to have CORS setup properly for us to be able to read it.
}

View file

@ -27,6 +27,29 @@ export class BaseMediaTile extends BaseMessageTile {
this._decryptedFile = null; this._decryptedFile = null;
this._isVisible = false; this._isVisible = false;
this._error = null; this._error = null;
this._downloading = false;
this._downloadError = null;
}
async downloadMedia() {
if (this._downloading || this.isPending) {
return;
}
const content = this._getContent();
const filename = content.body;
this._downloading = true;
this.emitChange("status");
let blob;
try {
blob = await this._mediaRepository.downloadAttachment(content);
this.platform.saveFileAs(blob, filename);
} catch (err) {
this._downloadError = err;
} finally {
blob?.dispose();
this._downloading = false;
}
this.emitChange("status");
} }
get isUploading() { get isUploading() {
@ -38,7 +61,7 @@ export class BaseMediaTile extends BaseMessageTile {
return pendingEvent && Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100); return pendingEvent && Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100);
} }
get sendStatus() { get status() {
const {pendingEvent} = this._entry; const {pendingEvent} = this._entry;
switch (pendingEvent?.status) { switch (pendingEvent?.status) {
case SendStatus.Waiting: case SendStatus.Waiting:
@ -53,6 +76,12 @@ export class BaseMediaTile extends BaseMessageTile {
case SendStatus.Error: case SendStatus.Error:
return this.i18n`Error: ${pendingEvent.error.message}`; return this.i18n`Error: ${pendingEvent.error.message}`;
default: default:
if (this._downloadError) {
return `Download failed`;
}
if (this._downloading) {
return this.i18n`Downloading…`;
}
return ""; return "";
} }
} }

View file

@ -16,6 +16,7 @@ limitations under the License.
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel";
import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; import {KeyBackupViewModel} from "./KeyBackupViewModel.js";
import {submitLogsToRageshakeServer} from "../../../domain/rageshake";
class PushNotificationStatus { class PushNotificationStatus {
constructor() { constructor() {
@ -51,6 +52,7 @@ export class SettingsViewModel extends ViewModel {
this.maxSentImageSizeLimit = 4000; this.maxSentImageSizeLimit = 4000;
this.pushNotifications = new PushNotificationStatus(); this.pushNotifications = new PushNotificationStatus();
this._activeTheme = undefined; this._activeTheme = undefined;
this._logsFeedbackMessage = undefined;
} }
get _session() { get _session() {
@ -131,18 +133,14 @@ export class SettingsViewModel extends ViewModel {
return this._formatBytes(this._estimate?.usage); return this._formatBytes(this._estimate?.usage);
} }
get themes() { get themeMapping() {
return this.platform.themeLoader.themes; return this.platform.themeLoader.themeMapping;
} }
get activeTheme() { get activeTheme() {
return this._activeTheme; return this._activeTheme;
} }
setTheme(name) {
this.platform.themeLoader.setTheme(name);
}
_formatBytes(n) { _formatBytes(n) {
if (typeof n === "number") { if (typeof n === "number") {
return Math.round(n / (1024 * 1024)).toFixed(1) + " MB"; return Math.round(n / (1024 * 1024)).toFixed(1) + " MB";
@ -156,6 +154,51 @@ export class SettingsViewModel extends ViewModel {
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
} }
get canSendLogsToServer() {
return !!this.platform.config.bugReportEndpointUrl;
}
get logsServer() {
const {bugReportEndpointUrl} = this.platform.config;
try {
if (bugReportEndpointUrl) {
return new URL(bugReportEndpointUrl).hostname;
}
} catch (e) {}
return "";
}
async sendLogsToServer() {
const {bugReportEndpointUrl} = this.platform.config;
if (bugReportEndpointUrl) {
this._logsFeedbackMessage = this.i18n`Sending logs…`;
this.emitChange();
try {
const logExport = await this.logger.export();
await submitLogsToRageshakeServer(
{
app: "hydrogen",
userAgent: this.platform.description,
version: DEFINE_VERSION,
text: `Submit logs from settings for user ${this._session.userId} on device ${this._session.deviceId}`,
},
logExport.asBlob(),
bugReportEndpointUrl,
this.platform.request
);
this._logsFeedbackMessage = this.i18n`Logs sent succesfully!`;
this.emitChange();
} catch (err) {
this._logsFeedbackMessage = err.message;
this.emitChange();
}
}
}
get logsFeedbackMessage() {
return this._logsFeedbackMessage;
}
async togglePushNotifications() { async togglePushNotifications() {
this.pushNotifications.updating = true; this.pushNotifications.updating = true;
this.pushNotifications.enabledOnServer = null; this.pushNotifications.enabledOnServer = null;
@ -185,5 +228,11 @@ export class SettingsViewModel extends ViewModel {
this.emitChange("pushNotifications.serverError"); this.emitChange("pushNotifications.serverError");
} }
} }
changeThemeOption(themeName, themeVariant) {
this.platform.themeLoader.setTheme(themeName, themeVariant);
// emit so that radio-buttons become displayed/hidden
this.emitChange("themeOption");
}
} }

View file

@ -17,9 +17,12 @@ limitations under the License.
import {BlobHandle} from "../../platform/web/dom/BlobHandle.js"; import {BlobHandle} from "../../platform/web/dom/BlobHandle.js";
export type RequestBody = BlobHandle | string | Map<string, string | {blob: BlobHandle, name: string}>;
export type EncodedBody = { export type EncodedBody = {
mimeType: string; mimeType: string;
body: BlobHandle | string; // the map gets transformed to a FormData object on the web
body: RequestBody
} }
export function encodeQueryParams(queryParams?: object): string { export function encodeQueryParams(queryParams?: object): string {
@ -41,6 +44,11 @@ export function encodeBody(body: BlobHandle | object): EncodedBody {
mimeType: blob.mimeType, mimeType: blob.mimeType,
body: blob // will be unwrapped in request fn body: blob // will be unwrapped in request fn
}; };
} else if (body instanceof Map) {
return {
mimeType: "multipart/form-data",
body: body
}
} else if (typeof body === "object") { } else if (typeof body === "object") {
const json = JSON.stringify(body); const json = JSON.stringify(body);
return { return {

View file

@ -163,7 +163,7 @@ export class GapWriter {
if (!Array.isArray(chunk)) { if (!Array.isArray(chunk)) {
throw new Error("Invalid chunk in response"); throw new Error("Invalid chunk in response");
} }
if (typeof end !== "string") { if (typeof end !== "string" && typeof end !== "undefined") {
throw new Error("Invalid end token in response"); throw new Error("Invalid end token in response");
} }

View file

@ -0,0 +1,68 @@
/*
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.
*/
export type ThemeManifest = Partial<{
/**
* Version number of the theme manifest.
* This must be incremented when backwards incompatible changes are introduced.
*/
version: number;
// A user-facing string that is the name for this theme-collection.
name: string;
/**
* This is added to the manifest during the build process and includes data
* that is needed to load themes at runtime.
*/
source: {
/**
* This is a mapping from theme-id to location of css file relative to the location of the
* manifest.
* eg: {"element-light": "theme-element-light.10f9bb22.css", ...}
*
* Here theme-id is 'theme-variant' where 'theme' is the key used to specify the manifest
* location for this theme-collection in vite.config.js (where the themeBuilder plugin is
* initialized) and 'variant' is the key used to specify the variant details in the values
* section below.
*/
"built-asset": Record<string, string>;
// Location of css file that will be used for themes derived from this theme.
"runtime-asset": string;
// Array of derived-variables
"derived-variables": Array<string>;
};
values: {
/**
* Mapping from variant key to details pertaining to this theme-variant.
* This variant key is used for forming theme-id as mentioned above.
*/
variants: Record<string, Variant>;
};
}>;
type Variant = Partial<{
/**
* If true, this variant is used a default dark/light variant and will be the selected theme
* when "Match system theme" is selected for this theme collection in settings.
*/
default: boolean;
// A user-facing string that is the name for this variant.
name: string;
/**
* Mapping from css variable to its value.
* eg: {"background-color-primary": "#21262b", ...}
*/
variables: Record<string, string>;
}>;

View file

@ -15,13 +15,13 @@ limitations under the License.
*/ */
import type {RequestResult} from "../web/dom/request/fetch.js"; import type {RequestResult} from "../web/dom/request/fetch.js";
import type {EncodedBody} from "../../matrix/net/common"; import type {RequestBody} from "../../matrix/net/common";
import type {ILogItem} from "../../logging/types"; import type {ILogItem} from "../../logging/types";
export interface IRequestOptions { export interface IRequestOptions {
uploadProgress?: (loadedBytes: number) => void; uploadProgress?: (loadedBytes: number) => void;
timeout?: number; timeout?: number;
body?: EncodedBody; body?: RequestBody;
headers?: Map<string, string|number>; headers?: Map<string, string|number>;
cache?: boolean; cache?: boolean;
method?: string; method?: string;

View file

@ -187,9 +187,13 @@ export class Platform {
this._serviceWorkerHandler, this._serviceWorkerHandler,
this._config.push this._config.push
); );
const manifests = this.config["themeManifests"]; if (this._themeLoader) {
await this._themeLoader?.init(manifests); const manifests = this.config["themeManifests"];
this._themeLoader?.setTheme(await this._themeLoader.getActiveTheme(), log); await this._themeLoader?.init(manifests, log);
const { themeName, themeVariant } = await this._themeLoader.getActiveTheme();
log.log({ l: "Active theme", name: themeName, variant: themeVariant });
this._themeLoader.setTheme(themeName, themeVariant, log);
}
}); });
} catch (err) { } catch (err) {
this._container.innerText = err.message; this._container.innerText = err.message;
@ -341,6 +345,10 @@ export class Platform {
head.appendChild(styleTag); head.appendChild(styleTag);
} }
get description() {
return navigator.userAgent ?? "<unknown>";
}
dispose() { dispose() {
this._disposables.dispose(); this._disposables.dispose();
} }

View file

@ -17,59 +17,191 @@ limitations under the License.
import type {ILogItem} from "../../logging/types.js"; import type {ILogItem} from "../../logging/types.js";
import type {Platform} from "./Platform.js"; import type {Platform} from "./Platform.js";
type NormalVariant = {
id: string;
cssLocation: string;
};
type DefaultVariant = {
dark: {
id: string;
cssLocation: string;
variantName: string;
};
light: {
id: string;
cssLocation: string;
variantName: string;
};
default: {
id: string;
cssLocation: string;
variantName: string;
};
}
type ThemeInformation = NormalVariant | DefaultVariant;
export enum ColorSchemePreference {
Dark,
Light
};
export class ThemeLoader { export class ThemeLoader {
private _platform: Platform; private _platform: Platform;
private _themeMapping: Record<string, string> = {}; private _themeMapping: Record<string, ThemeInformation>;
constructor(platform: Platform) { constructor(platform: Platform) {
this._platform = platform; this._platform = platform;
} }
async init(manifestLocations: string[]): Promise<void> { async init(manifestLocations: string[], log?: ILogItem): Promise<void> {
for (const manifestLocation of manifestLocations) { await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => {
const { body } = await this._platform this._themeMapping = {};
.request(manifestLocation, { const results = await Promise.all(
method: "GET", manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response())
format: "json", );
cache: true, results.forEach(({ body }) => this._populateThemeMap(body, log));
}) });
.response();
/*
After build has finished, the source section of each theme manifest
contains `built-assets` which is a mapping from the theme-name to the
location of the css file in build.
*/
Object.assign(this._themeMapping, body["source"]["built-assets"]);
}
} }
setTheme(themeName: string, log?: ILogItem) { private _populateThemeMap(manifest, log: ILogItem) {
this._platform.logger.wrapOrRun(log, {l: "change theme", id: themeName}, () => { log.wrap("populateThemeMap", (l) => {
const themeLocation = this._themeMapping[themeName]; /*
if (!themeLocation) { After build has finished, the source section of each theme manifest
throw new Error( `Cannot find theme location for theme "${themeName}"!`); contains `built-assets` which is a mapping from the theme-id to
cssLocation of theme
*/
const builtAssets: Record<string, string> = manifest.source?.["built-assets"];
const themeName = manifest.name;
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
for (const [themeId, cssLocation] of Object.entries(builtAssets)) {
const variant = themeId.match(/.+-(.+)/)?.[1];
const { name: variantName, default: isDefault, dark } = manifest.values.variants[variant!];
const themeDisplayName = `${themeName} ${variantName}`;
if (isDefault) {
/**
* This is a default variant!
* We'll add these to the themeMapping (separately) keyed with just the
* theme-name (i.e "Element" instead of "Element Dark").
* We need to be able to distinguish them from other variants!
*
* This allows us to render radio-buttons with "dark" and
* "light" options.
*/
const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant;
defaultVariant.variantName = variantName;
defaultVariant.id = themeId
defaultVariant.cssLocation = cssLocation;
continue;
}
// Non-default variants are keyed in themeMapping with "theme_name variant_name"
// eg: "Element Dark"
this._themeMapping[themeDisplayName] = {
cssLocation,
id: themeId
};
} }
this._platform.replaceStylesheet(themeLocation); if (defaultDarkVariant.id && defaultLightVariant.id) {
this._platform.settingsStorage.setString("theme", themeName); /**
* As mentioned above, if there's both a default dark and a default light variant,
* add them to themeMapping separately.
*/
const defaultVariant = this.preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant;
this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant };
}
else {
/**
* If only one default variant is found (i.e only dark default or light default but not both),
* treat it like any other variant.
*/
const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant;
this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation };
}
//Add the default-theme as an additional option to the mapping
const defaultThemeId = this.getDefaultTheme();
if (defaultThemeId) {
const themeDetails = this._findThemeDetailsFromId(defaultThemeId);
if (themeDetails) {
this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.cssLocation };
}
}
l.log({ l: "Default Theme", theme: defaultThemeId});
l.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" });
l.log({ l: "Result", themeMapping: this._themeMapping });
}); });
} }
get themes(): string[] { setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) {
return Object.keys(this._themeMapping); this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => {
let cssLocation: string;
let themeDetails = this._themeMapping[themeName];
if ("id" in themeDetails) {
cssLocation = themeDetails.cssLocation;
}
else {
if (!themeVariant) {
throw new Error("themeVariant is undefined!");
}
cssLocation = themeDetails[themeVariant].cssLocation;
}
this._platform.replaceStylesheet(cssLocation);
this._platform.settingsStorage.setString("theme-name", themeName);
if (themeVariant) {
this._platform.settingsStorage.setString("theme-variant", themeVariant);
}
else {
this._platform.settingsStorage.remove("theme-variant");
}
});
} }
async getActiveTheme(): Promise<string|undefined> { /** Maps theme display name to theme information */
// check if theme is set via settings get themeMapping(): Record<string, ThemeInformation> {
let theme = await this._platform.settingsStorage.getString("theme"); return this._themeMapping;
if (theme) { }
return theme;
async getActiveTheme(): Promise<{themeName: string, themeVariant?: string}> {
let themeName = await this._platform.settingsStorage.getString("theme-name");
let themeVariant = await this._platform.settingsStorage.getString("theme-variant");
if (!themeName || !this._themeMapping[themeName]) {
themeName = "Default" in this._themeMapping ? "Default" : Object.keys(this._themeMapping)[0];
if (!this._themeMapping[themeName][themeVariant]) {
themeVariant = "default" in this._themeMapping[themeName] ? "default" : undefined;
}
} }
// return default theme return { themeName, themeVariant };
}
getDefaultTheme(): string | undefined {
switch (this.preferredColorScheme) {
case ColorSchemePreference.Dark:
return this._platform.config["defaultTheme"]?.dark;
case ColorSchemePreference.Light:
return this._platform.config["defaultTheme"]?.light;
}
}
private _findThemeDetailsFromId(themeId: string): {themeName: string, cssLocation: string, variant?: string} | undefined {
for (const [themeName, themeData] of Object.entries(this._themeMapping)) {
if ("id" in themeData && themeData.id === themeId) {
return { themeName, cssLocation: themeData.cssLocation };
}
else if ("light" in themeData && themeData.light?.id === themeId) {
return { themeName, cssLocation: themeData.light.cssLocation, variant: "light" };
}
else if ("dark" in themeData && themeData.dark?.id === themeId) {
return { themeName, cssLocation: themeData.dark.cssLocation, variant: "dark" };
}
}
}
get preferredColorScheme(): ColorSchemePreference | undefined {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) { if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return this._platform.config["defaultTheme"].dark; return ColorSchemePreference.Dark;
} else if (window.matchMedia("(prefers-color-scheme: light)").matches) { }
return this._platform.config["defaultTheme"].light; else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
return ColorSchemePreference.Light;
} }
return undefined;
} }
} }

View file

@ -4,5 +4,6 @@
"gatewayUrl": "https://matrix.org", "gatewayUrl": "https://matrix.org",
"applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" "applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM"
}, },
"defaultHomeServer": "matrix.org" "defaultHomeServer": "matrix.org",
"bugReportEndpointUrl": "https://element.io/bugreports/submit"
} }

View file

@ -27,6 +27,20 @@ export function addCacheBuster(urlStr, random = Math.random) {
return urlStr + `_cacheBuster=${Math.ceil(random() * Number.MAX_SAFE_INTEGER)}`; return urlStr + `_cacheBuster=${Math.ceil(random() * Number.MAX_SAFE_INTEGER)}`;
} }
export function mapAsFormData(map) {
const formData = new FormData();
for (const [name, value] of map) {
// Special case {name: string, blob: BlobHandle} to set a filename.
// This is the format returned by platform.openFile
if (value.blob?.nativeBlob && value.name) {
formData.set(name, value.blob.nativeBlob, value.name);
} else {
formData.set(name, value);
}
}
return formData;
}
export function tests() { export function tests() {
return { return {
"add cache buster": assert => { "add cache buster": assert => {

View file

@ -20,7 +20,7 @@ import {
ConnectionError ConnectionError
} from "../../../../matrix/error.js"; } from "../../../../matrix/error.js";
import {abortOnTimeout} from "../../../../utils/timeout"; import {abortOnTimeout} from "../../../../utils/timeout";
import {addCacheBuster} from "./common.js"; import {addCacheBuster, mapAsFormData} from "./common.js";
import {xhrRequest} from "./xhr.js"; import {xhrRequest} from "./xhr.js";
class RequestResult { class RequestResult {
@ -70,6 +70,9 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) {
if (body?.nativeBlob) { if (body?.nativeBlob) {
body = body.nativeBlob; body = body.nativeBlob;
} }
if (body instanceof Map) {
body = mapAsFormData(body);
}
let options = {method, body}; let options = {method, body};
if (controller) { if (controller) {
options = Object.assign(options, { options = Object.assign(options, {

View file

@ -18,7 +18,7 @@ import {
AbortError, AbortError,
ConnectionError ConnectionError
} from "../../../../matrix/error.js"; } from "../../../../matrix/error.js";
import {addCacheBuster} from "./common.js"; import {addCacheBuster, mapAsFormData} from "./common.js";
class RequestResult { class RequestResult {
constructor(promise, xhr) { constructor(promise, xhr) {
@ -94,6 +94,9 @@ export function xhrRequest(url, options) {
if (body?.nativeBlob) { if (body?.nativeBlob) {
body = body.nativeBlob; body = body.nativeBlob;
} }
if (body instanceof Map) {
body = mapAsFormData(body);
}
xhr.send(body || null); xhr.send(body || null);
return new RequestResult(promise, xhr); return new RequestResult(promise, xhr);

View file

@ -31,7 +31,7 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) {
avatarClasses += ` ${extraClasses}`; avatarClasses += ` ${extraClasses}`;
} }
const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter); const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter);
const avatar = tag.div({className: avatarClasses}, [avatarContent]); const avatar = tag.div({className: avatarClasses, "data-testid": "avatar"}, [avatarContent]);
if (hasAvatar) { if (hasAvatar) {
setAttribute(avatar, "data-avatar-letter", vm.avatarLetter); setAttribute(avatar, "data-avatar-letter", vm.avatarLetter);
setAttribute(avatar, "data-avatar-color", vm.avatarColorNumber); setAttribute(avatar, "data-avatar-color", vm.avatarColorNumber);

View file

@ -1,45 +1,39 @@
{ {
"version": 1, "version": 1,
"name": "element", "name": "Element",
"values": { "values": {
"font-faces": [
{
"font-family": "Inter",
"src": [{"asset": "/fonts/Inter.ttf", "format": "ttf"}]
}
],
"variants": { "variants": {
"light": { "light": {
"base": true, "base": true,
"default": true, "default": true,
"name": "Light", "name": "Light",
"variables": { "variables": {
"background-color-primary": "#fff", "background-color-primary": "#fff",
"background-color-secondary": "#f6f6f6", "background-color-secondary": "#f6f6f6",
"text-color": "#2E2F32", "text-color": "#2E2F32",
"accent-color": "#03b381", "accent-color": "#03b381",
"error-color": "#FF4B55", "error-color": "#FF4B55",
"fixed-white": "#fff", "fixed-white": "#fff",
"room-badge": "#61708b", "room-badge": "#61708b",
"link-color": "#238cf5" "link-color": "#238cf5"
} }
}, },
"dark": { "dark": {
"dark": true, "dark": true,
"default": true, "default": true,
"name": "Dark", "name": "Dark",
"variables": { "variables": {
"background-color-primary": "#21262b", "background-color-primary": "#21262b",
"background-color-secondary": "#2D3239", "background-color-secondary": "#2D3239",
"text-color": "#fff", "text-color": "#fff",
"accent-color": "#03B381", "accent-color": "#03B381",
"error-color": "#FF4B55", "error-color": "#FF4B55",
"fixed-white": "#fff", "fixed-white": "#fff",
"room-badge": "#61708b", "room-badge": "#61708b",
"link-color": "#238cf5" "link-color": "#238cf5"
} }
} }
} }
} }
} }

View file

@ -233,7 +233,7 @@ only loads when the top comes into view*/
align-self: stretch; align-self: stretch;
} }
.Timeline_messageBody .media > .sendStatus { .Timeline_messageBody .media > .status {
align-self: end; align-self: end;
justify-self: start; justify-self: start;
font-size: 0.8em; font-size: 0.8em;
@ -251,7 +251,7 @@ only loads when the top comes into view*/
} }
.Timeline_messageBody .media > time, .Timeline_messageBody .media > time,
.Timeline_messageBody .media > .sendStatus { .Timeline_messageBody .media > .status {
color: var(--text-color); color: var(--text-color);
display: block; display: block;
padding: 2px; padding: 2px;

View file

@ -54,3 +54,11 @@ export function insertAt(parentNode: Element, idx: number, childNode: Node): voi
export function removeChildren(parentNode: Element): void { export function removeChildren(parentNode: Element): void {
parentNode.innerHTML = ''; parentNode.innerHTML = '';
} }
export function disableTargetCallback(callback: (evt: Event) => Promise<void>): (evt: Event) => Promise<void> {
return async (evt: Event) => {
(evt.target as HTMLElement)?.setAttribute("disabled", "disabled");
await callback(evt);
(evt.target as HTMLElement)?.removeAttribute("disabled");
}
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {BaseMessageView} from "./BaseMessageView.js"; import {BaseMessageView} from "./BaseMessageView.js";
import {Menu} from "../../../general/Menu.js";
export class BaseMediaView extends BaseMessageView { export class BaseMediaView extends BaseMessageView {
renderMessageBody(t, vm) { renderMessageBody(t, vm) {
@ -35,24 +36,39 @@ export class BaseMediaView extends BaseMessageView {
this.renderMedia(t, vm), this.renderMedia(t, vm),
t.time(vm.date + " " + vm.time), t.time(vm.date + " " + vm.time),
]; ];
const status = t.div({
className: {
status: true,
hidden: vm => !vm.status
},
}, vm => vm.status);
children.push(status);
if (vm.isPending) { if (vm.isPending) {
const sendStatus = t.div({
className: {
sendStatus: true,
hidden: vm => !vm.sendStatus
},
}, vm => vm.sendStatus);
const progress = t.progress({ const progress = t.progress({
min: 0, min: 0,
max: 100, max: 100,
value: vm => vm.uploadPercentage, value: vm => vm.uploadPercentage,
className: {hidden: vm => !vm.isUploading} className: {hidden: vm => !vm.isUploading}
}); });
children.push(sendStatus, progress); children.push(progress);
} }
return t.div({className: "Timeline_messageBody"}, [ return t.div({className: "Timeline_messageBody"}, [
t.div({className: "media", style: `max-width: ${vm.width}px`}, children), t.div({className: "media", style: `max-width: ${vm.width}px`, "data-testid": "media"}, children),
t.if(vm => vm.error, t => t.p({className: "error"}, vm.error)) t.if(vm => vm.error, t => t.p({className: "error"}, vm.error))
]); ]);
} }
createMenuOptions(vm) {
const options = super.createMenuOptions(vm);
if (!vm.isPending) {
let label;
switch (vm.shape) {
case "image": label = vm.i18n`Download image`; break;
case "video": label = vm.i18n`Download video`; break;
default: label = vm.i18n`Download media`; break;
}
options.push(Menu.option(label, () => vm.downloadMedia()));
}
return options;
}
} }

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView"; import {TemplateView} from "../../general/TemplateView";
import {disableTargetCallback} from "../../general/utils";
import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js" import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js"
export class SettingsView extends TemplateView { export class SettingsView extends TemplateView {
@ -101,11 +102,17 @@ export class SettingsView extends TemplateView {
return row(t, vm.i18n`Use the following theme`, this._themeOptions(t, vm)); return row(t, vm.i18n`Use the following theme`, this._themeOptions(t, vm));
}), }),
); );
const logButtons = [];
if (vm.canSendLogsToServer) {
logButtons.push(t.button({onClick: disableTargetCallback(() => vm.sendLogsToServer())}, `Submit logs to ${vm.logsServer}`));
}
logButtons.push(t.button({onClick: () => vm.exportLogs()}, "Download logs"));
settingNodes.push( settingNodes.push(
t.h3("Application"), t.h3("Application"),
row(t, vm.i18n`Version`, version), row(t, vm.i18n`Version`, version),
row(t, vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), row(t, vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`),
row(t, vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")), row(t, vm.i18n`Debug logs`, logButtons),
t.p({className: {hidden: vm => !vm.logsFeedbackMessage}}, vm => vm.logsFeedbackMessage),
t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ", t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ",
t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]), t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]),
); );
@ -140,11 +147,52 @@ export class SettingsView extends TemplateView {
} }
_themeOptions(t, vm) { _themeOptions(t, vm) {
const activeTheme = vm.activeTheme; const { themeName: activeThemeName, themeVariant: activeThemeVariant } = vm.activeTheme;
const optionTags = []; const optionTags = [];
for (const name of vm.themes) { // 1. render the dropdown containing the themes
optionTags.push(t.option({value: name, selected: name === activeTheme}, name)); for (const name of Object.keys(vm.themeMapping)) {
optionTags.push( t.option({ value: name, selected: name === activeThemeName} , name));
} }
return t.select({onChange: (e) => vm.setTheme(e.target.value)}, optionTags); const select = t.select({
onChange: (e) => {
const themeName = e.target.value;
if(!("id" in vm.themeMapping[themeName])) {
const colorScheme = darkRadioButton.checked ? "dark" : lightRadioButton.checked ? "light" : "default";
// execute the radio-button callback so that the theme actually changes!
// otherwise the theme would only change when another radio-button is selected.
radioButtonCallback(colorScheme);
return;
}
vm.changeThemeOption(themeName);
}
}, optionTags);
// 2. render the radio-buttons used to choose variant
const radioButtonCallback = (colorScheme) => {
const selectedThemeName = select.options[select.selectedIndex].value;
vm.changeThemeOption(selectedThemeName, colorScheme);
};
const isDarkSelected = activeThemeVariant === "dark";
const isLightSelected = activeThemeVariant === "light";
const darkRadioButton = t.input({ type: "radio", name: "radio-chooser", value: "dark", id: "dark", checked: isDarkSelected });
const defaultRadioButton = t.input({ type: "radio", name: "radio-chooser", value: "default", id: "default", checked: !(isDarkSelected || isLightSelected) });
const lightRadioButton = t.input({ type: "radio", name: "radio-chooser", value: "light", id: "light", checked: isLightSelected });
const radioButtons = t.form({
className: {
hidden: () => {
const themeName = select.options[select.selectedIndex].value;
return "id" in vm.themeMapping[themeName];
}
},
onChange: (e) => radioButtonCallback(e.target.value)
},
[
defaultRadioButton,
t.label({for: "default"}, "Match system theme"),
darkRadioButton,
t.label({for: "dark"}, "dark"),
lightRadioButton,
t.label({for: "light"}, "light"),
]);
return t.div({ className: "theme-chooser" }, [select, radioButtons]);
} }
} }

View file

@ -40,7 +40,7 @@ const commonOptions = {
postcss: { postcss: {
plugins: [ plugins: [
compileVariables({derive, compiledVariables}), compileVariables({derive, compiledVariables}),
urlVariables({compileVariables}), urlVariables({compiledVariables}),
urlProcessor({replacer}), urlProcessor({replacer}),
// cssvariables({ // cssvariables({
// preserve: (declaration) => { // preserve: (declaration) => {

View file

@ -3,8 +3,9 @@ const mergeOptions = require('merge-options');
const themeBuilder = require("./scripts/build-plugins/rollup-plugin-build-themes"); const themeBuilder = require("./scripts/build-plugins/rollup-plugin-build-themes");
const {commonOptions, compiledVariables} = require("./vite.common-config.js"); const {commonOptions, compiledVariables} = require("./vite.common-config.js");
// These paths will be saved without their hash so they havea consisent path to // These paths will be saved without their hash so they have a consisent path
// reference // that we can reference in our `package.json` `exports`. And so people can import
// them with a consistent path.
const pathsToExport = [ const pathsToExport = [
"main.js", "main.js",
"download-sandbox.html", "download-sandbox.html",
@ -21,7 +22,8 @@ export default mergeOptions(commonOptions, {
output: { output: {
assetFileNames: (chunkInfo) => { assetFileNames: (chunkInfo) => {
// Get rid of the hash so we can consistently reference these // Get rid of the hash so we can consistently reference these
// files in our `package.json` `exports` // files in our `package.json` `exports`. And so people can
// import them with a consistent path.
if(pathsToExport.includes(path.basename(chunkInfo.name))) { if(pathsToExport.includes(path.basename(chunkInfo.name))) {
return "assets/[name].[ext]"; return "assets/[name].[ext]";
} }

View file

@ -52,9 +52,9 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf"
integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w== integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz": "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz":
version "3.2.3" version "3.2.8"
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856"
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"