Merge branch 'bwindels/calls' into update-thirdroom-calls
1
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
||||||
*.sublime-project
|
*.sublime-project
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
fetchlogs
|
fetchlogs
|
||||||
sessionexports
|
sessionexports
|
||||||
|
|
25
README.md
|
@ -10,13 +10,34 @@ Hydrogen's goals are:
|
||||||
- It is a standalone webapp, but can also be easily embedded into an existing website/webapp to add chat capabilities.
|
- It is a standalone webapp, but can also be easily embedded into an existing website/webapp to add chat capabilities.
|
||||||
- Loading (unused) parts of the application after initial page load should be supported
|
- Loading (unused) parts of the application after initial page load should be supported
|
||||||
|
|
||||||
|
For embedded usage, see the [SDK instructions](doc/SDK.md).
|
||||||
|
|
||||||
If you find this interesting, come and discuss on [`#hydrogen:matrix.org`](https://matrix.to/#/#hydrogen:matrix.org).
|
If you find this interesting, come and discuss on [`#hydrogen:matrix.org`](https://matrix.to/#/#hydrogen:matrix.org).
|
||||||
|
|
||||||
# How to use
|
# How to use
|
||||||
|
|
||||||
Hydrogen is deployed to [hydrogen.element.io](https://hydrogen.element.io). You can run it locally `yarn install` (only the first time) and `yarn start` in the terminal, and point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md).
|
Hydrogen is deployed to [hydrogen.element.io](https://hydrogen.element.io). You can also deploy Hydrogen on your own web server:
|
||||||
|
|
||||||
Hydrogen uses symbolic links in the codebase, so if you are on Windows, have a look at [making git & symlinks work](https://github.com/git-for-windows/git/wiki/Symbolic-Links) there.
|
1. Download the [latest release package](https://github.com/vector-im/hydrogen-web/releases).
|
||||||
|
1. Extract the package to the public directory of your web server.
|
||||||
|
1. If this is your first deploy:
|
||||||
|
1. copy `config.sample.json` to `config.json` and if needed, make any modifications (unless you've set up your own [sygnal](https://github.com/matrix-org/sygnal) instance, you don't need to change anything in the `push` section).
|
||||||
|
1. Disable caching entirely on the server for:
|
||||||
|
- `index.html`
|
||||||
|
- `sw.js`
|
||||||
|
- `config.json`
|
||||||
|
- All theme manifests referenced in the `themeManifests` of `config.json`, these files are typically called `theme-{name}.json`.
|
||||||
|
|
||||||
|
These resources will still be cached client-side by the service worker. Because of this; you'll still need to refresh the app twice before config.json changes are applied.
|
||||||
|
|
||||||
|
## Set up a dev environment
|
||||||
|
|
||||||
|
You can run Hydrogen locally by the following commands in the terminal:
|
||||||
|
|
||||||
|
- `yarn install` (only the first time)
|
||||||
|
- `yarn start` in the terminal
|
||||||
|
|
||||||
|
Now point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md).
|
||||||
|
|
||||||
# FAQ
|
# FAQ
|
||||||
|
|
||||||
|
|
|
@ -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
|
@ -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!**
|
BIN
doc/images/coloring-process.png
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
doc/images/svg-icon-example.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
doc/images/theming-architecture.png
Normal file
After Width: | Height: | Size: 19 KiB |
15
package.json
|
@ -1,19 +1,24 @@
|
||||||
{
|
{
|
||||||
"name": "hydrogen-web",
|
"name": "hydrogen-web",
|
||||||
"version": "0.2.28",
|
"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",
|
||||||
"lint-ci": "eslint src/",
|
"lint-ci": "eslint src/",
|
||||||
"test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/",
|
"test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/",
|
||||||
"test:postcss": "impunity --entry-point scripts/postcss/tests/css-compile-variables.test.js scripts/postcss/tests/css-url-to-variables.test.js",
|
"test:postcss": "impunity --entry-point scripts/postcss/tests/css-compile-variables.test.js scripts/postcss/tests/css-url-to-variables.test.js",
|
||||||
|
"test:sdk": "yarn build:sdk && cd ./scripts/sdk/test/ && yarn --no-lockfile && node test-sdk-in-esm-vite-build-env.js && node test-sdk-in-commonjs-env.js",
|
||||||
"start": "vite --port 3000",
|
"start": "vite --port 3000",
|
||||||
"build": "vite build",
|
"build": "vite build && ./scripts/cleanup.sh",
|
||||||
"build:sdk": "./scripts/sdk/build.sh"
|
"build:sdk": "./scripts/sdk/build.sh",
|
||||||
|
"watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -48,11 +53,11 @@
|
||||||
"regenerator-runtime": "^0.13.7",
|
"regenerator-runtime": "^0.13.7",
|
||||||
"text-encoding": "^0.7.0",
|
"text-encoding": "^0.7.0",
|
||||||
"typescript": "^4.4",
|
"typescript": "^4.4",
|
||||||
"vite": "^2.6.14",
|
"vite": "^2.9.8",
|
||||||
"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",
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -31,6 +31,18 @@ function appendVariablesToCSS(variables, cssSource) {
|
||||||
return cssSource + getRootSectionWithVariables(variables);
|
return cssSource + getRootSectionWithVariables(variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addThemesToConfig(bundle, manifestLocations, defaultThemes) {
|
||||||
|
for (const [fileName, info] of Object.entries(bundle)) {
|
||||||
|
if (fileName === "config.json") {
|
||||||
|
const source = new TextDecoder().decode(info.source);
|
||||||
|
const config = JSON.parse(source);
|
||||||
|
config["themeManifests"] = manifestLocations;
|
||||||
|
config["defaultTheme"] = defaultThemes;
|
||||||
|
info.source = new TextEncoder().encode(JSON.stringify(config, undefined, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseBundle(bundle) {
|
function parseBundle(bundle) {
|
||||||
const chunkMap = new Map();
|
const chunkMap = new Map();
|
||||||
const assetMap = new Map();
|
const assetMap = new Map();
|
||||||
|
@ -72,7 +84,7 @@ function parseBundle(bundle) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function buildThemes(options) {
|
module.exports = function buildThemes(options) {
|
||||||
let manifest, variants, defaultDark, defaultLight;
|
let manifest, variants, defaultDark, defaultLight, defaultThemes = {};
|
||||||
let isDevelopment = false;
|
let isDevelopment = false;
|
||||||
const virtualModuleId = '@theme/'
|
const virtualModuleId = '@theme/'
|
||||||
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
||||||
|
@ -99,9 +111,11 @@ module.exports = function buildThemes(options) {
|
||||||
// This is the default theme, stash the file name for later
|
// This is the default theme, stash the file name for later
|
||||||
if (details.dark) {
|
if (details.dark) {
|
||||||
defaultDark = fileName;
|
defaultDark = fileName;
|
||||||
|
defaultThemes["dark"] = `${name}-${variant}`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
defaultLight = fileName;
|
defaultLight = fileName;
|
||||||
|
defaultThemes["light"] = `${name}-${variant}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// emit the css as built theme bundle
|
// emit the css as built theme bundle
|
||||||
|
@ -215,6 +229,7 @@ module.exports = function buildThemes(options) {
|
||||||
type: "text/css",
|
type: "text/css",
|
||||||
media: "(prefers-color-scheme: dark)",
|
media: "(prefers-color-scheme: dark)",
|
||||||
href: `./${darkThemeLocation}`,
|
href: `./${darkThemeLocation}`,
|
||||||
|
class: "theme",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -224,31 +239,43 @@ module.exports = function buildThemes(options) {
|
||||||
type: "text/css",
|
type: "text/css",
|
||||||
media: "(prefers-color-scheme: light)",
|
media: "(prefers-color-scheme: light)",
|
||||||
href: `./${lightThemeLocation}`,
|
href: `./${lightThemeLocation}`,
|
||||||
|
class: "theme",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
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 = [];
|
||||||
for (const [location, chunkArray] of chunkMap) {
|
for (const [location, chunkArray] of chunkMap) {
|
||||||
const manifest = require(`${location}/manifest.json`);
|
const manifest = require(`${location}/manifest.json`);
|
||||||
const compiledVariables = options.compiledVariables.get(location);
|
const compiledVariables = options.compiledVariables.get(location);
|
||||||
const derivedVariables = compiledVariables["derived-variables"];
|
const derivedVariables = compiledVariables["derived-variables"];
|
||||||
const icon = compiledVariables["icon"];
|
const icon = compiledVariables["icon"];
|
||||||
|
const builtAssets = {};
|
||||||
|
for (const chunk of chunkArray) {
|
||||||
|
const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/);
|
||||||
|
builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName;
|
||||||
|
}
|
||||||
manifest.source = {
|
manifest.source = {
|
||||||
"built-asset": chunkArray.map(chunk => assetMap.get(chunk.fileName).fileName),
|
"built-assets": builtAssets,
|
||||||
"runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName,
|
"runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName,
|
||||||
"derived-variables": derivedVariables,
|
"derived-variables": derivedVariables,
|
||||||
"icon": icon
|
"icon": icon
|
||||||
};
|
};
|
||||||
const name = `theme-${manifest.name}.json`;
|
const name = `theme-${manifest.name}.json`;
|
||||||
|
manifestLocations.push(`assets/${name}`);
|
||||||
this.emitFile({
|
this.emitFile({
|
||||||
type: "asset",
|
type: "asset",
|
||||||
name,
|
name,
|
||||||
source: JSON.stringify(manifest),
|
source: JSON.stringify(manifest),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
addThemesToConfig(bundle, manifestLocations, defaultThemes);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ function contentHash(str) {
|
||||||
return hasher.digest();
|
return hasher.digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
|
function injectServiceWorker(swFile, findUnhashedFileNamesFromBundle, placeholdersPerChunk) {
|
||||||
const swName = path.basename(swFile);
|
const swName = path.basename(swFile);
|
||||||
let root;
|
let root;
|
||||||
let version;
|
let version;
|
||||||
|
@ -31,6 +31,7 @@ function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
|
||||||
logger = config.logger;
|
logger = config.logger;
|
||||||
},
|
},
|
||||||
generateBundle: async function(options, bundle) {
|
generateBundle: async function(options, bundle) {
|
||||||
|
const otherUnhashedFiles = findUnhashedFileNamesFromBundle(bundle);
|
||||||
const unhashedFilenames = [swName].concat(otherUnhashedFiles);
|
const unhashedFilenames = [swName].concat(otherUnhashedFiles);
|
||||||
const unhashedFileContentMap = unhashedFilenames.reduce((map, fileName) => {
|
const unhashedFileContentMap = unhashedFilenames.reduce((map, fileName) => {
|
||||||
const chunkOrAsset = bundle[fileName];
|
const chunkOrAsset = bundle[fileName];
|
||||||
|
|
3
scripts/cleanup.sh
Executable file
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Remove icons created in .tmp
|
||||||
|
rm -rf .tmp
|
|
@ -2,6 +2,9 @@ VERSION=$(jq -r ".version" package.json)
|
||||||
PACKAGE=hydrogen-web-$VERSION.tar.gz
|
PACKAGE=hydrogen-web-$VERSION.tar.gz
|
||||||
yarn build
|
yarn build
|
||||||
pushd target
|
pushd target
|
||||||
|
# move config file so we don't override it
|
||||||
|
# when deploying a new version
|
||||||
|
mv config.json config.sample.json
|
||||||
tar -czvf ../$PACKAGE ./
|
tar -czvf ../$PACKAGE ./
|
||||||
popd
|
popd
|
||||||
echo $PACKAGE
|
echo $PACKAGE
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,13 +20,17 @@ 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);
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = valueParser(value);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error trying to parse ${decl}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
parsed.walk(node => {
|
parsed.walk(node => {
|
||||||
if (node.type !== "function" || node.value !== "url") {
|
if (node.type !== "function" || node.value !== "url") {
|
||||||
return;
|
return;
|
||||||
|
@ -35,7 +39,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 +48,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 +58,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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -2,6 +2,18 @@
|
||||||
"name": "@thirdroom/hydrogen-view-sdk",
|
"name": "@thirdroom/hydrogen-view-sdk",
|
||||||
"description": "Embeddable matrix client library, including view components",
|
"description": "Embeddable matrix client library, including view components",
|
||||||
"version": "0.0.11",
|
"version": "0.0.11",
|
||||||
"main": "./hydrogen.es.js",
|
"main": "./lib-build/hydrogen.cjs.js",
|
||||||
"type": "module"
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./lib-build/hydrogen.es.js",
|
||||||
|
"require": "./lib-build/hydrogen.cjs.js"
|
||||||
|
},
|
||||||
|
"./paths/vite": "./paths/vite.js",
|
||||||
|
"./style.css": "./asset-build/assets/theme-element-light.css",
|
||||||
|
"./theme-element-light.css": "./asset-build/assets/theme-element-light.css",
|
||||||
|
"./theme-element-dark.css": "./asset-build/assets/theme-element-dark.css",
|
||||||
|
"./main.js": "./asset-build/assets/main.js",
|
||||||
|
"./download-sandbox.html": "./asset-build/assets/download-sandbox.html",
|
||||||
|
"./assets/*": "./asset-build/assets/*"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,12 @@
|
||||||
# Exit whenever one of the commands fail with a non-zero exit code
|
# Exit whenever one of the commands fail with a non-zero exit code
|
||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
# Enable extended globs so we can use the `!(filename)` glob syntax
|
||||||
|
shopt -s extglob
|
||||||
|
|
||||||
rm -rf target
|
# Only remove the directory contents instead of the whole directory to maintain
|
||||||
|
# the `npm link`/`yarn link` symlink
|
||||||
|
rm -rf target/*
|
||||||
yarn run vite build -c vite.sdk-assets-config.js
|
yarn run vite build -c vite.sdk-assets-config.js
|
||||||
yarn run vite build -c vite.sdk-lib-config.js
|
yarn run vite build -c vite.sdk-lib-config.js
|
||||||
yarn tsc -p tsconfig-declaration.json
|
yarn tsc -p tsconfig-declaration.json
|
||||||
|
@ -12,19 +16,10 @@ mkdir target/paths
|
||||||
# this doesn't work, the ?url imports need to be in the consuming project, so disable for now
|
# this doesn't work, the ?url imports need to be in the consuming project, so disable for now
|
||||||
# ./scripts/sdk/transform-paths.js ./src/platform/web/sdk/paths/vite.js ./target/paths/vite.js
|
# ./scripts/sdk/transform-paths.js ./src/platform/web/sdk/paths/vite.js ./target/paths/vite.js
|
||||||
cp doc/SDK.md target/README.md
|
cp doc/SDK.md target/README.md
|
||||||
pushd target
|
pushd target/asset-build
|
||||||
pushd asset-build/assets
|
rm index.html
|
||||||
mv main.*.js ../../main.js
|
|
||||||
# Create a copy of light theme for backwards compatibility
|
|
||||||
cp theme-element-light.*.css ../../style.css
|
|
||||||
# Remove asset hash from css files
|
|
||||||
mv theme-element-light.*.css ../../theme-element-light.css
|
|
||||||
mv theme-element-dark.*.css ../../theme-element-dark.css
|
|
||||||
mv download-sandbox.*.html ../../download-sandbox.html
|
|
||||||
rm *.js *.wasm
|
|
||||||
mv ./* ../../
|
|
||||||
popd
|
popd
|
||||||
rm -rf asset-build
|
pushd target/asset-build/assets
|
||||||
mv lib-build/* .
|
# Remove all `*.wasm` and `*.js` files except for `main.js`
|
||||||
rm -rf lib-build
|
rm !(main).js *.wasm
|
||||||
popd
|
popd
|
||||||
|
|
|
@ -3,21 +3,7 @@ const fs = require("fs");
|
||||||
const appManifest = require("../../package.json");
|
const appManifest = require("../../package.json");
|
||||||
const baseSDKManifest = require("./base-manifest.json");
|
const baseSDKManifest = require("./base-manifest.json");
|
||||||
/*
|
/*
|
||||||
need to leave exports out of base-manifest.json because of #vite-bug,
|
Need to leave typescript type definitions out until the
|
||||||
with the downside that we can't support environments that support
|
|
||||||
both esm and commonjs modules, so we pick just esm.
|
|
||||||
```
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./hydrogen.es.js",
|
|
||||||
"require": "./hydrogen.cjs.js"
|
|
||||||
},
|
|
||||||
"./paths/vite": "./paths/vite.js",
|
|
||||||
"./style.css": "./style.css"
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
Also need to leave typescript type definitions out until the
|
|
||||||
typescript conversion is complete and all imports in the d.ts files
|
typescript conversion is complete and all imports in the d.ts files
|
||||||
exists.
|
exists.
|
||||||
```
|
```
|
||||||
|
|
3
scripts/sdk/test/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
yarn.lock
|
2
scripts/sdk/test/deps.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// Keep TypeScripts from complaining about hydrogen-view-sdk not having types yet
|
||||||
|
declare module "hydrogen-view-sdk";
|
21
scripts/sdk/test/esm-entry.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import * as hydrogenViewSdk from "hydrogen-view-sdk";
|
||||||
|
import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url';
|
||||||
|
import workerPath from 'hydrogen-view-sdk/main.js?url';
|
||||||
|
import olmWasmPath from '@matrix-org/olm/olm.wasm?url';
|
||||||
|
import olmJsPath from '@matrix-org/olm/olm.js?url';
|
||||||
|
import olmLegacyJsPath from '@matrix-org/olm/olm_legacy.js?url';
|
||||||
|
const assetPaths = {
|
||||||
|
downloadSandbox: downloadSandboxPath,
|
||||||
|
worker: workerPath,
|
||||||
|
olm: {
|
||||||
|
wasm: olmWasmPath,
|
||||||
|
legacyBundle: olmLegacyJsPath,
|
||||||
|
wasmBundle: olmJsPath
|
||||||
|
}
|
||||||
|
};
|
||||||
|
import "hydrogen-view-sdk/assets/theme-element-light.css";
|
||||||
|
|
||||||
|
console.log('hydrogenViewSdk', hydrogenViewSdk);
|
||||||
|
console.log('assetPaths', assetPaths);
|
||||||
|
|
||||||
|
console.log('Entry ESM works ✅');
|
12
scripts/sdk/test/index.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" class="hydrogen"></div>
|
||||||
|
<script type="module" src="./esm-entry.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
8
scripts/sdk/test/package.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"name": "test-sdk",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"hydrogen-view-sdk": "link:../../../target"
|
||||||
|
}
|
||||||
|
}
|
13
scripts/sdk/test/test-sdk-in-commonjs-env.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
// Make sure the SDK can be used in a CommonJS environment.
|
||||||
|
// Usage: node scripts/sdk/test/test-sdk-in-commonjs-env.js
|
||||||
|
const hydrogenViewSdk = require('hydrogen-view-sdk');
|
||||||
|
|
||||||
|
// Test that the "exports" are available:
|
||||||
|
// Worker
|
||||||
|
require.resolve('hydrogen-view-sdk/main.js');
|
||||||
|
// Styles
|
||||||
|
require.resolve('hydrogen-view-sdk/assets/theme-element-light.css');
|
||||||
|
// Can access files in the assets/* directory
|
||||||
|
require.resolve('hydrogen-view-sdk/assets/main.js');
|
||||||
|
|
||||||
|
console.log('SDK works in CommonJS ✅');
|
19
scripts/sdk/test/test-sdk-in-esm-vite-build-env.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
const { resolve } = require('path');
|
||||||
|
const { build } = require('vite');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await build({
|
||||||
|
outDir: './dist',
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, 'index.html')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('SDK works in Vite build ✅');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
|
@ -47,7 +47,7 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
|
||||||
this._options = options;
|
this._options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
childOptions<T extends Object>(explicitOptions: T): T & Options {
|
childOptions<T extends Object>(explicitOptions: T): T & O {
|
||||||
return Object.assign({}, this._options, explicitOptions);
|
return Object.assign({}, this._options, explicitOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,7 +115,7 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
emitChange(changedProps: any): void {
|
emitChange(changedProps?: any): void {
|
||||||
if (this._options.emitChange) {
|
if (this._options.emitChange) {
|
||||||
this._options.emitChange(changedProps);
|
this._options.emitChange(changedProps);
|
||||||
} else {
|
} else {
|
||||||
|
|
65
src/domain/rageshake.ts
Normal 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.
|
||||||
|
}
|
|
@ -20,15 +20,18 @@ import {getStreamVideoTrack, getStreamAudioTrack} from "../../../matrix/calls/co
|
||||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||||
import {EventObservableValue} from "../../../observable/value/EventObservableValue";
|
import {EventObservableValue} from "../../../observable/value/EventObservableValue";
|
||||||
import {ObservableValueMap} from "../../../observable/map/ObservableValueMap";
|
import {ObservableValueMap} from "../../../observable/map/ObservableValueMap";
|
||||||
|
import type {Room} from "../../../matrix/room/Room";
|
||||||
import type {GroupCall} from "../../../matrix/calls/group/GroupCall";
|
import type {GroupCall} from "../../../matrix/calls/group/GroupCall";
|
||||||
import type {Member} from "../../../matrix/calls/group/Member";
|
import type {Member} from "../../../matrix/calls/group/Member";
|
||||||
|
import type {RoomMember} from "../../../matrix/room/members/RoomMember";
|
||||||
import type {BaseObservableList} from "../../../observable/list/BaseObservableList";
|
import type {BaseObservableList} from "../../../observable/list/BaseObservableList";
|
||||||
|
import type {BaseObservableValue} from "../../../observable/value/BaseObservableValue";
|
||||||
import type {Stream} from "../../../platform/types/MediaDevices";
|
import type {Stream} from "../../../platform/types/MediaDevices";
|
||||||
import type {MediaRepository} from "../../../matrix/net/MediaRepository";
|
import type {MediaRepository} from "../../../matrix/net/MediaRepository";
|
||||||
|
|
||||||
type Options = BaseOptions & {
|
type Options = BaseOptions & {
|
||||||
call: GroupCall,
|
call: GroupCall,
|
||||||
mediaRepository: MediaRepository
|
room: Room,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CallViewModel extends ViewModel<Options> {
|
export class CallViewModel extends ViewModel<Options> {
|
||||||
|
@ -37,16 +40,35 @@ export class CallViewModel extends ViewModel<Options> {
|
||||||
constructor(options: Options) {
|
constructor(options: Options) {
|
||||||
super(options);
|
super(options);
|
||||||
const ownMemberViewModelMap = new ObservableValueMap("self", new EventObservableValue(this.call, "change"))
|
const ownMemberViewModelMap = new ObservableValueMap("self", new EventObservableValue(this.call, "change"))
|
||||||
.mapValues(call => new OwnMemberViewModel(this.childOptions({call: this.call, mediaRepository: this.getOption("mediaRepository")})), () => {});
|
.mapValues((call, emitChange) => new OwnMemberViewModel(this.childOptions({call, emitChange})), () => {});
|
||||||
this.memberViewModels = this.call.members
|
this.memberViewModels = this.call.members
|
||||||
.filterValues(member => member.isConnected)
|
.filterValues(member => member.isConnected)
|
||||||
.mapValues(member => new CallMemberViewModel(this.childOptions({member, mediaRepository: this.getOption("mediaRepository")})))
|
.mapValues(member => new CallMemberViewModel(this.childOptions({member, mediaRepository: this.getOption("room").mediaRepository})))
|
||||||
.join(ownMemberViewModelMap)
|
.join(ownMemberViewModelMap)
|
||||||
.sortValues((a, b) => a.compare(b));
|
.sortValues((a, b) => a.compare(b));
|
||||||
|
this.track(this.memberViewModels.subscribe({
|
||||||
|
onRemove: () => {
|
||||||
|
this.emitChange(); // update memberCount
|
||||||
|
},
|
||||||
|
onAdd: () => {
|
||||||
|
this.emitChange(); // update memberCount
|
||||||
|
},
|
||||||
|
onUpdate: () => {},
|
||||||
|
onReset: () => {},
|
||||||
|
onMove: () => {}
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
private get call(): GroupCall {
|
get isCameraMuted(): boolean {
|
||||||
return this.getOption("call");
|
return this.call.muteSettings?.camera ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMicrophoneMuted(): boolean {
|
||||||
|
return this.call.muteSettings?.microphone ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get memberCount(): number {
|
||||||
|
return this.memberViewModels.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
|
@ -57,29 +79,62 @@ export class CallViewModel extends ViewModel<Options> {
|
||||||
return this.call.id;
|
return this.call.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get stream(): Stream | undefined {
|
private get call(): GroupCall {
|
||||||
return this.call.localMedia?.userMedia;
|
return this.getOption("call");
|
||||||
}
|
}
|
||||||
|
|
||||||
leave() {
|
async hangup() {
|
||||||
if (this.call.hasJoined) {
|
if (this.call.hasJoined) {
|
||||||
this.call.leave();
|
await this.call.leave();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleVideo() {
|
async toggleCamera() {
|
||||||
if (this.call.muteSettings) {
|
const {localMedia, muteSettings} = this.call;
|
||||||
this.call.setMuted(this.call.muteSettings.toggleCamera());
|
if (muteSettings && localMedia) {
|
||||||
|
// unmute but no track?
|
||||||
|
if (muteSettings.camera && !getStreamVideoTrack(localMedia.userMedia)) {
|
||||||
|
const stream = await this.platform.mediaDevices.getMediaTracks(!muteSettings.microphone, true);
|
||||||
|
await this.call.setMedia(localMedia.withUserMedia(stream));
|
||||||
|
} else {
|
||||||
|
await this.call.setMuted(muteSettings.toggleCamera());
|
||||||
|
}
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleMicrophone() {
|
||||||
|
const {localMedia, muteSettings} = this.call;
|
||||||
|
if (muteSettings && localMedia) {
|
||||||
|
// unmute but no track?
|
||||||
|
if (muteSettings.microphone && !getStreamAudioTrack(localMedia.userMedia)) {
|
||||||
|
const stream = await this.platform.mediaDevices.getMediaTracks(true, !muteSettings.camera);
|
||||||
|
console.log("got tracks", Array.from(stream.getTracks()).map((t: MediaStreamTrack) => { return {kind: t.kind, id: t.id};}))
|
||||||
|
await this.call.setMedia(localMedia.withUserMedia(stream));
|
||||||
|
} else {
|
||||||
|
await this.call.setMuted(muteSettings.toggleMicrophone());
|
||||||
|
}
|
||||||
|
this.emitChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwnMemberOptions = BaseOptions & {
|
class OwnMemberViewModel extends ViewModel<Options> implements IStreamViewModel {
|
||||||
call: GroupCall,
|
private memberObservable: undefined | BaseObservableValue<RoomMember>;
|
||||||
mediaRepository: MediaRepository
|
|
||||||
}
|
constructor(options: Options) {
|
||||||
|
super(options);
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const room = this.getOption("room");
|
||||||
|
this.memberObservable = await room.observeMember(room.user.id);
|
||||||
|
this.track(this.memberObservable!.subscribe(() => {
|
||||||
|
this.emitChange(undefined);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
class OwnMemberViewModel extends ViewModel<OwnMemberOptions> implements IStreamViewModel {
|
|
||||||
get stream(): Stream | undefined {
|
get stream(): Stream | undefined {
|
||||||
return this.call.localMedia?.userMedia;
|
return this.call.localMedia?.userMedia;
|
||||||
}
|
}
|
||||||
|
@ -89,27 +144,40 @@ class OwnMemberViewModel extends ViewModel<OwnMemberOptions> implements IStreamV
|
||||||
}
|
}
|
||||||
|
|
||||||
get isCameraMuted(): boolean {
|
get isCameraMuted(): boolean {
|
||||||
return isMuted(this.call.muteSettings?.camera, !!getStreamVideoTrack(this.stream));
|
return this.call.muteSettings?.camera ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isMicrophoneMuted(): boolean {
|
get isMicrophoneMuted(): boolean {
|
||||||
return isMuted(this.call.muteSettings?.microphone, !!getStreamAudioTrack(this.stream));
|
return this.call.muteSettings?.microphone ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get avatarLetter(): string {
|
get avatarLetter(): string {
|
||||||
return "I";
|
const member = this.memberObservable?.get();
|
||||||
|
if (member) {
|
||||||
|
return avatarInitials(member.name);
|
||||||
|
} else {
|
||||||
|
return this.getOption("room").user.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get avatarColorNumber(): number {
|
get avatarColorNumber(): number {
|
||||||
return 3;
|
return getIdentifierColorNumber(this.getOption("room").user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
avatarUrl(size: number): string | undefined {
|
avatarUrl(size: number): string | undefined {
|
||||||
return undefined;
|
const member = this.memberObservable?.get();
|
||||||
|
if (member) {
|
||||||
|
return getAvatarHttpUrl(member.avatarUrl, size, this.platform, this.getOption("room").mediaRepository);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get avatarTitle(): string {
|
get avatarTitle(): string {
|
||||||
return "Me";
|
const member = this.memberObservable?.get();
|
||||||
|
if (member) {
|
||||||
|
return member.name;
|
||||||
|
} else {
|
||||||
|
return this.getOption("room").user.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compare(other: OwnMemberViewModel | CallMemberViewModel): number {
|
compare(other: OwnMemberViewModel | CallMemberViewModel): number {
|
||||||
|
@ -117,7 +185,10 @@ class OwnMemberViewModel extends ViewModel<OwnMemberOptions> implements IStreamV
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemberOptions = BaseOptions & {member: Member, mediaRepository: MediaRepository};
|
type MemberOptions = BaseOptions & {
|
||||||
|
member: Member,
|
||||||
|
mediaRepository: MediaRepository
|
||||||
|
};
|
||||||
|
|
||||||
export class CallMemberViewModel extends ViewModel<MemberOptions> implements IStreamViewModel {
|
export class CallMemberViewModel extends ViewModel<MemberOptions> implements IStreamViewModel {
|
||||||
get stream(): Stream | undefined {
|
get stream(): Stream | undefined {
|
||||||
|
@ -129,11 +200,11 @@ export class CallMemberViewModel extends ViewModel<MemberOptions> implements ISt
|
||||||
}
|
}
|
||||||
|
|
||||||
get isCameraMuted(): boolean {
|
get isCameraMuted(): boolean {
|
||||||
return isMuted(this.member.remoteMuteSettings?.camera, !!getStreamVideoTrack(this.stream));
|
return this.member.remoteMuteSettings?.camera ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isMicrophoneMuted(): boolean {
|
get isMicrophoneMuted(): boolean {
|
||||||
return isMuted(this.member.remoteMuteSettings?.microphone, !!getStreamAudioTrack(this.stream));
|
return this.member.remoteMuteSettings?.microphone ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get avatarLetter(): string {
|
get avatarLetter(): string {
|
||||||
|
@ -172,11 +243,3 @@ export interface IStreamViewModel extends AvatarSource, ViewModel {
|
||||||
get isCameraMuted(): boolean;
|
get isCameraMuted(): boolean;
|
||||||
get isMicrophoneMuted(): boolean;
|
get isMicrophoneMuted(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMuted(muted: boolean | undefined, hasTrack: boolean) {
|
|
||||||
if (muted) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return !hasTrack;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -62,13 +62,14 @@ export class RoomViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
this._callViewModel = this.disposeTracked(this._callViewModel);
|
this._callViewModel = this.disposeTracked(this._callViewModel);
|
||||||
if (call) {
|
if (call) {
|
||||||
this._callViewModel = this.track(new CallViewModel(this.childOptions({call, mediaRepository: this._room.mediaRepository})));
|
this._callViewModel = this.track(new CallViewModel(this.childOptions({call, room: this._room})));
|
||||||
}
|
}
|
||||||
this.emitChange("callViewModel");
|
this.emitChange("callViewModel");
|
||||||
}));
|
}));
|
||||||
const call = this._callObservable.get();
|
const call = this._callObservable.get();
|
||||||
|
// TODO: cleanup this duplication to create CallViewModel
|
||||||
if (call) {
|
if (call) {
|
||||||
this._callViewModel = new CallViewModel(this.childOptions({call}));
|
this._callViewModel = this.track(new CallViewModel(this.childOptions({call, room: this._room})));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
@ -50,6 +51,8 @@ export class SettingsViewModel extends ViewModel {
|
||||||
this.minSentImageSizeLimit = 400;
|
this.minSentImageSizeLimit = 400;
|
||||||
this.maxSentImageSizeLimit = 4000;
|
this.maxSentImageSizeLimit = 4000;
|
||||||
this.pushNotifications = new PushNotificationStatus();
|
this.pushNotifications = new PushNotificationStatus();
|
||||||
|
this._activeTheme = undefined;
|
||||||
|
this._logsFeedbackMessage = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
get _session() {
|
get _session() {
|
||||||
|
@ -76,6 +79,9 @@ export class SettingsViewModel extends ViewModel {
|
||||||
this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
|
this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
|
||||||
this.pushNotifications.supported = await this.platform.notificationService.supportsPush();
|
this.pushNotifications.supported = await this.platform.notificationService.supportsPush();
|
||||||
this.pushNotifications.enabled = await this._session.arePushNotificationsEnabled();
|
this.pushNotifications.enabled = await this._session.arePushNotificationsEnabled();
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
this._activeTheme = await this.platform.themeLoader.getActiveTheme();
|
||||||
|
}
|
||||||
this.emitChange("");
|
this.emitChange("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,6 +133,14 @@ export class SettingsViewModel extends ViewModel {
|
||||||
return this._formatBytes(this._estimate?.usage);
|
return this._formatBytes(this._estimate?.usage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get themeMapping() {
|
||||||
|
return this.platform.themeLoader.themeMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeTheme() {
|
||||||
|
return this._activeTheme;
|
||||||
|
}
|
||||||
|
|
||||||
_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";
|
||||||
|
@ -146,6 +160,51 @@ export class SettingsViewModel extends ViewModel {
|
||||||
return logExport.asBlob();
|
return logExport.asBlob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
@ -175,5 +234,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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -159,6 +159,11 @@ export class Logger implements ILogger {
|
||||||
this._openItems.clear();
|
this._openItems.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_removeItemFromOpenList(item: LogItem): void {
|
||||||
|
this._openItems.delete(item);
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
_persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void {
|
_persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void {
|
||||||
for (var i = 0; i < this.reporters.length; i += 1) {
|
for (var i = 0; i < this.reporters.length; i += 1) {
|
||||||
|
@ -186,6 +191,7 @@ class DeferredPersistRootLogItem extends LogItem {
|
||||||
finish() {
|
finish() {
|
||||||
super.finish();
|
super.finish();
|
||||||
(this._logger as Logger)._persistItem(this, undefined, false);
|
(this._logger as Logger)._persistItem(this, undefined, false);
|
||||||
|
(this._logger as Logger)._removeItemFromOpenList(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
forceFinish() {
|
forceFinish() {
|
||||||
|
|
|
@ -38,7 +38,6 @@ export class DeviceMessageHandler {
|
||||||
|
|
||||||
async prepareSync(toDeviceEvents, lock, txn, log) {
|
async prepareSync(toDeviceEvents, lock, txn, log) {
|
||||||
log.set("messageTypes", countBy(toDeviceEvents, e => e.type));
|
log.set("messageTypes", countBy(toDeviceEvents, e => e.type));
|
||||||
this._handleUnencryptedCallEvents(toDeviceEvents, log);
|
|
||||||
const encryptedEvents = toDeviceEvents.filter(e => e.type === "m.room.encrypted");
|
const encryptedEvents = toDeviceEvents.filter(e => e.type === "m.room.encrypted");
|
||||||
if (!this._olmDecryption) {
|
if (!this._olmDecryption) {
|
||||||
log.log("can't decrypt, encryption not enabled", log.level.Warn);
|
log.log("can't decrypt, encryption not enabled", log.level.Warn);
|
||||||
|
@ -54,20 +53,6 @@ export class DeviceMessageHandler {
|
||||||
}
|
}
|
||||||
const newRoomKeys = this._megolmDecryption.roomKeysFromDeviceMessages(olmDecryptChanges.results, log);
|
const newRoomKeys = this._megolmDecryption.roomKeysFromDeviceMessages(olmDecryptChanges.results, log);
|
||||||
|
|
||||||
// const callMessages = olmDecryptChanges.results.filter(dr => this._callHandler.handlesDeviceMessageEventType(dr.event?.type));
|
|
||||||
// // load devices by sender key
|
|
||||||
// await Promise.all(callMessages.map(async dr => {
|
|
||||||
// dr.setDevice(await this._getDevice(dr.senderCurve25519Key, txn));
|
|
||||||
// }));
|
|
||||||
// // TODO: pass this in the prep and run it in afterSync or afterSyncComplete (as callHandler can send events as well)?
|
|
||||||
// for (const dr of callMessages) {
|
|
||||||
// if (dr.device) {
|
|
||||||
// this._callHandler.handleDeviceMessage(dr.event, dr.device.userId, dr.device.deviceId, log);
|
|
||||||
// } else {
|
|
||||||
// console.error("could not deliver message because don't have device for sender key", dr.event);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO: somehow include rooms that received a call to_device message in the sync state?
|
// TODO: somehow include rooms that received a call to_device message in the sync state?
|
||||||
// or have updates flow through event emitter?
|
// or have updates flow through event emitter?
|
||||||
// well, we don't really need to update the room other then when a call starts or stops
|
// well, we don't really need to update the room other then when a call starts or stops
|
||||||
|
@ -76,33 +61,43 @@ export class DeviceMessageHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleUnencryptedCallEvents(toDeviceEvents, log) {
|
|
||||||
const callMessages = toDeviceEvents.filter(e => this._callHandler.handlesDeviceMessageEventType(e.type));
|
|
||||||
for (const event of callMessages) {
|
|
||||||
const userId = event.sender;
|
|
||||||
const deviceId = event.content.device_id;
|
|
||||||
this._callHandler.handleDeviceMessage(event, userId, deviceId, log);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** check that prep is not undefined before calling this */
|
/** check that prep is not undefined before calling this */
|
||||||
async writeSync(prep, txn) {
|
async writeSync(prep, txn) {
|
||||||
// write olm changes
|
// write olm changes
|
||||||
prep.olmDecryptChanges.write(txn);
|
prep.olmDecryptChanges.write(txn);
|
||||||
const didWriteValues = await Promise.all(prep.newRoomKeys.map(key => this._megolmDecryption.writeRoomKey(key, txn)));
|
const didWriteValues = await Promise.all(prep.newRoomKeys.map(key => this._megolmDecryption.writeRoomKey(key, txn)));
|
||||||
return didWriteValues.some(didWrite => !!didWrite);
|
const hasNewRoomKeys = didWriteValues.some(didWrite => !!didWrite);
|
||||||
|
return {
|
||||||
|
hasNewRoomKeys,
|
||||||
|
decryptionResults: prep.olmDecryptChanges.results
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async afterSyncCompleted(decryptionResults, deviceTracker, hsApi, log) {
|
||||||
async _getDevice(senderKey, txn) {
|
// if we don't have a device, we need to fetch the device keys the message claims
|
||||||
let device = this._senderDeviceCache.get(senderKey);
|
// and check the keys, and we should only do network requests during
|
||||||
if (!device) {
|
// sync processing in the afterSyncCompleted step.
|
||||||
device = await txn.deviceIdentities.getByCurve25519Key(senderKey);
|
const callMessages = decryptionResults.filter(dr => this._callHandler.handlesDeviceMessageEventType(dr.event?.type));
|
||||||
if (device) {
|
if (callMessages.length) {
|
||||||
this._senderDeviceCache.set(device);
|
await log.wrap("process call signalling messages", async log => {
|
||||||
}
|
for (const dr of callMessages) {
|
||||||
|
// serialize device loading, so subsequent messages for the same device take advantage of the cache
|
||||||
|
const device = await deviceTracker.deviceForId(dr.event.sender, dr.event.content.device_id, hsApi, log);
|
||||||
|
dr.setDevice(device);
|
||||||
|
if (dr.isVerified) {
|
||||||
|
this._callHandler.handleDeviceMessage(dr.event, dr.userId, dr.deviceId, log);
|
||||||
|
} else {
|
||||||
|
log.log({
|
||||||
|
l: "could not verify olm fingerprint key matches, ignoring",
|
||||||
|
ed25519Key: dr.device.ed25519Key,
|
||||||
|
claimedEd25519Key: dr.claimedEd25519Key,
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
userId: device.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return device;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -79,18 +79,22 @@ export class Session {
|
||||||
this._callHandler = new CallHandler({
|
this._callHandler = new CallHandler({
|
||||||
clock: this._platform.clock,
|
clock: this._platform.clock,
|
||||||
hsApi: this._hsApi,
|
hsApi: this._hsApi,
|
||||||
encryptDeviceMessage: async (roomId, userId, message, log) => {
|
encryptDeviceMessage: async (roomId, userId, deviceId, message, log) => {
|
||||||
if (!this._deviceTracker || !this._olmEncryption) {
|
if (!this._deviceTracker || !this._olmEncryption) {
|
||||||
throw new Error("encryption is not enabled");
|
log.set("encryption_disabled", true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// TODO: just get the devices we're sending the message to, not all the room devices
|
const device = await log.wrap("get device key", async log => {
|
||||||
// although we probably already fetched all devices to send messages in the likely e2ee room
|
const device = this._deviceTracker.deviceForId(userId, deviceId, this._hsApi, log);
|
||||||
const devices = await log.wrap("get device keys", async log => {
|
if (!device) {
|
||||||
await this._deviceTracker.trackRoom(this.rooms.get(roomId), log);
|
log.set("not_found", true);
|
||||||
return this._deviceTracker.devicesForRoomMembers(roomId, [userId], this._hsApi, log);
|
}
|
||||||
|
return device;
|
||||||
});
|
});
|
||||||
const encryptedMessage = await this._olmEncryption.encrypt(message.type, message.content, devices, this._hsApi, log);
|
if (device) {
|
||||||
return encryptedMessage;
|
const encryptedMessages = await this._olmEncryption.encrypt(message.type, message.content, [device], this._hsApi, log);
|
||||||
|
return encryptedMessages;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
storage: this._storage,
|
storage: this._storage,
|
||||||
webRTC: this._platform.webRTC,
|
webRTC: this._platform.webRTC,
|
||||||
|
@ -693,7 +697,9 @@ export class Session {
|
||||||
async writeSync(syncResponse, syncFilterId, preparation, txn, log) {
|
async writeSync(syncResponse, syncFilterId, preparation, txn, log) {
|
||||||
const changes = {
|
const changes = {
|
||||||
syncInfo: null,
|
syncInfo: null,
|
||||||
e2eeAccountChanges: null
|
e2eeAccountChanges: null,
|
||||||
|
hasNewRoomKeys: false,
|
||||||
|
deviceMessageDecryptionResults: null,
|
||||||
};
|
};
|
||||||
const syncToken = syncResponse.next_batch;
|
const syncToken = syncResponse.next_batch;
|
||||||
if (syncToken !== this.syncToken) {
|
if (syncToken !== this.syncToken) {
|
||||||
|
@ -714,7 +720,9 @@ export class Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preparation) {
|
if (preparation) {
|
||||||
changes.hasNewRoomKeys = await log.wrap("deviceMsgs", log => this._deviceMessageHandler.writeSync(preparation, txn, log));
|
const {hasNewRoomKeys, decryptionResults} = await log.wrap("deviceMsgs", log => this._deviceMessageHandler.writeSync(preparation, txn, log));
|
||||||
|
changes.hasNewRoomKeys = hasNewRoomKeys;
|
||||||
|
changes.deviceMessageDecryptionResults = decryptionResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
// store account data
|
// store account data
|
||||||
|
@ -755,6 +763,9 @@ export class Session {
|
||||||
if (changes.hasNewRoomKeys) {
|
if (changes.hasNewRoomKeys) {
|
||||||
this._keyBackup.get()?.flush(log);
|
this._keyBackup.get()?.flush(log);
|
||||||
}
|
}
|
||||||
|
if (changes.deviceMessageDecryptionResults) {
|
||||||
|
await this._deviceMessageHandler.afterSyncCompleted(changes.deviceMessageDecryptionResults, this._deviceTracker, this._hsApi, log);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_tryReplaceRoomBeingCreated(roomId, log) {
|
_tryReplaceRoomBeingCreated(roomId, log) {
|
||||||
|
|
|
@ -160,7 +160,7 @@ export class Sync {
|
||||||
const isCatchupSync = this._status.get() === SyncStatus.CatchupSync;
|
const isCatchupSync = this._status.get() === SyncStatus.CatchupSync;
|
||||||
const sessionPromise = (async () => {
|
const sessionPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
await log.wrap("session", log => this._session.afterSyncCompleted(sessionChanges, isCatchupSync, log), log.level.Detail);
|
await log.wrap("session", log => this._session.afterSyncCompleted(sessionChanges, isCatchupSync, log));
|
||||||
} catch (err) {} // error is logged, but don't fail sessionPromise
|
} catch (err) {} // error is logged, but don't fail sessionPromise
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {EventType, CallIntent} from "./callEventTypes";
|
||||||
import {GroupCall} from "./group/GroupCall";
|
import {GroupCall} from "./group/GroupCall";
|
||||||
import {makeId} from "../common";
|
import {makeId} from "../common";
|
||||||
import {CALL_LOG_TYPE} from "./common";
|
import {CALL_LOG_TYPE} from "./common";
|
||||||
|
import {EVENT_TYPE as MEMBER_EVENT_TYPE, RoomMember} from "../room/members/RoomMember";
|
||||||
|
|
||||||
import type {LocalMedia} from "./LocalMedia";
|
import type {LocalMedia} from "./LocalMedia";
|
||||||
import type {Room} from "../room/Room";
|
import type {Room} from "../room/Room";
|
||||||
|
@ -36,6 +37,7 @@ import type {Transaction} from "../storage/idb/Transaction";
|
||||||
import type {CallEntry} from "../storage/idb/stores/CallStore";
|
import type {CallEntry} from "../storage/idb/stores/CallStore";
|
||||||
import type {Clock} from "../../platform/web/dom/Clock";
|
import type {Clock} from "../../platform/web/dom/Clock";
|
||||||
import type {RoomStateHandler} from "../room/state/types";
|
import type {RoomStateHandler} from "../room/state/types";
|
||||||
|
import type {MemberSync} from "../room/timeline/persistence/MemberWriter";
|
||||||
|
|
||||||
export type Options = Omit<GroupCallOptions, "emitUpdate" | "createTimeout"> & {
|
export type Options = Omit<GroupCallOptions, "emitUpdate" | "createTimeout"> & {
|
||||||
clock: Clock
|
clock: Clock
|
||||||
|
@ -77,7 +79,7 @@ export class CallHandler implements RoomStateHandler {
|
||||||
const names = this.options.storage.storeNames;
|
const names = this.options.storage.storeNames;
|
||||||
const txn = await this.options.storage.readTxn([
|
const txn = await this.options.storage.readTxn([
|
||||||
names.calls,
|
names.calls,
|
||||||
names.roomState
|
names.roomState,
|
||||||
]);
|
]);
|
||||||
return txn;
|
return txn;
|
||||||
}
|
}
|
||||||
|
@ -97,15 +99,22 @@ export class CallHandler implements RoomStateHandler {
|
||||||
}));
|
}));
|
||||||
const roomIds = Array.from(new Set(callEntries.map(e => e.roomId)));
|
const roomIds = Array.from(new Set(callEntries.map(e => e.roomId)));
|
||||||
await Promise.all(roomIds.map(async roomId => {
|
await Promise.all(roomIds.map(async roomId => {
|
||||||
// const ownCallsMemberEvent = await txn.roomState.get(roomId, EventType.GroupCallMember, this.options.ownUserId);
|
// TODO: don't load all members until we need them
|
||||||
// if (ownCallsMemberEvent) {
|
|
||||||
// this.handleCallMemberEvent(ownCallsMemberEvent.event, log);
|
|
||||||
// }
|
|
||||||
const callsMemberEvents = await txn.roomState.getAllForType(roomId, EventType.GroupCallMember);
|
const callsMemberEvents = await txn.roomState.getAllForType(roomId, EventType.GroupCallMember);
|
||||||
for (const entry of callsMemberEvents) {
|
await Promise.all(callsMemberEvents.map(async entry => {
|
||||||
this.handleCallMemberEvent(entry.event, roomId, log);
|
const userId = entry.event.sender;
|
||||||
}
|
const roomMemberState = await txn.roomState.get(roomId, MEMBER_EVENT_TYPE, userId);
|
||||||
// TODO: we should be loading the other members as well at some point
|
let roomMember;
|
||||||
|
if (roomMemberState) {
|
||||||
|
roomMember = RoomMember.fromMemberEvent(roomMemberState.event);
|
||||||
|
}
|
||||||
|
if (!roomMember) {
|
||||||
|
// we'll be missing the member here if we received a call and it's members
|
||||||
|
// as pre-gap state and the members weren't active in the timeline we got.
|
||||||
|
roomMember = RoomMember.fromUserId(roomId, userId, "join");
|
||||||
|
}
|
||||||
|
this.handleCallMemberEvent(entry.event, roomMember, roomId, log);
|
||||||
|
}));
|
||||||
}));
|
}));
|
||||||
log.set("newSize", this._calls.size);
|
log.set("newSize", this._calls.size);
|
||||||
});
|
});
|
||||||
|
@ -144,12 +153,18 @@ export class CallHandler implements RoomStateHandler {
|
||||||
// TODO: check and poll turn server credentials here
|
// TODO: check and poll turn server credentials here
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
handleRoomState(room: Room, event: StateEvent, txn: Transaction, log: ILogItem) {
|
async handleRoomState(room: Room, event: StateEvent, memberSync: MemberSync, txn: Transaction, log: ILogItem) {
|
||||||
if (event.type === EventType.GroupCall) {
|
if (event.type === EventType.GroupCall) {
|
||||||
this.handleCallEvent(event, room.id, txn, log);
|
this.handleCallEvent(event, room.id, txn, log);
|
||||||
}
|
}
|
||||||
if (event.type === EventType.GroupCallMember) {
|
if (event.type === EventType.GroupCallMember) {
|
||||||
this.handleCallMemberEvent(event, room.id, log);
|
let member = await memberSync.lookupMemberAtEvent(event.sender, event, txn);
|
||||||
|
if (!member) {
|
||||||
|
// we'll be missing the member here if we received a call and it's members
|
||||||
|
// as pre-gap state and the members weren't active in the timeline we got.
|
||||||
|
member = RoomMember.fromUserId(room.id, event.sender, "join");
|
||||||
|
}
|
||||||
|
this.handleCallMemberEvent(event, member, room.id, log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,6 +172,11 @@ export class CallHandler implements RoomStateHandler {
|
||||||
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>) {
|
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>) {
|
||||||
// TODO: also have map for roomId to calls, so we can easily update members
|
// TODO: also have map for roomId to calls, so we can easily update members
|
||||||
// we will also need this to get the call for a room
|
// we will also need this to get the call for a room
|
||||||
|
for (const call of this._calls.values()) {
|
||||||
|
if (call.roomId === room.id) {
|
||||||
|
call.updateRoomMembers(memberChanges);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
|
@ -193,7 +213,7 @@ export class CallHandler implements RoomStateHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCallMemberEvent(event: StateEvent, roomId: string, log: ILogItem) {
|
private handleCallMemberEvent(event: StateEvent, member: RoomMember, roomId: string, log: ILogItem) {
|
||||||
const userId = event.state_key;
|
const userId = event.state_key;
|
||||||
const roomMemberKey = getRoomMemberKey(roomId, userId)
|
const roomMemberKey = getRoomMemberKey(roomId, userId)
|
||||||
const calls = event.content["m.calls"] ?? [];
|
const calls = event.content["m.calls"] ?? [];
|
||||||
|
@ -202,7 +222,7 @@ export class CallHandler implements RoomStateHandler {
|
||||||
const callId = call["m.call_id"];
|
const callId = call["m.call_id"];
|
||||||
const groupCall = this._calls.get(callId);
|
const groupCall = this._calls.get(callId);
|
||||||
// TODO: also check the member when receiving the m.call event
|
// TODO: also check the member when receiving the m.call event
|
||||||
groupCall?.updateMembership(userId, call, eventTimestamp, log);
|
groupCall?.updateMembership(userId, member, call, eventTimestamp, log);
|
||||||
};
|
};
|
||||||
const newCallIdsMemberOf = new Set<string>(calls.map(call => call["m.call_id"]));
|
const newCallIdsMemberOf = new Set<string>(calls.map(call => call["m.call_id"]));
|
||||||
let previousCallIdsMemberOf = this.roomMemberToCallIds.get(roomMemberKey);
|
let previousCallIdsMemberOf = this.roomMemberToCallIds.get(roomMemberKey);
|
||||||
|
|
|
@ -40,18 +40,13 @@ export class LocalMedia {
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
replaceClone(oldClone: LocalMedia | undefined, oldOriginal: LocalMedia | undefined): LocalMedia {
|
replaceClone(oldClone: LocalMedia | undefined, oldOriginal: LocalMedia | undefined): LocalMedia {
|
||||||
let userMedia;
|
|
||||||
let screenShare;
|
|
||||||
const cloneOrAdoptStream = (oldOriginalStream: Stream | undefined, oldCloneStream: Stream | undefined, newStream: Stream | undefined): Stream | undefined => {
|
const cloneOrAdoptStream = (oldOriginalStream: Stream | undefined, oldCloneStream: Stream | undefined, newStream: Stream | undefined): Stream | undefined => {
|
||||||
let stream;
|
let stream;
|
||||||
if (oldOriginalStream?.id === newStream?.id) {
|
if (oldOriginalStream?.id === newStream?.id) {
|
||||||
stream = oldCloneStream;
|
return oldCloneStream;
|
||||||
} else {
|
} else {
|
||||||
stream = newStream?.clone();
|
return newStream?.clone();
|
||||||
getStreamAudioTrack(oldCloneStream)?.stop();
|
|
||||||
getStreamVideoTrack(oldCloneStream)?.stop();
|
|
||||||
}
|
}
|
||||||
return stream;
|
|
||||||
}
|
}
|
||||||
return new LocalMedia(
|
return new LocalMedia(
|
||||||
cloneOrAdoptStream(oldOriginal?.userMedia, oldClone?.userMedia, this.userMedia),
|
cloneOrAdoptStream(oldOriginal?.userMedia, oldClone?.userMedia, this.userMedia),
|
||||||
|
@ -66,16 +61,8 @@ export class LocalMedia {
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.stopExcept(undefined);
|
getStreamAudioTrack(this.userMedia)?.stop();
|
||||||
}
|
getStreamVideoTrack(this.userMedia)?.stop();
|
||||||
|
getStreamVideoTrack(this.screenShare)?.stop();
|
||||||
stopExcept(newMedia: LocalMedia | undefined) {
|
|
||||||
if(newMedia?.userMedia?.id !== this.userMedia?.id) {
|
|
||||||
getStreamAudioTrack(this.userMedia)?.stop();
|
|
||||||
getStreamVideoTrack(this.userMedia)?.stop();
|
|
||||||
}
|
|
||||||
if(newMedia?.screenShare?.id !== this.screenShare?.id) {
|
|
||||||
getStreamVideoTrack(this.screenShare)?.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,7 +71,6 @@ export class PeerCall implements IDisposable {
|
||||||
private localMedia?: LocalMedia;
|
private localMedia?: LocalMedia;
|
||||||
private localMuteSettings?: MuteSettings;
|
private localMuteSettings?: MuteSettings;
|
||||||
// TODO: this should go in member
|
// TODO: this should go in member
|
||||||
private seq: number = 0;
|
|
||||||
// A queue for candidates waiting to go out.
|
// A queue for candidates waiting to go out.
|
||||||
// We try to amalgamate candidates into a single candidate message where
|
// We try to amalgamate candidates into a single candidate message where
|
||||||
// possible
|
// possible
|
||||||
|
@ -178,6 +177,7 @@ export class PeerCall implements IDisposable {
|
||||||
if (this._state !== CallState.Fledgling) {
|
if (this._state !== CallState.Fledgling) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
log.set("signalingState", this.peerConnection.signalingState);
|
||||||
this.direction = CallDirection.Outbound;
|
this.direction = CallDirection.Outbound;
|
||||||
this.setState(CallState.CreateOffer, log);
|
this.setState(CallState.CreateOffer, log);
|
||||||
this.localMuteSettings = localMuteSettings;
|
this.localMuteSettings = localMuteSettings;
|
||||||
|
@ -238,7 +238,6 @@ export class PeerCall implements IDisposable {
|
||||||
const content: MCallSDPStreamMetadataChanged<MCallBase> = {
|
const content: MCallSDPStreamMetadataChanged<MCallBase> = {
|
||||||
call_id: this.callId,
|
call_id: this.callId,
|
||||||
version: 1,
|
version: 1,
|
||||||
seq: this.seq++,
|
|
||||||
[SDPStreamMetadataKey]: this.getSDPMetadata()
|
[SDPStreamMetadataKey]: this.getSDPMetadata()
|
||||||
};
|
};
|
||||||
await this.sendSignallingMessage({type: EventType.SDPStreamMetadataChangedPrefix, content}, log);
|
await this.sendSignallingMessage({type: EventType.SDPStreamMetadataChangedPrefix, content}, log);
|
||||||
|
@ -263,7 +262,6 @@ export class PeerCall implements IDisposable {
|
||||||
const content: MCallSDPStreamMetadataChanged<MCallBase> = {
|
const content: MCallSDPStreamMetadataChanged<MCallBase> = {
|
||||||
call_id: this.callId,
|
call_id: this.callId,
|
||||||
version: 1,
|
version: 1,
|
||||||
seq: this.seq++,
|
|
||||||
[SDPStreamMetadataKey]: this.getSDPMetadata()
|
[SDPStreamMetadataKey]: this.getSDPMetadata()
|
||||||
};
|
};
|
||||||
await this.sendSignallingMessage({type: EventType.SDPStreamMetadataChangedPrefix, content}, log);
|
await this.sendSignallingMessage({type: EventType.SDPStreamMetadataChangedPrefix, content}, log);
|
||||||
|
@ -312,35 +310,39 @@ export class PeerCall implements IDisposable {
|
||||||
}, async log => {
|
}, async log => {
|
||||||
logItem = log;
|
logItem = log;
|
||||||
|
|
||||||
switch (message.type) {
|
const callIdMatches = this.callId === message.content.call_id;
|
||||||
case EventType.Invite:
|
|
||||||
if (this.callId !== message.content.call_id) {
|
if (message.type === EventType.Invite && !callIdMatches) {
|
||||||
await this.handleInviteGlare(message.content, partyId, log);
|
await this.handleInviteGlare(message.content, partyId, log);
|
||||||
} else {
|
} else if (callIdMatches) {
|
||||||
|
switch (message.type) {
|
||||||
|
case EventType.Invite:
|
||||||
await this.handleFirstInvite(message.content, partyId, log);
|
await this.handleFirstInvite(message.content, partyId, log);
|
||||||
}
|
break;
|
||||||
break;
|
case EventType.Answer:
|
||||||
case EventType.Answer:
|
await this.handleAnswer(message.content, partyId, log);
|
||||||
await this.handleAnswer(message.content, partyId, log);
|
break;
|
||||||
break;
|
case EventType.Negotiate:
|
||||||
case EventType.Negotiate:
|
await this.onNegotiateReceived(message.content, log);
|
||||||
await this.onNegotiateReceived(message.content, log);
|
break;
|
||||||
break;
|
case EventType.Candidates:
|
||||||
case EventType.Candidates:
|
await this.handleRemoteIceCandidates(message.content, partyId, log);
|
||||||
await this.handleRemoteIceCandidates(message.content, partyId, log);
|
break;
|
||||||
break;
|
case EventType.SDPStreamMetadataChanged:
|
||||||
case EventType.SDPStreamMetadataChanged:
|
case EventType.SDPStreamMetadataChangedPrefix:
|
||||||
case EventType.SDPStreamMetadataChangedPrefix:
|
this.updateRemoteSDPStreamMetadata(message.content[SDPStreamMetadataKey], log);
|
||||||
this.updateRemoteSDPStreamMetadata(message.content[SDPStreamMetadataKey], log);
|
break;
|
||||||
break;
|
case EventType.Hangup:
|
||||||
case EventType.Hangup:
|
// TODO: this is a bit hacky, double check its what we need
|
||||||
// TODO: this is a bit hacky, double check its what we need
|
log.set("reason", message.content.reason);
|
||||||
log.set("reason", message.content.reason);
|
this.terminate(CallParty.Remote, message.content.reason ?? CallErrorCode.UserHangup, log);
|
||||||
this.terminate(CallParty.Remote, message.content.reason ?? CallErrorCode.UserHangup, log);
|
break;
|
||||||
break;
|
default:
|
||||||
default:
|
log.log(`Unknown event type for call: ${message.type}`);
|
||||||
log.log(`Unknown event type for call: ${message.type}`);
|
break;
|
||||||
break;
|
}
|
||||||
|
} else if (!callIdMatches) {
|
||||||
|
log.set("wrongCallId", true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return logItem;
|
return logItem;
|
||||||
|
@ -350,7 +352,6 @@ export class PeerCall implements IDisposable {
|
||||||
const content = {
|
const content = {
|
||||||
call_id: callId,
|
call_id: callId,
|
||||||
version: 1,
|
version: 1,
|
||||||
seq: this.seq++,
|
|
||||||
};
|
};
|
||||||
// TODO: Don't send UserHangup reason to older clients
|
// TODO: Don't send UserHangup reason to older clients
|
||||||
if (reason) {
|
if (reason) {
|
||||||
|
@ -387,8 +388,7 @@ export class PeerCall implements IDisposable {
|
||||||
const offer = this.peerConnection.localDescription!;
|
const offer = this.peerConnection.localDescription!;
|
||||||
// Get rid of any candidates waiting to be sent: they'll be included in the local
|
// Get rid of any candidates waiting to be sent: they'll be included in the local
|
||||||
// description we just got and will send in the offer.
|
// description we just got and will send in the offer.
|
||||||
log.log(`Discarding ${
|
log.set("includedCandidates", this.candidateSendQueue.length);
|
||||||
this.candidateSendQueue.length} candidates that will be sent in offer`);
|
|
||||||
this.candidateSendQueue = [];
|
this.candidateSendQueue = [];
|
||||||
|
|
||||||
// need to queue this
|
// need to queue this
|
||||||
|
@ -398,7 +398,6 @@ export class PeerCall implements IDisposable {
|
||||||
offer,
|
offer,
|
||||||
[SDPStreamMetadataKey]: this.getSDPMetadata(),
|
[SDPStreamMetadataKey]: this.getSDPMetadata(),
|
||||||
version: 1,
|
version: 1,
|
||||||
seq: this.seq++,
|
|
||||||
lifetime: CALL_TIMEOUT_MS
|
lifetime: CALL_TIMEOUT_MS
|
||||||
};
|
};
|
||||||
await this.sendSignallingMessage({type: EventType.Invite, content}, log);
|
await this.sendSignallingMessage({type: EventType.Invite, content}, log);
|
||||||
|
@ -409,7 +408,6 @@ export class PeerCall implements IDisposable {
|
||||||
description: offer,
|
description: offer,
|
||||||
[SDPStreamMetadataKey]: this.getSDPMetadata(),
|
[SDPStreamMetadataKey]: this.getSDPMetadata(),
|
||||||
version: 1,
|
version: 1,
|
||||||
seq: this.seq++,
|
|
||||||
lifetime: CALL_TIMEOUT_MS
|
lifetime: CALL_TIMEOUT_MS
|
||||||
};
|
};
|
||||||
await this.sendSignallingMessage({type: EventType.Negotiate, content}, log);
|
await this.sendSignallingMessage({type: EventType.Negotiate, content}, log);
|
||||||
|
@ -420,16 +418,20 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
this.sendCandidateQueue(log);
|
this.sendCandidateQueue(log);
|
||||||
|
|
||||||
await log.wrap("invite timeout", async log => {
|
if (this._state === CallState.InviteSent) {
|
||||||
if (this._state === CallState.InviteSent) {
|
const timeoutLog = this.logItem.child("invite timeout");
|
||||||
|
log.refDetached(timeoutLog);
|
||||||
|
// don't await this, as it would block other negotationneeded events from being processed
|
||||||
|
// as they are processed serially
|
||||||
|
timeoutLog.run(async log => {
|
||||||
try { await this.delay(CALL_TIMEOUT_MS); }
|
try { await this.delay(CALL_TIMEOUT_MS); }
|
||||||
catch (err) { return; }
|
catch (err) { return; }
|
||||||
// @ts-ignore TS doesn't take the await above into account to know that the state could have changed in between
|
// @ts-ignore TS doesn't take the await above into account to know that the state could have changed in between
|
||||||
if (this._state === CallState.InviteSent) {
|
if (this._state === CallState.InviteSent) {
|
||||||
this._hangup(CallErrorCode.InviteTimeout, log);
|
this._hangup(CallErrorCode.InviteTimeout, log);
|
||||||
}
|
}
|
||||||
}
|
}).catch(err => {}); // prevent error from being unhandled, it will be logged already by run above
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private async handleInviteGlare(content: MCallInvite<MCallBase>, partyId: PartyId, log: ILogItem): Promise<void> {
|
private async handleInviteGlare(content: MCallInvite<MCallBase>, partyId: PartyId, log: ILogItem): Promise<void> {
|
||||||
|
@ -674,7 +676,6 @@ export class PeerCall implements IDisposable {
|
||||||
description: this.peerConnection.localDescription!,
|
description: this.peerConnection.localDescription!,
|
||||||
[SDPStreamMetadataKey]: this.getSDPMetadata(),
|
[SDPStreamMetadataKey]: this.getSDPMetadata(),
|
||||||
version: 1,
|
version: 1,
|
||||||
seq: this.seq++,
|
|
||||||
lifetime: CALL_TIMEOUT_MS
|
lifetime: CALL_TIMEOUT_MS
|
||||||
};
|
};
|
||||||
await this.sendSignallingMessage({type: EventType.Negotiate, content}, log);
|
await this.sendSignallingMessage({type: EventType.Negotiate, content}, log);
|
||||||
|
@ -689,7 +690,6 @@ export class PeerCall implements IDisposable {
|
||||||
const answerContent: MCallAnswer<MCallBase> = {
|
const answerContent: MCallAnswer<MCallBase> = {
|
||||||
call_id: this.callId,
|
call_id: this.callId,
|
||||||
version: 1,
|
version: 1,
|
||||||
seq: this.seq++,
|
|
||||||
answer: {
|
answer: {
|
||||||
sdp: localDescription.sdp,
|
sdp: localDescription.sdp,
|
||||||
type: localDescription.type,
|
type: localDescription.type,
|
||||||
|
@ -755,7 +755,6 @@ export class PeerCall implements IDisposable {
|
||||||
content: {
|
content: {
|
||||||
call_id: this.callId,
|
call_id: this.callId,
|
||||||
version: 1,
|
version: 1,
|
||||||
seq: this.seq++,
|
|
||||||
candidates
|
candidates
|
||||||
},
|
},
|
||||||
}, log);
|
}, log);
|
||||||
|
@ -885,6 +884,7 @@ export class PeerCall implements IDisposable {
|
||||||
this.setState(CallState.Ended, log);
|
this.setState(CallState.Ended, log);
|
||||||
this.localMedia = undefined;
|
this.localMedia = undefined;
|
||||||
|
|
||||||
|
// TODO: change signalingState to connectionState?
|
||||||
if (this.peerConnection && this.peerConnection.signalingState !== 'closed') {
|
if (this.peerConnection && this.peerConnection.signalingState !== 'closed') {
|
||||||
this.peerConnection.close();
|
this.peerConnection.close();
|
||||||
}
|
}
|
||||||
|
@ -896,8 +896,8 @@ export class PeerCall implements IDisposable {
|
||||||
const streamId = this.localMedia.userMedia.id;
|
const streamId = this.localMedia.userMedia.id;
|
||||||
metadata[streamId] = {
|
metadata[streamId] = {
|
||||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||||
audio_muted: this.localMuteSettings?.microphone || !getStreamAudioTrack(this.localMedia.userMedia),
|
audio_muted: this.localMuteSettings?.microphone ?? false,
|
||||||
video_muted: this.localMuteSettings?.camera || !getStreamVideoTrack(this.localMedia.userMedia),
|
video_muted: this.localMuteSettings?.camera ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (this.localMedia?.screenShare) {
|
if (this.localMedia?.screenShare) {
|
||||||
|
@ -945,7 +945,7 @@ export class PeerCall implements IDisposable {
|
||||||
this.updateRemoteMedia(log);
|
this.updateRemoteMedia(log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
};
|
};
|
||||||
stream.addEventListener("removetrack", listener);
|
stream.addEventListener("removetrack", listener);
|
||||||
const disposeListener = () => {
|
const disposeListener = () => {
|
||||||
|
@ -980,8 +980,10 @@ export class PeerCall implements IDisposable {
|
||||||
videoReceiver.track.enabled = !metaData.video_muted;
|
videoReceiver.track.enabled = !metaData.video_muted;
|
||||||
}
|
}
|
||||||
this._remoteMuteSettings = new MuteSettings(
|
this._remoteMuteSettings = new MuteSettings(
|
||||||
metaData.audio_muted || !audioReceiver?.track,
|
metaData.audio_muted ?? false,
|
||||||
metaData.video_muted || !videoReceiver?.track
|
metaData.video_muted ?? false,
|
||||||
|
!!audioReceiver?.track ?? false,
|
||||||
|
!!videoReceiver?.track ?? false
|
||||||
);
|
);
|
||||||
log.log({
|
log.log({
|
||||||
l: "setting userMedia",
|
l: "setting userMedia",
|
||||||
|
@ -1003,49 +1005,56 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
private updateLocalMedia(localMedia: LocalMedia, logItem: ILogItem): Promise<void> {
|
private updateLocalMedia(localMedia: LocalMedia, logItem: ILogItem): Promise<void> {
|
||||||
return logItem.wrap("updateLocalMedia", async log => {
|
return logItem.wrap("updateLocalMedia", async log => {
|
||||||
const oldMedia = this.localMedia;
|
const senders = this.peerConnection.getSenders();
|
||||||
this.localMedia = localMedia;
|
|
||||||
const applyStream = async (oldStream: Stream | undefined, stream: Stream | undefined, streamPurpose: SDPStreamMetadataPurpose) => {
|
const applyStream = async (oldStream: Stream | undefined, stream: Stream | undefined, streamPurpose: SDPStreamMetadataPurpose) => {
|
||||||
const applyTrack = async (oldTrack: Track | undefined, newTrack: Track | undefined) => {
|
const applyTrack = async (oldTrack: Track | undefined, newTrack: Track | undefined) => {
|
||||||
if (!oldTrack && newTrack) {
|
const oldSender = senders.find(s => s.track === oldTrack);
|
||||||
log.wrap(`adding ${streamPurpose} ${newTrack.kind} track`, log => {
|
const streamToKeep = (oldStream ?? stream)!;
|
||||||
const sender = this.peerConnection.addTrack(newTrack, stream!);
|
if (streamToKeep !== stream) {
|
||||||
this.options.webRTC.prepareSenderForPurpose(this.peerConnection, sender, streamPurpose);
|
if (oldTrack) {
|
||||||
});
|
streamToKeep.removeTrack(oldTrack);
|
||||||
} else if (oldTrack) {
|
oldTrack.stop();
|
||||||
const sender = this.peerConnection.getSenders().find(s => s.track && s.track.id === oldTrack.id);
|
|
||||||
if (sender) {
|
|
||||||
if (newTrack && oldTrack.id !== newTrack.id) {
|
|
||||||
try {
|
|
||||||
await log.wrap(`replacing ${streamPurpose} ${newTrack.kind} track`, log => {
|
|
||||||
return sender.replaceTrack(newTrack);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// can't replace the track without renegotiating{
|
|
||||||
log.wrap(`adding and removing ${streamPurpose} ${newTrack.kind} track`, log => {
|
|
||||||
this.peerConnection.removeTrack(sender);
|
|
||||||
const newSender = this.peerConnection.addTrack(newTrack);
|
|
||||||
this.options.webRTC.prepareSenderForPurpose(this.peerConnection, newSender, streamPurpose);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (!newTrack) {
|
|
||||||
log.wrap(`removing ${streamPurpose} ${sender.track!.kind} track`, log => {
|
|
||||||
this.peerConnection.removeTrack(sender);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
log.log(`${streamPurpose} ${oldTrack.kind} track hasn't changed`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// TODO: should we do something if we didn't find the sender? e.g. some other code already removed the sender but didn't update localMedia
|
if (newTrack) {
|
||||||
|
streamToKeep.addTrack(newTrack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newTrack && oldSender) {
|
||||||
|
try {
|
||||||
|
await log.wrap(`attempting to replace ${streamPurpose} ${newTrack.kind} track`, log => {
|
||||||
|
return oldSender.replaceTrack(newTrack);
|
||||||
|
});
|
||||||
|
// replaceTrack succeeded, nothing left to do
|
||||||
|
return;
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
if(oldSender) {
|
||||||
|
log.wrap(`removing ${streamPurpose} ${oldSender.track!.kind} track`, log => {
|
||||||
|
this.peerConnection.removeTrack(oldSender);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (newTrack) {
|
||||||
|
log.wrap(`adding ${streamPurpose} ${newTrack.kind} track`, log => {
|
||||||
|
const newSender = this.peerConnection.addTrack(newTrack, streamToKeep);
|
||||||
|
this.options.webRTC.prepareSenderForPurpose(this.peerConnection, newSender, streamPurpose);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!oldStream && !stream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await applyTrack(getStreamAudioTrack(oldStream), getStreamAudioTrack(stream));
|
await applyTrack(getStreamAudioTrack(oldStream), getStreamAudioTrack(stream));
|
||||||
await applyTrack(getStreamVideoTrack(oldStream), getStreamVideoTrack(stream));
|
await applyTrack(getStreamVideoTrack(oldStream), getStreamVideoTrack(stream));
|
||||||
};
|
};
|
||||||
|
|
||||||
await applyStream(oldMedia?.userMedia, localMedia?.userMedia, SDPStreamMetadataPurpose.Usermedia);
|
await applyStream(this.localMedia?.userMedia, localMedia?.userMedia, SDPStreamMetadataPurpose.Usermedia);
|
||||||
await applyStream(oldMedia?.screenShare, localMedia?.screenShare, SDPStreamMetadataPurpose.Screenshare);
|
await applyStream(this.localMedia?.screenShare, localMedia?.screenShare, SDPStreamMetadataPurpose.Screenshare);
|
||||||
|
// we explicitly don't replace this.localMedia if already set
|
||||||
|
// as we need to keep the old stream so the stream id doesn't change
|
||||||
|
// instead we add and remove tracks in the stream in applyTrack
|
||||||
|
if (!this.localMedia) {
|
||||||
|
this.localMedia = localMedia;
|
||||||
|
}
|
||||||
// TODO: datachannel, but don't do it here as we don't want to do it from answer, rather in different method
|
// TODO: datachannel, but don't do it here as we don't want to do it from answer, rather in different method
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1162,3 +1171,13 @@ function enableTransceiver(transceiver: Transceiver, enabled: boolean, exclusive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tests to write:
|
||||||
|
*
|
||||||
|
* upgradeCall: adding a track with setMedia calls the correct methods on the peerConnection
|
||||||
|
* upgradeCall: removing a track with setMedia calls the correct methods on the peerConnection
|
||||||
|
* upgradeCall: replacing compatible track with setMedia calls the correct methods on the peerConnection
|
||||||
|
* upgradeCall: replacing incompatible track (sender.replaceTrack throws) with setMedia calls the correct methods on the peerConnection
|
||||||
|
*
|
||||||
|
* */
|
||||||
|
|
|
@ -65,7 +65,6 @@ export interface CallReplacesTarget {
|
||||||
export type MCallBase = {
|
export type MCallBase = {
|
||||||
call_id: string;
|
call_id: string;
|
||||||
version: string | number;
|
version: string | number;
|
||||||
seq: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MGroupCallBase = MCallBase & {
|
export type MGroupCallBase = MCallBase & {
|
||||||
|
@ -74,6 +73,7 @@ export type MGroupCallBase = MCallBase & {
|
||||||
sender_session_id: string;
|
sender_session_id: string;
|
||||||
dest_session_id: string;
|
dest_session_id: string;
|
||||||
party_id: string; // Should not need this?
|
party_id: string; // Should not need this?
|
||||||
|
seq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MCallAnswer<Base extends MCallBase> = Base & {
|
export type MCallAnswer<Base extends MCallBase> = Base & {
|
||||||
|
|
|
@ -25,14 +25,36 @@ export function getStreamVideoTrack(stream: Stream | undefined): Track | undefin
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MuteSettings {
|
export class MuteSettings {
|
||||||
constructor (public readonly microphone: boolean = false, public readonly camera: boolean = false) {}
|
constructor (
|
||||||
|
private readonly isMicrophoneMuted: boolean = false,
|
||||||
|
private readonly isCameraMuted: boolean = false,
|
||||||
|
private hasMicrophoneTrack: boolean = false,
|
||||||
|
private hasCameraTrack: boolean = false,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
updateTrackInfo(userMedia: Stream | undefined) {
|
||||||
|
this.hasMicrophoneTrack = !!getStreamAudioTrack(userMedia);
|
||||||
|
this.hasCameraTrack = !!getStreamVideoTrack(userMedia);
|
||||||
|
}
|
||||||
|
|
||||||
|
get microphone(): boolean {
|
||||||
|
return !this.hasMicrophoneTrack || this.isMicrophoneMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
get camera(): boolean {
|
||||||
|
return !this.hasCameraTrack || this.isCameraMuted;
|
||||||
|
}
|
||||||
|
|
||||||
toggleCamera(): MuteSettings {
|
toggleCamera(): MuteSettings {
|
||||||
return new MuteSettings(this.microphone, !this.camera);
|
return new MuteSettings(this.microphone, !this.camera, this.hasMicrophoneTrack, this.hasCameraTrack);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMicrophone(): MuteSettings {
|
toggleMicrophone(): MuteSettings {
|
||||||
return new MuteSettings(!this.microphone, this.camera);
|
return new MuteSettings(!this.microphone, this.camera, this.hasMicrophoneTrack, this.hasCameraTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: MuteSettings) {
|
||||||
|
return this.microphone === other.microphone && this.camera === other.camera;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {ObservableMap} from "../../../observable/map/ObservableMap";
|
||||||
import {Member} from "./Member";
|
import {Member} from "./Member";
|
||||||
import {LocalMedia} from "../LocalMedia";
|
import {LocalMedia} from "../LocalMedia";
|
||||||
import {MuteSettings, CALL_LOG_TYPE} from "../common";
|
import {MuteSettings, CALL_LOG_TYPE} from "../common";
|
||||||
import {RoomMember} from "../../room/members/RoomMember";
|
import {MemberChange, RoomMember} from "../../room/members/RoomMember";
|
||||||
import {EventEmitter} from "../../../utils/EventEmitter";
|
import {EventEmitter} from "../../../utils/EventEmitter";
|
||||||
import {EventType, CallIntent} from "../callEventTypes";
|
import {EventType, CallIntent} from "../callEventTypes";
|
||||||
|
|
||||||
|
@ -55,7 +55,7 @@ function getDeviceFromMemberKey(key: string): string {
|
||||||
|
|
||||||
export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDeviceMessage"> & {
|
export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDeviceMessage"> & {
|
||||||
emitUpdate: (call: GroupCall, params?: any) => void;
|
emitUpdate: (call: GroupCall, params?: any) => void;
|
||||||
encryptDeviceMessage: (roomId: string, userId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
|
encryptDeviceMessage: (roomId: string, userId: string, deviceId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage | undefined>,
|
||||||
storage: Storage,
|
storage: Storage,
|
||||||
logger: ILogger,
|
logger: ILogger,
|
||||||
};
|
};
|
||||||
|
@ -96,8 +96,8 @@ export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
this._memberOptions = Object.assign({}, options, {
|
this._memberOptions = Object.assign({}, options, {
|
||||||
confId: this.id,
|
confId: this.id,
|
||||||
emitUpdate: member => this._members.update(getMemberKey(member.userId, member.deviceId), member),
|
emitUpdate: member => this._members.update(getMemberKey(member.userId, member.deviceId), member),
|
||||||
encryptDeviceMessage: (userId: string, message: SignallingMessage<MGroupCallBase>, log) => {
|
encryptDeviceMessage: (userId: string, deviceId: string, message: SignallingMessage<MGroupCallBase>, log) => {
|
||||||
return this.options.encryptDeviceMessage(this.roomId, userId, message, log);
|
return this.options.encryptDeviceMessage(this.roomId, userId, deviceId, message, log);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -148,21 +148,26 @@ export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
ownSessionId: this.options.sessionId
|
ownSessionId: this.options.sessionId
|
||||||
});
|
});
|
||||||
const membersLogItem = logItem.child("member connections");
|
const membersLogItem = logItem.child("member connections");
|
||||||
|
const localMuteSettings = new MuteSettings();
|
||||||
|
localMuteSettings.updateTrackInfo(localMedia.userMedia);
|
||||||
const joinedData = new JoinedData(
|
const joinedData = new JoinedData(
|
||||||
logItem,
|
logItem,
|
||||||
membersLogItem,
|
membersLogItem,
|
||||||
localMedia,
|
localMedia,
|
||||||
new MuteSettings()
|
localMuteSettings
|
||||||
);
|
);
|
||||||
this.joinedData = joinedData;
|
this.joinedData = joinedData;
|
||||||
await joinedData.logItem.wrap("join", async log => {
|
await joinedData.logItem.wrap("join", async log => {
|
||||||
this._state = GroupCallState.Joining;
|
this._state = GroupCallState.Joining;
|
||||||
this.emitChange();
|
this.emitChange();
|
||||||
const memberContent = await this._createJoinPayload();
|
await log.wrap("update member state", async log => {
|
||||||
// send m.call.member state event
|
const memberContent = await this._createJoinPayload();
|
||||||
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log});
|
log.set("payload", memberContent);
|
||||||
await request.response();
|
// send m.call.member state event
|
||||||
this.emitChange();
|
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log});
|
||||||
|
await request.response();
|
||||||
|
this.emitChange();
|
||||||
|
});
|
||||||
// send invite to all members that are < my userId
|
// send invite to all members that are < my userId
|
||||||
for (const [,member] of this._members) {
|
for (const [,member] of this._members) {
|
||||||
this.connectToMember(member, joinedData, log);
|
this.connectToMember(member, joinedData, log);
|
||||||
|
@ -174,10 +179,14 @@ export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
if ((this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) && this.joinedData) {
|
if ((this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) && this.joinedData) {
|
||||||
const oldMedia = this.joinedData.localMedia;
|
const oldMedia = this.joinedData.localMedia;
|
||||||
this.joinedData.localMedia = localMedia;
|
this.joinedData.localMedia = localMedia;
|
||||||
|
// reflect the fact we gained or lost local tracks in the local mute settings
|
||||||
|
// and update the track info so PeerCall can use it to send up to date metadata,
|
||||||
|
this.joinedData.localMuteSettings.updateTrackInfo(localMedia.userMedia);
|
||||||
|
this.emitChange(); //allow listeners to see new media/mute settings
|
||||||
await Promise.all(Array.from(this._members.values()).map(m => {
|
await Promise.all(Array.from(this._members.values()).map(m => {
|
||||||
return m.setMedia(localMedia, oldMedia);
|
return m.setMedia(localMedia, oldMedia);
|
||||||
}));
|
}));
|
||||||
oldMedia?.stopExcept(localMedia);
|
oldMedia?.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -186,11 +195,19 @@ export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
if (!joinedData) {
|
if (!joinedData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const prevMuteSettings = joinedData.localMuteSettings;
|
||||||
|
// we still update the mute settings if nothing changed because
|
||||||
|
// you might be muted because you don't have a track or because
|
||||||
|
// you actively chosen to mute
|
||||||
|
// (which we want to respect in the future when you add a track)
|
||||||
joinedData.localMuteSettings = muteSettings;
|
joinedData.localMuteSettings = muteSettings;
|
||||||
await Promise.all(Array.from(this._members.values()).map(m => {
|
joinedData.localMuteSettings.updateTrackInfo(joinedData.localMedia.userMedia);
|
||||||
return m.setMuted(joinedData.localMuteSettings);
|
if (!prevMuteSettings.equals(muteSettings)) {
|
||||||
}));
|
await Promise.all(Array.from(this._members.values()).map(m => {
|
||||||
this.emitChange();
|
return m.setMuted(joinedData.localMuteSettings);
|
||||||
|
}));
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get muteSettings(): MuteSettings | undefined {
|
get muteSettings(): MuteSettings | undefined {
|
||||||
|
@ -269,7 +286,20 @@ export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
updateMembership(userId: string, callMembership: CallMembership, eventTimestamp: number, syncLog: ILogItem) {
|
updateRoomMembers(memberChanges: Map<string, MemberChange>) {
|
||||||
|
for (const change of memberChanges.values()) {
|
||||||
|
const {member} = change;
|
||||||
|
for (const callMember of this._members.values()) {
|
||||||
|
// find all call members for a room member (can be multiple, for every device)
|
||||||
|
if (callMember.userId === member.userId) {
|
||||||
|
callMember.updateRoomMember(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
updateMembership(userId: string, roomMember: RoomMember, callMembership: CallMembership, eventTimestamp: number, syncLog: ILogItem) {
|
||||||
syncLog.wrap({l: "update call membership", t: CALL_LOG_TYPE, id: this.id, userId}, log => {
|
syncLog.wrap({l: "update call membership", t: CALL_LOG_TYPE, id: this.id, userId}, log => {
|
||||||
const devices = callMembership["m.devices"];
|
const devices = callMembership["m.devices"];
|
||||||
const previousDeviceIds = this.getDeviceIdsForUserId(userId);
|
const previousDeviceIds = this.getDeviceIdsForUserId(userId);
|
||||||
|
@ -306,8 +336,8 @@ export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
}
|
}
|
||||||
log.set("add", true);
|
log.set("add", true);
|
||||||
member = new Member(
|
member = new Member(
|
||||||
RoomMember.fromUserId(this.roomId, userId, "join"),
|
roomMember,
|
||||||
device, deviceIndex, eventTimestamp, this._memberOptions
|
device, deviceIndex, eventTimestamp, this._memberOptions,
|
||||||
);
|
);
|
||||||
this._members.add(memberKey, member);
|
this._members.add(memberKey, member);
|
||||||
if (this.joinedData) {
|
if (this.joinedData) {
|
||||||
|
@ -499,8 +529,7 @@ export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
const logItem = joinedData.membersLogItem.child({l: "member", id: memberKey});
|
const logItem = joinedData.membersLogItem.child({l: "member", id: memberKey});
|
||||||
logItem.set("sessionId", member.sessionId);
|
logItem.set("sessionId", member.sessionId);
|
||||||
log.wrap({l: "connect", id: memberKey}, log => {
|
log.wrap({l: "connect", id: memberKey}, log => {
|
||||||
// Safari can't send a MediaStream to multiple sources, so clone it
|
const connectItem = member.connect(joinedData.localMedia, joinedData.localMuteSettings, logItem);
|
||||||
const connectItem = member.connect(joinedData.localMedia.clone(), joinedData.localMuteSettings, logItem);
|
|
||||||
if (connectItem) {
|
if (connectItem) {
|
||||||
log.refDetached(connectItem);
|
log.refDetached(connectItem);
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {PeerCall, CallState} from "../PeerCall";
|
||||||
import {makeTxnId, makeId} from "../../common";
|
import {makeTxnId, makeId} from "../../common";
|
||||||
import {EventType, CallErrorCode} from "../callEventTypes";
|
import {EventType, CallErrorCode} from "../callEventTypes";
|
||||||
import {formatToDeviceMessagesPayload} from "../../common";
|
import {formatToDeviceMessagesPayload} from "../../common";
|
||||||
|
import {sortedIndex} from "../../../utils/sortedIndex";
|
||||||
import type {MuteSettings} from "../common";
|
import type {MuteSettings} from "../common";
|
||||||
import type {Options as PeerCallOptions, RemoteMedia} from "../PeerCall";
|
import type {Options as PeerCallOptions, RemoteMedia} from "../PeerCall";
|
||||||
import type {LocalMedia} from "../LocalMedia";
|
import type {LocalMedia} from "../LocalMedia";
|
||||||
|
@ -36,7 +36,7 @@ export type Options = Omit<PeerCallOptions, "emitUpdate" | "sendSignallingMessag
|
||||||
// local session id of our client
|
// local session id of our client
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
hsApi: HomeServerApi,
|
hsApi: HomeServerApi,
|
||||||
encryptDeviceMessage: (userId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
|
encryptDeviceMessage: (userId: string, deviceId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage | undefined>,
|
||||||
emitUpdate: (participant: Member, params?: any) => void,
|
emitUpdate: (participant: Member, params?: any) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,6 +53,9 @@ const errorCodesWithoutRetry = [
|
||||||
class MemberConnection {
|
class MemberConnection {
|
||||||
public retryCount: number = 0;
|
public retryCount: number = 0;
|
||||||
public peerCall?: PeerCall;
|
public peerCall?: PeerCall;
|
||||||
|
public lastProcessedSeqNr: number | undefined;
|
||||||
|
public queuedSignallingMessages: SignallingMessage<MGroupCallBase>[] = [];
|
||||||
|
public outboundSeqCounter: number = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public localMedia: LocalMedia,
|
public localMedia: LocalMedia,
|
||||||
|
@ -65,7 +68,7 @@ export class Member {
|
||||||
private connection?: MemberConnection;
|
private connection?: MemberConnection;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly member: RoomMember,
|
public member: RoomMember,
|
||||||
private callDeviceMembership: CallDeviceMembership,
|
private callDeviceMembership: CallDeviceMembership,
|
||||||
private _deviceIndex: number,
|
private _deviceIndex: number,
|
||||||
private _eventTimestamp: number,
|
private _eventTimestamp: number,
|
||||||
|
@ -123,7 +126,8 @@ export class Member {
|
||||||
if (this.connection) {
|
if (this.connection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const connection = new MemberConnection(localMedia, localMuteSettings, memberLogItem);
|
// Safari can't send a MediaStream to multiple sources, so clone it
|
||||||
|
const connection = new MemberConnection(localMedia.clone(), localMuteSettings, memberLogItem);
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
let connectLogItem;
|
let connectLogItem;
|
||||||
connection.logItem.wrap("connect", async log => {
|
connection.logItem.wrap("connect", async log => {
|
||||||
|
@ -167,7 +171,7 @@ export class Member {
|
||||||
connection.logItem.wrap("disconnect", async log => {
|
connection.logItem.wrap("disconnect", async log => {
|
||||||
disconnectLogItem = log;
|
disconnectLogItem = log;
|
||||||
if (hangup) {
|
if (hangup) {
|
||||||
connection.peerCall?.hangup(CallErrorCode.UserHangup, log);
|
await connection.peerCall?.hangup(CallErrorCode.UserHangup, log);
|
||||||
} else {
|
} else {
|
||||||
await connection.peerCall?.close(undefined, log);
|
await connection.peerCall?.close(undefined, log);
|
||||||
}
|
}
|
||||||
|
@ -195,6 +199,13 @@ export class Member {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
updateRoomMember(roomMember: RoomMember) {
|
||||||
|
this.member = roomMember;
|
||||||
|
// TODO: this emits an update during the writeSync phase, which we usually try to avoid
|
||||||
|
this.options.emitUpdate(this);
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
emitUpdateFromPeerCall = (peerCall: PeerCall, params: any, log: ILogItem): void => {
|
emitUpdateFromPeerCall = (peerCall: PeerCall, params: any, log: ILogItem): void => {
|
||||||
const connection = this.connection!;
|
const connection = this.connection!;
|
||||||
|
@ -230,25 +241,26 @@ export class Member {
|
||||||
/** @internal */
|
/** @internal */
|
||||||
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem): Promise<void> => {
|
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem): Promise<void> => {
|
||||||
const groupMessage = message as SignallingMessage<MGroupCallBase>;
|
const groupMessage = message as SignallingMessage<MGroupCallBase>;
|
||||||
|
groupMessage.content.seq = this.connection!.outboundSeqCounter++;
|
||||||
groupMessage.content.conf_id = this.options.confId;
|
groupMessage.content.conf_id = this.options.confId;
|
||||||
groupMessage.content.device_id = this.options.ownDeviceId;
|
groupMessage.content.device_id = this.options.ownDeviceId;
|
||||||
groupMessage.content.party_id = this.options.ownDeviceId;
|
groupMessage.content.party_id = this.options.ownDeviceId;
|
||||||
groupMessage.content.sender_session_id = this.options.sessionId;
|
groupMessage.content.sender_session_id = this.options.sessionId;
|
||||||
groupMessage.content.dest_session_id = this.sessionId;
|
groupMessage.content.dest_session_id = this.sessionId;
|
||||||
// const encryptedMessages = await this.options.encryptDeviceMessage(this.member.userId, groupMessage, log);
|
let payload;
|
||||||
// const payload = formatToDeviceMessagesPayload(encryptedMessages);
|
let type: string = message.type;
|
||||||
const payload = {
|
const encryptedMessages = await this.options.encryptDeviceMessage(this.member.userId, this.deviceId, groupMessage, log);
|
||||||
messages: {
|
if (encryptedMessages) {
|
||||||
[this.member.userId]: {
|
payload = formatToDeviceMessagesPayload(encryptedMessages);
|
||||||
[this.deviceId]: groupMessage.content
|
type = "m.room.encrypted";
|
||||||
}
|
} else {
|
||||||
}
|
// device needs deviceId and userId
|
||||||
};
|
payload = formatToDeviceMessagesPayload([{content: groupMessage.content, device: this}]);
|
||||||
|
}
|
||||||
// TODO: remove this for release
|
// TODO: remove this for release
|
||||||
log.set("payload", groupMessage.content);
|
log.set("payload", groupMessage.content);
|
||||||
const request = this.options.hsApi.sendToDevice(
|
const request = this.options.hsApi.sendToDevice(
|
||||||
message.type,
|
type,
|
||||||
//"m.room.encrypted",
|
|
||||||
payload,
|
payload,
|
||||||
makeTxnId(),
|
makeTxnId(),
|
||||||
{log}
|
{log}
|
||||||
|
@ -269,11 +281,27 @@ export class Member {
|
||||||
if (message.type === EventType.Invite && !connection.peerCall) {
|
if (message.type === EventType.Invite && !connection.peerCall) {
|
||||||
connection.peerCall = this._createPeerCall(message.content.call_id);
|
connection.peerCall = this._createPeerCall(message.content.call_id);
|
||||||
}
|
}
|
||||||
|
const idx = sortedIndex(connection.queuedSignallingMessages, message, (a, b) => a.content.seq - b.content.seq);
|
||||||
|
connection.queuedSignallingMessages.splice(idx, 0, message);
|
||||||
|
let hasBeenDequeued = false;
|
||||||
if (connection.peerCall) {
|
if (connection.peerCall) {
|
||||||
const item = connection.peerCall.handleIncomingSignallingMessage(message, this.deviceId, connection.logItem);
|
while (
|
||||||
syncLog.refDetached(item);
|
connection.queuedSignallingMessages.length && (
|
||||||
} else {
|
connection.lastProcessedSeqNr === undefined ||
|
||||||
// TODO: need to buffer events until invite comes?
|
connection.queuedSignallingMessages[0].content.seq === connection.lastProcessedSeqNr + 1
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const dequeuedMessage = connection.queuedSignallingMessages.shift()!;
|
||||||
|
if (dequeuedMessage === message) {
|
||||||
|
hasBeenDequeued = true;
|
||||||
|
}
|
||||||
|
const item = connection.peerCall!.handleIncomingSignallingMessage(dequeuedMessage, this.deviceId, connection.logItem);
|
||||||
|
syncLog.refDetached(item);
|
||||||
|
connection.lastProcessedSeqNr = dequeuedMessage.content.seq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasBeenDequeued) {
|
||||||
|
syncLog.refDetached(connection.logItem.log({l: "queued signalling message", type: message.type, seq: message.content.seq}));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
syncLog.log({l: "member not connected", userId: this.userId, deviceId: this.deviceId});
|
syncLog.log({l: "member not connected", userId: this.userId, deviceId: this.deviceId});
|
||||||
|
@ -284,7 +312,7 @@ export class Member {
|
||||||
async setMedia(localMedia: LocalMedia, previousMedia: LocalMedia): Promise<void> {
|
async setMedia(localMedia: LocalMedia, previousMedia: LocalMedia): Promise<void> {
|
||||||
const {connection} = this;
|
const {connection} = this;
|
||||||
if (connection) {
|
if (connection) {
|
||||||
connection.localMedia = connection.localMedia.replaceClone(connection.localMedia, previousMedia);
|
connection.localMedia = localMedia.replaceClone(connection.localMedia, previousMedia);
|
||||||
await connection.peerCall?.setMedia(connection.localMedia, connection.logItem);
|
await connection.peerCall?.setMedia(connection.localMedia, connection.logItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -214,11 +214,12 @@ export class DeviceTracker {
|
||||||
const allDeviceIdentities = [];
|
const allDeviceIdentities = [];
|
||||||
const deviceIdentitiesToStore = [];
|
const deviceIdentitiesToStore = [];
|
||||||
// filter out devices that have changed their ed25519 key since last time we queried them
|
// filter out devices that have changed their ed25519 key since last time we queried them
|
||||||
deviceIdentities = await Promise.all(deviceIdentities.map(async deviceIdentity => {
|
await Promise.all(deviceIdentities.map(async deviceIdentity => {
|
||||||
if (knownDeviceIds.includes(deviceIdentity.deviceId)) {
|
if (knownDeviceIds.includes(deviceIdentity.deviceId)) {
|
||||||
const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId);
|
const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId);
|
||||||
if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) {
|
if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) {
|
||||||
allDeviceIdentities.push(existingDevice);
|
allDeviceIdentities.push(existingDevice);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allDeviceIdentities.push(deviceIdentity);
|
allDeviceIdentities.push(deviceIdentity);
|
||||||
|
@ -308,6 +309,7 @@ export class DeviceTracker {
|
||||||
return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log);
|
return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** gets devices for the given user ids that are in the given room */
|
||||||
async devicesForRoomMembers(roomId, userIds, hsApi, log) {
|
async devicesForRoomMembers(roomId, userIds, hsApi, log) {
|
||||||
const txn = await this._storage.readTxn([
|
const txn = await this._storage.readTxn([
|
||||||
this._storage.storeNames.userIdentities,
|
this._storage.storeNames.userIdentities,
|
||||||
|
@ -315,6 +317,60 @@ export class DeviceTracker {
|
||||||
return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log);
|
return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** gets a single device */
|
||||||
|
async deviceForId(userId, deviceId, hsApi, log) {
|
||||||
|
const txn = await this._storage.readTxn([
|
||||||
|
this._storage.storeNames.deviceIdentities,
|
||||||
|
]);
|
||||||
|
let device = await txn.deviceIdentities.get(userId, deviceId);
|
||||||
|
if (device) {
|
||||||
|
log.set("existingDevice", true);
|
||||||
|
} else {
|
||||||
|
//// BEGIN EXTRACT (deviceKeysMap)
|
||||||
|
const deviceKeyResponse = await hsApi.queryKeys({
|
||||||
|
"timeout": 10000,
|
||||||
|
"device_keys": {
|
||||||
|
[userId]: [deviceId]
|
||||||
|
},
|
||||||
|
"token": this._getSyncToken()
|
||||||
|
}, {log}).response();
|
||||||
|
// verify signature
|
||||||
|
const verifiedKeysPerUser = log.wrap("verify", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log));
|
||||||
|
//// END EXTRACT
|
||||||
|
|
||||||
|
const verifiedKeys = verifiedKeysPerUser
|
||||||
|
.find(vkpu => vkpu.userId === userId).verifiedKeys
|
||||||
|
.find(vk => vk["device_id"] === deviceId);
|
||||||
|
// user hasn't uploaded keys for device?
|
||||||
|
if (!verifiedKeys) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
device = deviceKeysAsDeviceIdentity(verifiedKeys);
|
||||||
|
const txn = await this._storage.readWriteTxn([
|
||||||
|
this._storage.storeNames.deviceIdentities,
|
||||||
|
]);
|
||||||
|
// check again we don't have the device already.
|
||||||
|
// when updating all keys for a user we allow updating the
|
||||||
|
// device when the key hasn't changed so the device display name
|
||||||
|
// can be updated, but here we don't.
|
||||||
|
const existingDevice = await txn.deviceIdentities.get(userId, deviceId);
|
||||||
|
if (existingDevice) {
|
||||||
|
device = existingDevice;
|
||||||
|
log.set("existingDeviceAfterFetch", true);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
txn.deviceIdentities.set(device);
|
||||||
|
log.set("newDevice", true);
|
||||||
|
} catch (err) {
|
||||||
|
txn.abort();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await txn.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} roomId [description]
|
* @param {string} roomId [description]
|
||||||
* @param {Array<string>} userIds a set of user ids to try and find the identity for. Will be check to belong to roomId.
|
* @param {Array<string>} userIds a set of user ids to try and find the identity for. Will be check to belong to roomId.
|
||||||
|
@ -363,3 +419,154 @@ export class DeviceTracker {
|
||||||
return await txn.deviceIdentities.getByCurve25519Key(curve25519Key);
|
return await txn.deviceIdentities.getByCurve25519Key(curve25519Key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import {createMockStorage} from "../../mocks/Storage";
|
||||||
|
import {Instance as NullLoggerInstance} from "../../logging/NullLogger";
|
||||||
|
|
||||||
|
export function tests() {
|
||||||
|
|
||||||
|
function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) {
|
||||||
|
return {
|
||||||
|
isTrackingMembers: false,
|
||||||
|
isEncrypted: true,
|
||||||
|
loadMemberList: () => {
|
||||||
|
const joinedMembers = joinedUserIds.map(userId => {return {membership: "join", roomId, userId};});
|
||||||
|
const invitedMembers = invitedUserIds.map(userId => {return {membership: "invite", roomId, userId};});
|
||||||
|
const members = joinedMembers.concat(invitedMembers);
|
||||||
|
const memberMap = members.reduce((map, member) => {
|
||||||
|
map.set(member.userId, member);
|
||||||
|
return map;
|
||||||
|
}, new Map());
|
||||||
|
return {members: memberMap, release() {}}
|
||||||
|
},
|
||||||
|
writeIsTrackingMembers(isTrackingMembers) {
|
||||||
|
if (this.isTrackingMembers !== isTrackingMembers) {
|
||||||
|
return isTrackingMembers;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
applyIsTrackingMembersChanges(isTrackingMembers) {
|
||||||
|
if (isTrackingMembers !== undefined) {
|
||||||
|
this.isTrackingMembers = isTrackingMembers;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQueryKeysHSApiMock(createKey = (algorithm, userId, deviceId) => `${algorithm}:${userId}:${deviceId}:key`) {
|
||||||
|
return {
|
||||||
|
queryKeys(payload) {
|
||||||
|
const {device_keys: deviceKeys} = payload;
|
||||||
|
const userKeys = Object.entries(deviceKeys).reduce((userKeys, [userId, deviceIds]) => {
|
||||||
|
if (deviceIds.length === 0) {
|
||||||
|
deviceIds = ["device1"];
|
||||||
|
}
|
||||||
|
userKeys[userId] = deviceIds.filter(d => d === "device1").reduce((deviceKeys, deviceId) => {
|
||||||
|
deviceKeys[deviceId] = {
|
||||||
|
"algorithms": [
|
||||||
|
"m.olm.v1.curve25519-aes-sha2",
|
||||||
|
"m.megolm.v1.aes-sha2"
|
||||||
|
],
|
||||||
|
"device_id": deviceId,
|
||||||
|
"keys": {
|
||||||
|
[`curve25519:${deviceId}`]: createKey("curve25519", userId, deviceId),
|
||||||
|
[`ed25519:${deviceId}`]: createKey("ed25519", userId, deviceId),
|
||||||
|
},
|
||||||
|
"signatures": {
|
||||||
|
[userId]: {
|
||||||
|
[`ed25519:${deviceId}`]: `ed25519:${userId}:${deviceId}:signature`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unsigned": {
|
||||||
|
"device_display_name": `${userId} Phone`
|
||||||
|
},
|
||||||
|
"user_id": userId
|
||||||
|
};
|
||||||
|
return deviceKeys;
|
||||||
|
}, {});
|
||||||
|
return userKeys;
|
||||||
|
}, {});
|
||||||
|
const response = {device_keys: userKeys};
|
||||||
|
return {
|
||||||
|
async response() {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const roomId = "!abc:hs.tld";
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trackRoom only writes joined members": async assert => {
|
||||||
|
const storage = await createMockStorage();
|
||||||
|
const tracker = new DeviceTracker({
|
||||||
|
storage,
|
||||||
|
getSyncToken: () => "token",
|
||||||
|
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
|
||||||
|
ownUserId: "@alice:hs.tld",
|
||||||
|
ownDeviceId: "ABCD",
|
||||||
|
});
|
||||||
|
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"], ["@charly:hs.tld"]);
|
||||||
|
await tracker.trackRoom(room, NullLoggerInstance.item);
|
||||||
|
const txn = await storage.readTxn([storage.storeNames.userIdentities]);
|
||||||
|
assert.deepEqual(await txn.userIdentities.get("@alice:hs.tld"), {
|
||||||
|
userId: "@alice:hs.tld",
|
||||||
|
roomIds: [roomId],
|
||||||
|
deviceTrackingStatus: TRACKING_STATUS_OUTDATED
|
||||||
|
});
|
||||||
|
assert.deepEqual(await txn.userIdentities.get("@bob:hs.tld"), {
|
||||||
|
userId: "@bob:hs.tld",
|
||||||
|
roomIds: [roomId],
|
||||||
|
deviceTrackingStatus: TRACKING_STATUS_OUTDATED
|
||||||
|
});
|
||||||
|
assert.equal(await txn.userIdentities.get("@charly:hs.tld"), undefined);
|
||||||
|
},
|
||||||
|
"getting devices for tracked room yields correct keys": async assert => {
|
||||||
|
const storage = await createMockStorage();
|
||||||
|
const tracker = new DeviceTracker({
|
||||||
|
storage,
|
||||||
|
getSyncToken: () => "token",
|
||||||
|
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
|
||||||
|
ownUserId: "@alice:hs.tld",
|
||||||
|
ownDeviceId: "ABCD",
|
||||||
|
});
|
||||||
|
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]);
|
||||||
|
await tracker.trackRoom(room, NullLoggerInstance.item);
|
||||||
|
const hsApi = createQueryKeysHSApiMock();
|
||||||
|
const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
|
||||||
|
assert.equal(devices.length, 2);
|
||||||
|
assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key");
|
||||||
|
assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key");
|
||||||
|
},
|
||||||
|
"device with changed key is ignored": async assert => {
|
||||||
|
const storage = await createMockStorage();
|
||||||
|
const tracker = new DeviceTracker({
|
||||||
|
storage,
|
||||||
|
getSyncToken: () => "token",
|
||||||
|
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
|
||||||
|
ownUserId: "@alice:hs.tld",
|
||||||
|
ownDeviceId: "ABCD",
|
||||||
|
});
|
||||||
|
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]);
|
||||||
|
await tracker.trackRoom(room, NullLoggerInstance.item);
|
||||||
|
const hsApi = createQueryKeysHSApiMock();
|
||||||
|
// query devices first time
|
||||||
|
await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
|
||||||
|
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities]);
|
||||||
|
// mark alice as outdated, so keys will be fetched again
|
||||||
|
tracker.writeDeviceChanges(["@alice:hs.tld"], txn, NullLoggerInstance.item);
|
||||||
|
await txn.complete();
|
||||||
|
const hsApiWithChangedAliceKey = createQueryKeysHSApiMock((algo, userId, deviceId) => {
|
||||||
|
return `${algo}:${userId}:${deviceId}:${userId === "@alice:hs.tld" ? "newKey" : "key"}`;
|
||||||
|
});
|
||||||
|
const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApiWithChangedAliceKey, NullLoggerInstance.item);
|
||||||
|
assert.equal(devices.length, 2);
|
||||||
|
assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key");
|
||||||
|
assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key");
|
||||||
|
const txn2 = await storage.readTxn([storage.storeNames.deviceIdentities]);
|
||||||
|
// also check the modified key was not stored
|
||||||
|
assert.equal((await txn2.deviceIdentities.get("@alice:hs.tld", "device1")).ed25519Key, "ed25519:@alice:hs.tld:device1:key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -41,5 +41,8 @@ Runs before any room.prepareSync, so the new room keys can be passed to each roo
|
||||||
- e2ee account
|
- e2ee account
|
||||||
- generate more otks if needed
|
- generate more otks if needed
|
||||||
- upload new otks if needed or device keys if not uploaded before
|
- upload new otks if needed or device keys if not uploaded before
|
||||||
|
- device message handler:
|
||||||
|
- fetch keys we don't know about yet for (call) to_device messages identity
|
||||||
|
- pass signalling messages to call handler
|
||||||
- rooms
|
- rooms
|
||||||
- share new room keys if needed
|
- share new room keys if needed
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -459,6 +459,10 @@ export class BaseRoom extends EventEmitter {
|
||||||
return this._summary.data.isDirectMessage;
|
return this._summary.data.isDirectMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get user() {
|
||||||
|
return this._user;
|
||||||
|
}
|
||||||
|
|
||||||
isDirectMessageForUserId(userId) {
|
isDirectMessageForUserId(userId) {
|
||||||
if (this._summary.data.dmUserId === userId) {
|
if (this._summary.data.dmUserId === userId) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -123,7 +123,7 @@ export class Room extends BaseRoom {
|
||||||
txn.roomState.removeAllForRoom(this.id);
|
txn.roomState.removeAllForRoom(this.id);
|
||||||
txn.roomMembers.removeAllForRoom(this.id);
|
txn.roomMembers.removeAllForRoom(this.id);
|
||||||
}
|
}
|
||||||
const {entries: newEntries, updatedEntries, newLiveKey, memberChanges} =
|
const {entries: newEntries, updatedEntries, newLiveKey, memberChanges, memberSync} =
|
||||||
await log.wrap("syncWriter", log => this._syncWriter.writeSync(
|
await log.wrap("syncWriter", log => this._syncWriter.writeSync(
|
||||||
roomResponse, isRejoin, summaryChanges.hasFetchedMembers, txn, log), log.level.Detail);
|
roomResponse, isRejoin, summaryChanges.hasFetchedMembers, txn, log), log.level.Detail);
|
||||||
if (decryptChanges) {
|
if (decryptChanges) {
|
||||||
|
@ -180,7 +180,7 @@ export class Room extends BaseRoom {
|
||||||
removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log);
|
removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log);
|
||||||
}
|
}
|
||||||
const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse);
|
const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse);
|
||||||
this._runRoomStateHandlers(roomResponse, txn, log);
|
await this._runRoomStateHandlers(roomResponse, memberSync, txn, log);
|
||||||
return {
|
return {
|
||||||
roomResponse,
|
roomResponse,
|
||||||
summaryChanges,
|
summaryChanges,
|
||||||
|
@ -453,14 +453,16 @@ export class Room extends BaseRoom {
|
||||||
return this._sendQueue.pendingEvents;
|
return this._sendQueue.pendingEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** global room state handlers, run during write sync step */
|
/** global room state handlers, run during writeSync step */
|
||||||
_runRoomStateHandlers(roomResponse, txn, log) {
|
_runRoomStateHandlers(roomResponse, memberSync, txn, log) {
|
||||||
|
const promises = [];
|
||||||
iterateResponseStateEvents(roomResponse, event => {
|
iterateResponseStateEvents(roomResponse, event => {
|
||||||
this._roomStateHandler.handleRoomState(this, event, txn, log);
|
promises.push(this._roomStateHandler.handleRoomState(this, event, memberSync, txn, log));
|
||||||
});
|
});
|
||||||
|
return Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** local room state observers, run during after sync step */
|
/** local room state observers, run during afterSync step */
|
||||||
_emitSyncRoomState(roomResponse) {
|
_emitSyncRoomState(roomResponse) {
|
||||||
iterateResponseStateEvents(roomResponse, event => {
|
iterateResponseStateEvents(roomResponse, event => {
|
||||||
for (const handler of this._roomStateObservers) {
|
for (const handler of this._roomStateObservers) {
|
||||||
|
|
|
@ -20,14 +20,17 @@ import type {Transaction} from "../../storage/idb/Transaction";
|
||||||
import type {Room} from "../Room";
|
import type {Room} from "../Room";
|
||||||
import type {MemberChange} from "../members/RoomMember";
|
import type {MemberChange} from "../members/RoomMember";
|
||||||
import type {RoomStateHandler} from "./types";
|
import type {RoomStateHandler} from "./types";
|
||||||
|
import type {MemberSync} from "../timeline/persistence/MemberWriter.js";
|
||||||
import {BaseObservable} from "../../../observable/BaseObservable";
|
import {BaseObservable} from "../../../observable/BaseObservable";
|
||||||
|
|
||||||
/** keeps track of all handlers registered with Session.observeRoomState */
|
/** keeps track of all handlers registered with Session.observeRoomState */
|
||||||
export class RoomStateHandlerSet extends BaseObservable<RoomStateHandler> implements RoomStateHandler {
|
export class RoomStateHandlerSet extends BaseObservable<RoomStateHandler> implements RoomStateHandler {
|
||||||
handleRoomState(room: Room, stateEvent: StateEvent, txn: Transaction, log: ILogItem) {
|
async handleRoomState(room: Room, stateEvent: StateEvent, memberSync: MemberSync, txn: Transaction, log: ILogItem): Promise<void> {
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
for(let h of this._handlers) {
|
for(let h of this._handlers) {
|
||||||
h.handleRoomState(room, stateEvent, txn, log);
|
promises.push(h.handleRoomState(room, stateEvent, memberSync, txn, log));
|
||||||
}
|
}
|
||||||
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>) {
|
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>) {
|
||||||
for(let h of this._handlers) {
|
for(let h of this._handlers) {
|
||||||
|
|
|
@ -19,12 +19,13 @@ import type {StateEvent} from "../../storage/types";
|
||||||
import type {Transaction} from "../../storage/idb/Transaction";
|
import type {Transaction} from "../../storage/idb/Transaction";
|
||||||
import type {ILogItem} from "../../../logging/types";
|
import type {ILogItem} from "../../../logging/types";
|
||||||
import type {MemberChange} from "../members/RoomMember";
|
import type {MemberChange} from "../members/RoomMember";
|
||||||
|
import type {MemberSync} from "../timeline/persistence/MemberWriter";
|
||||||
|
|
||||||
/** used for Session.observeRoomState, which observes in all room, but without loading from storage
|
/** used for Session.observeRoomState, which observes in all room, but without loading from storage
|
||||||
* It receives the sync write transaction, so other stores can be updated as part of the same transaction. */
|
* It receives the sync write transaction, so other stores can be updated as part of the same transaction. */
|
||||||
export interface RoomStateHandler {
|
export interface RoomStateHandler {
|
||||||
handleRoomState(room: Room, stateEvent: StateEvent, syncWriteTxn: Transaction, log: ILogItem);
|
handleRoomState(room: Room, stateEvent: StateEvent, memberSync: MemberSync, syncWriteTxn: Transaction, log: ILogItem): Promise<void>;
|
||||||
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>);
|
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -56,7 +56,11 @@ export class MemberWriter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemberSync {
|
/** Represents the member changes in a given sync.
|
||||||
|
* Used to write the changes to storage and historical member
|
||||||
|
* information for events in the same sync.
|
||||||
|
**/
|
||||||
|
export class MemberSync {
|
||||||
constructor(memberWriter, stateEvents, timelineEvents, hasFetchedMembers) {
|
constructor(memberWriter, stateEvents, timelineEvents, hasFetchedMembers) {
|
||||||
this._memberWriter = memberWriter;
|
this._memberWriter = memberWriter;
|
||||||
this._timelineEvents = timelineEvents;
|
this._timelineEvents = timelineEvents;
|
||||||
|
|
|
@ -244,7 +244,7 @@ export class SyncWriter {
|
||||||
const {currentKey, entries, updatedEntries} =
|
const {currentKey, entries, updatedEntries} =
|
||||||
await this._writeTimeline(timelineEvents, timeline, memberSync, this._lastLiveKey, txn, log);
|
await this._writeTimeline(timelineEvents, timeline, memberSync, this._lastLiveKey, txn, log);
|
||||||
const memberChanges = await memberSync.write(txn);
|
const memberChanges = await memberSync.write(txn);
|
||||||
return {entries, updatedEntries, newLiveKey: currentKey, memberChanges};
|
return {entries, updatedEntries, newLiveKey: currentKey, memberChanges, memberSync};
|
||||||
}
|
}
|
||||||
|
|
||||||
afterSync(newLiveKey) {
|
afterSync(newLiveKey) {
|
||||||
|
|
|
@ -67,7 +67,7 @@ export class StorageFactory {
|
||||||
requestPersistedStorage().then(persisted => {
|
requestPersistedStorage().then(persisted => {
|
||||||
// Firefox lies here though, and returns true even if the user denied the request
|
// Firefox lies here though, and returns true even if the user denied the request
|
||||||
if (!persisted) {
|
if (!persisted) {
|
||||||
console.warn("no persisted storage, database can be evicted by browser");
|
log.log("no persisted storage, database can be evicted by browser", log.level.Warn);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -56,6 +56,8 @@ export interface Stream {
|
||||||
clone(): Stream;
|
clone(): Stream;
|
||||||
addEventListener<K extends keyof StreamEventMap>(type: K, listener: (this: Stream, ev: StreamEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
addEventListener<K extends keyof StreamEventMap>(type: K, listener: (this: Stream, ev: StreamEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
||||||
removeEventListener<K extends keyof StreamEventMap>(type: K, listener: (this: Stream, ev: StreamEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
|
removeEventListener<K extends keyof StreamEventMap>(type: K, listener: (this: Stream, ev: StreamEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
|
||||||
|
addTrack(track: Track);
|
||||||
|
removeTrack(track: Track);
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TrackKind {
|
export enum TrackKind {
|
||||||
|
|
68
src/platform/types/theme.ts
Normal 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>;
|
||||||
|
}>;
|
|
@ -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;
|
||||||
|
|
|
@ -41,6 +41,7 @@ import {parseHTML} from "./parsehtml.js";
|
||||||
import {handleAvatarError} from "./ui/avatar";
|
import {handleAvatarError} from "./ui/avatar";
|
||||||
import {MediaDevicesWrapper} from "./dom/MediaDevices";
|
import {MediaDevicesWrapper} from "./dom/MediaDevices";
|
||||||
import {DOMWebRTC} from "./dom/WebRTC";
|
import {DOMWebRTC} from "./dom/WebRTC";
|
||||||
|
import {ThemeLoader} from "./ThemeLoader";
|
||||||
|
|
||||||
function addScript(src) {
|
function addScript(src) {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
|
@ -169,20 +170,40 @@ export class Platform {
|
||||||
this._workerPromise = undefined;
|
this._workerPromise = undefined;
|
||||||
this.mediaDevices = new MediaDevicesWrapper(navigator.mediaDevices);
|
this.mediaDevices = new MediaDevicesWrapper(navigator.mediaDevices);
|
||||||
this.webRTC = new DOMWebRTC();
|
this.webRTC = new DOMWebRTC();
|
||||||
|
this._themeLoader = import.meta.env.DEV? null: new ThemeLoader(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
if (!this._config) {
|
try {
|
||||||
if (!this._configURL) {
|
await this.logger.run("Platform init", async (log) => {
|
||||||
throw new Error("Neither config nor configURL was provided!");
|
if (!this._config) {
|
||||||
}
|
if (!this._configURL) {
|
||||||
const {body}= await this.request(this._configURL, {method: "GET", format: "json", cache: true}).response();
|
throw new Error("Neither config nor configURL was provided!");
|
||||||
this._config = body;
|
}
|
||||||
|
const {status, body}= await this.request(this._configURL, {method: "GET", format: "json", cache: true}).response();
|
||||||
|
if (status === 404) {
|
||||||
|
throw new Error(`Could not find ${this._configURL}. Did you copy over config.sample.json?`);
|
||||||
|
} else if (status >= 400) {
|
||||||
|
throw new Error(`Got status ${status} while trying to fetch ${this._configURL}`);
|
||||||
|
}
|
||||||
|
this._config = body;
|
||||||
|
}
|
||||||
|
this.notificationService = new NotificationService(
|
||||||
|
this._serviceWorkerHandler,
|
||||||
|
this._config.push
|
||||||
|
);
|
||||||
|
if (this._themeLoader) {
|
||||||
|
const manifests = this.config["themeManifests"];
|
||||||
|
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) {
|
||||||
|
this._container.innerText = err.message;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
this.notificationService = new NotificationService(
|
|
||||||
this._serviceWorkerHandler,
|
|
||||||
this._config.push
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_createLogger(isDevelopment) {
|
_createLogger(isDevelopment) {
|
||||||
|
@ -314,6 +335,27 @@ export class Platform {
|
||||||
return DEFINE_VERSION;
|
return DEFINE_VERSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get themeLoader() {
|
||||||
|
return this._themeLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceStylesheet(newPath) {
|
||||||
|
const head = document.querySelector("head");
|
||||||
|
// remove default theme
|
||||||
|
document.querySelectorAll(".theme").forEach(e => e.remove());
|
||||||
|
// add new theme
|
||||||
|
const styleTag = document.createElement("link");
|
||||||
|
styleTag.href = `./${newPath}`;
|
||||||
|
styleTag.rel = "stylesheet";
|
||||||
|
styleTag.type = "text/css";
|
||||||
|
styleTag.className = "theme";
|
||||||
|
head.appendChild(styleTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
get description() {
|
||||||
|
return navigator.userAgent ?? "<unknown>";
|
||||||
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this._disposables.dispose();
|
this._disposables.dispose();
|
||||||
}
|
}
|
||||||
|
|
207
src/platform/web/ThemeLoader.ts
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 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 {ILogItem} from "../../logging/types.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 {
|
||||||
|
private _platform: Platform;
|
||||||
|
private _themeMapping: Record<string, ThemeInformation>;
|
||||||
|
|
||||||
|
constructor(platform: Platform) {
|
||||||
|
this._platform = platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(manifestLocations: string[], log?: ILogItem): Promise<void> {
|
||||||
|
await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => {
|
||||||
|
this._themeMapping = {};
|
||||||
|
const results = await Promise.all(
|
||||||
|
manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response())
|
||||||
|
);
|
||||||
|
results.forEach(({ body }) => this._populateThemeMap(body, log));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _populateThemeMap(manifest, log: ILogItem) {
|
||||||
|
log.wrap("populateThemeMap", (l) => {
|
||||||
|
/*
|
||||||
|
After build has finished, the source section of each theme manifest
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (defaultDarkVariant.id && defaultLightVariant.id) {
|
||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps theme display name to theme information */
|
||||||
|
get themeMapping(): Record<string, ThemeInformation> {
|
||||||
|
return this._themeMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { 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) {
|
||||||
|
return ColorSchemePreference.Dark;
|
||||||
|
}
|
||||||
|
else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
|
||||||
|
return ColorSchemePreference.Light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,9 @@ export class MediaDevicesWrapper implements IMediaDevices {
|
||||||
|
|
||||||
async getMediaTracks(audio: true | MediaDeviceInfo, video: boolean | MediaDeviceInfo): Promise<Stream> {
|
async getMediaTracks(audio: true | MediaDeviceInfo, video: boolean | MediaDeviceInfo): Promise<Stream> {
|
||||||
const stream = await this.mediaDevices.getUserMedia(this.getUserMediaContraints(audio, video));
|
const stream = await this.mediaDevices.getUserMedia(this.getUserMediaContraints(audio, video));
|
||||||
|
stream.addEventListener("removetrack", evt => {
|
||||||
|
console.log(`removing track ${evt.track.id} (${evt.track.kind}) from stream ${stream.id}`);
|
||||||
|
});
|
||||||
return stream as Stream;
|
return stream as Stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,11 +24,24 @@ const SPEAKING_SAMPLE_COUNT = 8; // samples
|
||||||
|
|
||||||
export class DOMWebRTC implements WebRTC {
|
export class DOMWebRTC implements WebRTC {
|
||||||
createPeerConnection(forceTURN: boolean, turnServers: RTCIceServer[], iceCandidatePoolSize): PeerConnection {
|
createPeerConnection(forceTURN: boolean, turnServers: RTCIceServer[], iceCandidatePoolSize): PeerConnection {
|
||||||
return new RTCPeerConnection({
|
const peerConn = new RTCPeerConnection({
|
||||||
iceTransportPolicy: forceTURN ? 'relay' : undefined,
|
iceTransportPolicy: forceTURN ? 'relay' : undefined,
|
||||||
iceServers: turnServers,
|
iceServers: turnServers,
|
||||||
iceCandidatePoolSize: iceCandidatePoolSize,
|
iceCandidatePoolSize: iceCandidatePoolSize,
|
||||||
}) as PeerConnection;
|
}) as PeerConnection;
|
||||||
|
return new Proxy(peerConn, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (prop === "close") {
|
||||||
|
console.trace("calling peerConnection.close");
|
||||||
|
}
|
||||||
|
const value = target[prop];
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return value.bind(target);
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareSenderForPurpose(peerConnection: PeerConnection, sender: Sender, purpose: SDPStreamMetadataPurpose): void {
|
prepareSenderForPurpose(peerConnection: PeerConnection, sender: Sender, purpose: SDPStreamMetadataPurpose): void {
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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, {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -95,8 +95,8 @@ let pendingFetchAbortController = new AbortController();
|
||||||
|
|
||||||
async function handleRequest(request) {
|
async function handleRequest(request) {
|
||||||
try {
|
try {
|
||||||
if (request.url.includes("config.json")) {
|
if (request.url.includes("config.json") || /theme-.+\.json/.test(request.url)) {
|
||||||
return handleConfigRequest(request);
|
return handleStaleWhileRevalidateRequest(request);
|
||||||
}
|
}
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
// rewrite / to /index.html so it hits the cache
|
// rewrite / to /index.html so it hits the cache
|
||||||
|
@ -123,9 +123,13 @@ async function handleRequest(request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfigRequest(request) {
|
/**
|
||||||
|
* Stale-while-revalidate caching for certain files
|
||||||
|
* see https://developer.chrome.com/docs/workbox/caching-strategies-overview/#stale-while-revalidate
|
||||||
|
*/
|
||||||
|
async function handleStaleWhileRevalidateRequest(request) {
|
||||||
let response = await readCache(request);
|
let response = await readCache(request);
|
||||||
const networkResponsePromise = fetchAndUpdateConfig(request);
|
const networkResponsePromise = fetchAndUpdateCache(request);
|
||||||
if (response) {
|
if (response) {
|
||||||
return response;
|
return response;
|
||||||
} else {
|
} else {
|
||||||
|
@ -133,7 +137,7 @@ async function handleConfigRequest(request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAndUpdateConfig(request) {
|
async function fetchAndUpdateCache(request) {
|
||||||
const response = await fetch(request, {
|
const response = await fetch(request, {
|
||||||
signal: pendingFetchAbortController.signal,
|
signal: pendingFetchAbortController.signal,
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -156,8 +160,14 @@ async function updateCache(request, response) {
|
||||||
cache.put(request, response.clone());
|
cache.put(request, response.clone());
|
||||||
} else if (request.url.startsWith(baseURL)) {
|
} else if (request.url.startsWith(baseURL)) {
|
||||||
let assetName = request.url.substr(baseURL.length);
|
let assetName = request.url.substr(baseURL.length);
|
||||||
|
let cacheName;
|
||||||
if (HASHED_CACHED_ON_REQUEST_ASSETS.includes(assetName)) {
|
if (HASHED_CACHED_ON_REQUEST_ASSETS.includes(assetName)) {
|
||||||
const cache = await caches.open(hashedCacheName);
|
cacheName = hashedCacheName;
|
||||||
|
} else if (UNHASHED_PRECACHED_ASSETS.includes(assetName)) {
|
||||||
|
cacheName = unhashedCacheName;
|
||||||
|
}
|
||||||
|
if (cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
await cache.put(request, response.clone());
|
await cache.put(request, response.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -46,6 +46,14 @@ limitations under the License.
|
||||||
font-size: calc(var(--avatar-size) * 0.6);
|
font-size: calc(var(--avatar-size) * 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hydrogen .avatar.size-96 {
|
||||||
|
--avatar-size: 96px;
|
||||||
|
width: var(--avatar-size);
|
||||||
|
height: var(--avatar-size);
|
||||||
|
line-height: var(--avatar-size);
|
||||||
|
font-size: calc(var(--avatar-size) * 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
.hydrogen .avatar.size-64 {
|
.hydrogen .avatar.size-64 {
|
||||||
--avatar-size: 64px;
|
--avatar-size: 64px;
|
||||||
width: var(--avatar-size);
|
width: var(--avatar-size);
|
||||||
|
|
219
src/platform/web/ui/css/themes/element/call.css
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.CallView {
|
||||||
|
height: 40vh;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView > * {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_members {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--background-color-secondary--darker-60);
|
||||||
|
padding: 12px;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 0;
|
||||||
|
list-style: none;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.StreamView {
|
||||||
|
display: grid;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.StreamView > * {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.StreamView video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.StreamView_avatar {
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.StreamView_muteStatus {
|
||||||
|
align-self: start;
|
||||||
|
justify-self: end;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 14px;
|
||||||
|
display: block;
|
||||||
|
background-color: var(--text-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.StreamView_muteStatus.microphoneMuted {
|
||||||
|
background-image: url("./icons/mic-muted.svg?primary=text-color--lighter-80");
|
||||||
|
}
|
||||||
|
|
||||||
|
.StreamView_muteStatus.cameraMuted {
|
||||||
|
background-image: url("./icons/cam-muted.svg?primary=text-color--lighter-80");
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_buttons {
|
||||||
|
align-self: end;
|
||||||
|
justify-self: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
/** Chrome (v100) requires this to make the buttons clickable
|
||||||
|
* where they overlap with the video element, even though
|
||||||
|
* the buttons come later in the DOM. */
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_buttons button {
|
||||||
|
border-radius: 100%;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_buttons button:disabled {
|
||||||
|
background-color: var(--accent-color--lighter-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_buttons .CallView_hangup {
|
||||||
|
background-color: var(--error-color);
|
||||||
|
background-image: url("./icons/hangup.svg?primary=background-color-primary");
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_buttons .CallView_hangup:disabled {
|
||||||
|
background-color: var(--error-color--lighter-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_buttons .CallView_mutedMicrophone {
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
background-image: url("./icons/mic-muted.svg?primary=text-color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_buttons .CallView_unmutedMicrophone {
|
||||||
|
background-image: url("./icons/mic-unmuted.svg?primary=background-color-primary");
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_buttons .CallView_mutedCamera {
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
background-image: url("./icons/cam-muted.svg?primary=text-color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_buttons .CallView_unmutedCamera {
|
||||||
|
background-image: url("./icons/cam-unmuted.svg?primary=background-color-primary");
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_members.size1 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallView_members.size2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* square */
|
||||||
|
.CallView_members.square.size3,
|
||||||
|
.CallView_members.square.size4 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.square.size5,
|
||||||
|
.CallView_members.square.size6 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.square.size7,
|
||||||
|
.CallView_members.square.size8,
|
||||||
|
.CallView_members.square.size9 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.square.size10 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
/** tall */
|
||||||
|
.CallView_members.tall.size3 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.tall.size4 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.tall.size5,
|
||||||
|
.CallView_members.tall.size6 {
|
||||||
|
grid-template-rows: repeat(3, 1fr);
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.tall.size7,
|
||||||
|
.CallView_members.tall.size8 {
|
||||||
|
grid-template-rows: repeat(4, 1fr);
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.tall.size9,
|
||||||
|
.CallView_members.tall.size10 {
|
||||||
|
grid-template-rows: repeat(5, 1fr);
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
/** wide */
|
||||||
|
.CallView_members.wide.size2 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
.CallView_members.wide.size3 {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.wide.size4 {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.wide.size5,
|
||||||
|
.CallView_members.wide.size6 {
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.wide.size7,
|
||||||
|
.CallView_members.wide.size8 {
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
.CallView_members.wide.size9,
|
||||||
|
.CallView_members.wide.size10 {
|
||||||
|
grid-template-rows: repeat(3, 1fr);
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0.290472 1.37627C0.677768 0.985743 1.3057 0.985743 1.69299 1.37627L16.569 16.3762C16.9563 16.7668 16.9563 17.3999 16.569 17.7904C16.1817 18.181 15.5538 18.181 15.1665 17.7904L0.290472 2.79048C-0.096824 2.39995 -0.096824 1.76679 0.290472 1.37627Z" fill="#ff00ff"></path><path d="M0.597515 5.19186C0.323238 5.646 0.165249 6.17941 0.165249 6.75001V14.0833C0.165249 15.7402 1.49729 17.0833 3.14045 17.0833H12.363L0.639137 5.2371C0.624608 5.22242 0.610733 5.20733 0.597515 5.19186Z" fill="#ff00ff"></path><path d="M14.2148 6.75002V11.9031L6.14598 3.75002H11.2396C12.8828 3.75002 14.2148 5.09317 14.2148 6.75002Z" fill="#ff00ff"></path><path d="M18.3887 5.88312L15.8677 7.91669V12.9167L18.3887 14.9503C19.038 15.4741 19.9999 15.0079 19.9999 14.1694V6.66399C19.9999 5.82548 19.038 5.35931 18.3887 5.88312Z" fill="#ff00ff"></path></svg>
|
After Width: | Height: | Size: 934 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2.50001 3.33334C1.11929 3.33334 0 4.45264 0 5.83336V14.1667C0 15.5474 1.11929 16.6667 2.50001 16.6667H11.6667C13.0474 16.6667 14.1667 15.5474 14.1667 14.1667V5.83336C14.1667 4.45264 13.0474 3.33334 11.6667 3.33334H2.50001Z" fill="#ff00ff"></path><path d="M18.6462 5.24983L15.8334 7.50004V12.5001L18.6462 14.7503C19.1918 15.1868 20.0001 14.7983 20.0001 14.0996V5.90056C20.0001 5.2018 19.1918 4.81332 18.6462 5.24983Z" fill="#ff00ff"></path></svg>
|
After Width: | Height: | Size: 551 B |
3
src/platform/web/ui/css/themes/element/icons/hangup.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12.0084 7.75648C10.3211 7.69163 6.85136 8.12949 6.00781 8.35133C5.95792 8.36445 5.90044 8.37912 5.83616 8.39552C4.54101 8.72607 0.48272 9.76183 0.0442423 13.0436C-0.295466 15.5862 1.40575 16.3558 2.25618 16.2386C2.84479 16.1648 4.5301 15.8983 6.08724 15.6189C7.61629 15.3446 7.61551 14.3359 7.61499 13.6538C7.61498 13.6413 7.61497 13.6288 7.61497 13.6165L7.61497 12.2453C7.61497 11.8961 7.94315 11.6942 8.3958 11.6396C9.99822 11.422 11.3359 11.4213 12.0055 11.4213L12.0112 11.4213C12.6807 11.4213 14.0018 11.422 15.6042 11.6396C16.0569 11.6942 16.385 11.8961 16.385 12.2453L16.385 13.6165C16.385 13.6289 16.385 13.6413 16.385 13.6538C16.3845 14.3359 16.3837 15.3446 17.9128 15.619C19.4699 15.8983 21.1552 16.1648 21.7438 16.2386C22.5942 16.3558 24.2955 15.5862 23.9558 13.0436C23.5173 9.76183 19.459 8.72608 18.1638 8.39553C18.0996 8.37913 18.0421 8.36446 17.9922 8.35134C17.1487 8.1295 13.6956 7.69163 12.0084 7.75648Z" fill="#ff00ff"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1 KiB |
19
src/platform/web/ui/css/themes/element/icons/mic-muted.svg
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="m 4.1217459,2.7782531 c -0.3709886,-0.3710026 -0.9725054,-0.3710026 -1.343494,0 -0.3710025,0.3709886 -0.3710025,0.9725053 0,1.3434939 L 8.1999992,9.5435095 V 12 c 0,2.098686 1.7013144,3.8 3.7999998,3.8 0.704722,0 1.364635,-0.19183 1.93037,-0.526122 l 1.372342,1.372328 C 14.370936,17.309764 13.231045,17.7 11.999999,17.7 8.8519707,17.7 6.2999991,15.148029 6.2999991,12 c 0,-0.524663 -0.4253364,-0.95 -0.95,-0.95 -0.5246638,0 -0.9500002,0.425337 -0.9500002,0.95 0,3.875644 2.9009978,7.073739 6.6500001,7.541217 V 20.55 c 0,0.52471 0.425337,0.95 0.95,0.95 0.524664,0 0.95,-0.42529 0.95,-0.95 v -1.008783 c 1.387668,-0.173095 2.659163,-0.720139 3.710352,-1.537372 l 3.217902,3.217902 c 0.371004,0.371004 0.97249,0.371004 1.343494,0 0.371004,-0.371004 0.371004,-0.97249 0,-1.343494 z"
|
||||||
|
fill="#ff00ff" />
|
||||||
|
<path
|
||||||
|
d="m 17.50209,13.494223 1.488825,1.49188 C 19.383012,14.069434 19.6,13.060061 19.6,12 c 0,-0.524663 -0.425289,-0.95 -0.95,-0.95 -0.52471,0 -0.95,0.425337 -0.95,0.95 0,0.517048 -0.06887,1.017997 -0.19791,1.494223 z"
|
||||||
|
fill="#ff00ff" />
|
||||||
|
<path
|
||||||
|
d="M 8.609236,4.5827722 15.8,11.788534 V 6.3000001 c 0,-2.0986857 -1.701315,-3.8 -3.800001,-3.8 -1.480728,0 -2.7636389,0.8469192 -3.390763,2.0827721 z"
|
||||||
|
fill="#ff00ff" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
16
src/platform/web/ui/css/themes/element/icons/mic-unmuted.svg
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M 8.06739,6.2516702 C 8.06739,4.1796776 9.8281549,2.5 12.000157,2.5 c 2.172018,0 3.932783,1.6796776 3.932783,3.7516702 v 5.6107078 c 0,2.071992 -1.760765,3.75167 -3.932783,3.75167 -2.1720021,0 -3.932767,-1.679678 -3.932767,-3.75167 z"
|
||||||
|
fill="#ff00ff" />
|
||||||
|
<path
|
||||||
|
d="m 5.1176439,10.664726 c 0.69616,0 1.2605143,0.53836 1.2605143,1.20246 0,2.951033 2.507364,5.346879 5.6068308,5.354479 0.0051,0 0.01014,0 0.01523,0 0.0051,0 0.0101,0 0.01514,0 3.099312,-0.0078 5.606474,-2.403554 5.606474,-5.354479 0,-0.6641 0.564416,-1.20246 1.260515,-1.20246 0.696097,0 1.260514,0.53836 1.260514,1.20246 0,3.878047 -2.984629,7.089761 -6.882141,7.66705 v 0.763258 c 0,0.664146 -0.564339,1.202506 -1.260499,1.202506 -0.69616,0 -1.260514,-0.53836 -1.260514,-1.202506 v -0.763258 c -3.8976481,-0.577135 -6.8825558,-3.788863 -6.8825558,-7.66705 0,-0.6641 0.5643543,-1.20246 1.2604987,-1.20246 z"
|
||||||
|
fill="#ff00ff" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 7C0 5.34315 1.34315 4 3 4H14C15.6569 4 17 5.34315 17 7V17C17 18.6569 15.6569 20 14 20H3C1.34315 20 0 18.6569 0 17V7Z" fill="#ff00ff"/>
|
||||||
|
<path d="M19 9L22.3753 6.29976C23.0301 5.77595 24 6.24212 24 7.08062V16.9194C24 17.7579 23.0301 18.2241 22.3753 17.7002L19 15V9Z" fill="#ff00ff"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 397 B |
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6.02698 13.9613C7.16801 15.1932 9.91484 17.3263 10.6635 17.7641C10.7078 17.79 10.7585 17.8201 10.8152 17.8538C11.9576 18.5329 15.5373 20.6609 18.1454 18.6694C20.1661 17.1266 19.5091 15.3909 18.8289 14.875C18.3633 14.5128 16.9914 13.5145 15.7006 12.6152C14.4331 11.7322 13.7268 12.4397 13.2492 12.918C13.2404 12.9268 13.2317 12.9355 13.2231 12.9442L12.2621 13.9051C12.0174 14.1498 11.6451 14.0605 11.2886 13.7804C10.0092 12.8061 9.06809 11.8659 8.59723 11.395L8.59326 11.391C8.12246 10.9202 7.19387 9.99076 6.21957 8.7114C5.93949 8.35485 5.85018 7.9826 6.09489 7.73789L7.05586 6.77693C7.06448 6.7683 7.0732 6.7596 7.08199 6.75082C7.56034 6.27321 8.2678 5.56684 7.38479 4.29937C6.48555 3.0086 5.4872 1.6367 5.125 1.17106C4.60907 0.490937 2.87345 -0.166084 1.33056 1.85458C-0.660932 4.46274 1.46708 8.04241 2.1462 9.18482C2.17991 9.24152 2.21005 9.29221 2.23593 9.33649C2.67367 10.0851 4.79507 12.8203 6.02698 13.9613Z" fill="#ff00ff"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1 KiB |
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ limitations under the License.
|
||||||
@import url('../../main.css');
|
@import url('../../main.css');
|
||||||
@import url('inter.css');
|
@import url('inter.css');
|
||||||
@import url('timeline.css');
|
@import url('timeline.css');
|
||||||
|
@import url('call.css');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
@ -1155,56 +1156,3 @@ button.RoomDetailsView_row::after {
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-size: 36px;
|
background-size: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.CallView {
|
|
||||||
max-height: 50vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CallView ul {
|
|
||||||
display: flex;
|
|
||||||
margin: 0;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.StreamView {
|
|
||||||
width: 360px;
|
|
||||||
min-height: 200px;
|
|
||||||
border: 2px var(--accent-color) solid;
|
|
||||||
display: grid;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.StreamView > * {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.StreamView video {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.StreamView_avatar {
|
|
||||||
align-self: center;
|
|
||||||
justify-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.StreamView_muteStatus {
|
|
||||||
align-self: end;
|
|
||||||
justify-self: start;
|
|
||||||
color: var(--text-color--lighter-80);
|
|
||||||
}
|
|
||||||
|
|
||||||
.StreamView_muteStatus.microphoneMuted::before {
|
|
||||||
content: "mic muted";
|
|
||||||
}
|
|
||||||
|
|
||||||
.StreamView_muteStatus.cameraMuted::before {
|
|
||||||
content: "cam muted";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -17,26 +17,81 @@ limitations under the License.
|
||||||
import {TemplateView, Builder} from "../../general/TemplateView";
|
import {TemplateView, Builder} from "../../general/TemplateView";
|
||||||
import {AvatarView} from "../../AvatarView";
|
import {AvatarView} from "../../AvatarView";
|
||||||
import {ListView} from "../../general/ListView";
|
import {ListView} from "../../general/ListView";
|
||||||
|
import {classNames} from "../../general/html";
|
||||||
import {Stream} from "../../../../types/MediaDevices";
|
import {Stream} from "../../../../types/MediaDevices";
|
||||||
import type {CallViewModel, CallMemberViewModel, IStreamViewModel} from "../../../../../domain/session/room/CallViewModel";
|
import type {CallViewModel, CallMemberViewModel, IStreamViewModel} from "../../../../../domain/session/room/CallViewModel";
|
||||||
|
|
||||||
export class CallView extends TemplateView<CallViewModel> {
|
export class CallView extends TemplateView<CallViewModel> {
|
||||||
|
private resizeObserver?: ResizeObserver;
|
||||||
|
|
||||||
render(t: Builder<CallViewModel>, vm: CallViewModel): Element {
|
render(t: Builder<CallViewModel>, vm: CallViewModel): Element {
|
||||||
|
const members = t.view(new ListView({
|
||||||
|
className: "CallView_members",
|
||||||
|
list: vm.memberViewModels
|
||||||
|
}, vm => new StreamView(vm))) as HTMLElement;
|
||||||
|
this.bindMembersCssClasses(t, members);
|
||||||
return t.div({class: "CallView"}, [
|
return t.div({class: "CallView"}, [
|
||||||
t.p(vm => `Call ${vm.name} (${vm.id})`),
|
members,
|
||||||
t.view(new ListView({list: vm.memberViewModels}, vm => new StreamView(vm))),
|
//t.p(vm => `Call ${vm.name}`),
|
||||||
t.div({class: "buttons"}, [
|
t.div({class: "CallView_buttons"}, [
|
||||||
t.button({onClick: () => vm.leave()}, "Leave"),
|
t.button({className: {
|
||||||
t.button({onClick: () => vm.toggleVideo()}, "Toggle video"),
|
"CallView_mutedMicrophone": vm => vm.isMicrophoneMuted,
|
||||||
|
"CallView_unmutedMicrophone": vm => !vm.isMicrophoneMuted,
|
||||||
|
}, onClick: disableTargetCallback(() => vm.toggleMicrophone())}),
|
||||||
|
t.button({className: {
|
||||||
|
"CallView_mutedCamera": vm => vm.isCameraMuted,
|
||||||
|
"CallView_unmutedCamera": vm => !vm.isCameraMuted,
|
||||||
|
}, onClick: disableTargetCallback(() => vm.toggleCamera())}),
|
||||||
|
t.button({className: "CallView_hangup", onClick: disableTargetCallback(() => vm.hangup())}),
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bindMembersCssClasses(t, members) {
|
||||||
|
t.mapSideEffect(vm => vm.memberCount, count => {
|
||||||
|
members.classList.forEach((c, _, list) => {
|
||||||
|
if (c.startsWith("size")) {
|
||||||
|
list.remove(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
members.classList.add(`size${count}`);
|
||||||
|
});
|
||||||
|
// update classes describing aspect ratio categories
|
||||||
|
if (typeof ResizeObserver === "function") {
|
||||||
|
const set = (c, flag) => {
|
||||||
|
if (flag) {
|
||||||
|
members.classList.add(c);
|
||||||
|
} else {
|
||||||
|
members.classList.remove(c);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
|
const ar = members.clientWidth / members.clientHeight;
|
||||||
|
const isTall = ar < 0.5;
|
||||||
|
const isSquare = !isTall && ar < 1.8
|
||||||
|
const isWide = !isTall && !isSquare;
|
||||||
|
set("tall", isTall);
|
||||||
|
set("square", isSquare);
|
||||||
|
set("wide", isWide);
|
||||||
|
});
|
||||||
|
this.resizeObserver!.observe(members);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public unmount() {
|
||||||
|
if (this.resizeObserver) {
|
||||||
|
this.resizeObserver.unobserve((this.root()! as Element).querySelector(".CallView_members"));
|
||||||
|
this.resizeObserver = undefined;
|
||||||
|
}
|
||||||
|
super.unmount();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class StreamView extends TemplateView<IStreamViewModel> {
|
class StreamView extends TemplateView<IStreamViewModel> {
|
||||||
render(t: Builder<IStreamViewModel>, vm: IStreamViewModel): Element {
|
render(t: Builder<IStreamViewModel>, vm: IStreamViewModel): Element {
|
||||||
const video = t.video({
|
const video = t.video({
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
|
disablePictureInPicture: true,
|
||||||
className: {
|
className: {
|
||||||
hidden: vm => vm.isCameraMuted
|
hidden: vm => vm.isCameraMuted
|
||||||
}
|
}
|
||||||
|
@ -49,7 +104,7 @@ class StreamView extends TemplateView<IStreamViewModel> {
|
||||||
t.div({className: {
|
t.div({className: {
|
||||||
StreamView_avatar: true,
|
StreamView_avatar: true,
|
||||||
hidden: vm => !vm.isCameraMuted
|
hidden: vm => !vm.isCameraMuted
|
||||||
}}, t.view(new AvatarView(vm, 64), {parentProvidesUpdates: true})),
|
}}, t.view(new AvatarView(vm, 96), {parentProvidesUpdates: true})),
|
||||||
t.div({
|
t.div({
|
||||||
className: {
|
className: {
|
||||||
StreamView_muteStatus: true,
|
StreamView_muteStatus: true,
|
||||||
|
@ -60,4 +115,18 @@ class StreamView extends TemplateView<IStreamViewModel> {
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update(value, props) {
|
||||||
|
super.update(value);
|
||||||
|
// update the AvatarView as we told it to not subscribe itself with parentProvidesUpdates
|
||||||
|
this.updateSubViews(value, props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -97,16 +98,24 @@ export class SettingsView extends TemplateView {
|
||||||
settingNodes.push(
|
settingNodes.push(
|
||||||
t.h3("Preferences"),
|
t.h3("Preferences"),
|
||||||
row(t, vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)),
|
row(t, vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)),
|
||||||
|
t.if(vm => !import.meta.env.DEV && vm.activeTheme, (t, vm) => {
|
||||||
|
return row(t, vm.i18n`Use the following theme`, this._themeOptions(t, vm));
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const logButtons = [t.button({onClick: () => vm.exportLogs()}, "Export")];
|
const logButtons = [];
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
logButtons.push(t.button({onClick: () => openLogs(vm)}, "Open logs"));
|
logButtons.push(t.button({onClick: () => openLogs(vm)}, "Open logs"));
|
||||||
}
|
}
|
||||||
|
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`, logButtons),
|
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"), "."]),
|
||||||
t.p([])
|
t.p([])
|
||||||
|
@ -140,6 +149,56 @@ export class SettingsView extends TemplateView {
|
||||||
vm.i18n`no resizing`;
|
vm.i18n`no resizing`;
|
||||||
})];
|
})];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_themeOptions(t, vm) {
|
||||||
|
const { themeName: activeThemeName, themeVariant: activeThemeVariant } = vm.activeTheme;
|
||||||
|
const optionTags = [];
|
||||||
|
// 1. render the dropdown containing the themes
|
||||||
|
for (const name of Object.keys(vm.themeMapping)) {
|
||||||
|
optionTags.push( t.option({ value: name, selected: name === activeThemeName} , name));
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -16,25 +16,39 @@ export default defineConfig(({mode}) => {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
assetFileNames: (asset) => asset.name.includes("config.json") ? "assets/[name][extname]": "assets/[name].[hash][extname]",
|
assetFileNames: (asset) => {
|
||||||
|
if (asset.name.includes("config.json")) {
|
||||||
|
return "[name][extname]";
|
||||||
|
}
|
||||||
|
else if (asset.name.match(/theme-.+\.json/)) {
|
||||||
|
return "assets/[name][extname]";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "assets/[name].[hash][extname]";
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
themeBuilder({
|
themeBuilder({
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
themes: {"element": "./src/platform/web/ui/css/themes/element"},
|
themes: {
|
||||||
|
element: "./src/platform/web/ui/css/themes/element",
|
||||||
|
},
|
||||||
default: "element",
|
default: "element",
|
||||||
},
|
},
|
||||||
compiledVariables
|
compiledVariables,
|
||||||
}),
|
}),
|
||||||
// important this comes before service worker
|
// important this comes before service worker
|
||||||
// otherwise the manifest and the icons it refers to won't be cached
|
// otherwise the manifest and the icons it refers to won't be cached
|
||||||
injectWebManifest("assets/manifest.json"),
|
injectWebManifest("assets/manifest.json"),
|
||||||
injectServiceWorker("./src/platform/web/sw.js", ["index.html"], {
|
injectServiceWorker("./src/platform/web/sw.js", findUnhashedFileNamesFromBundle, {
|
||||||
// placeholders to replace at end of build by chunk name
|
// placeholders to replace at end of build by chunk name
|
||||||
"index": {DEFINE_GLOBAL_HASH: definePlaceholders.DEFINE_GLOBAL_HASH},
|
index: {
|
||||||
"sw": definePlaceholders
|
DEFINE_GLOBAL_HASH: definePlaceholders.DEFINE_GLOBAL_HASH,
|
||||||
|
},
|
||||||
|
sw: definePlaceholders,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
define: Object.assign({
|
define: Object.assign({
|
||||||
|
@ -42,3 +56,16 @@ export default defineConfig(({mode}) => {
|
||||||
}, definePlaceholders),
|
}, definePlaceholders),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function findUnhashedFileNamesFromBundle(bundle) {
|
||||||
|
const names = ["index.html"];
|
||||||
|
for (const fileName of Object.keys(bundle)) {
|
||||||
|
if (fileName.includes("config.json")) {
|
||||||
|
names.push(fileName);
|
||||||
|
}
|
||||||
|
if (/theme-.+\.json/.test(fileName)) {
|
||||||
|
names.push(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
|
@ -3,11 +3,35 @@ 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 have a consisent path
|
||||||
|
// that we can reference in our `package.json` `exports`. And so people can import
|
||||||
|
// them with a consistent path.
|
||||||
|
const pathsToExport = [
|
||||||
|
"main.js",
|
||||||
|
"download-sandbox.html",
|
||||||
|
"theme-element-light.css",
|
||||||
|
"theme-element-dark.css",
|
||||||
|
];
|
||||||
|
|
||||||
export default mergeOptions(commonOptions, {
|
export default mergeOptions(commonOptions, {
|
||||||
root: "src/",
|
root: "src/",
|
||||||
base: "./",
|
base: "./",
|
||||||
build: {
|
build: {
|
||||||
outDir: "../target/asset-build/",
|
outDir: "../target/asset-build/",
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
assetFileNames: (chunkInfo) => {
|
||||||
|
// Get rid of the hash so we can consistently reference these
|
||||||
|
// files in our `package.json` `exports`. And so people can
|
||||||
|
// import them with a consistent path.
|
||||||
|
if(pathsToExport.includes(path.basename(chunkInfo.name))) {
|
||||||
|
return "assets/[name].[ext]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "assets/[name]-[hash][extname]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
themeBuilder({
|
themeBuilder({
|
||||||
|
|
230
yarn.lock
|
@ -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"
|
||||||
|
|
||||||
"@matrixdotorg/structured-logviewer@^0.0.1":
|
"@matrixdotorg/structured-logviewer@^0.0.1":
|
||||||
version "0.0.1"
|
version "0.0.1"
|
||||||
|
@ -503,66 +503,141 @@ entities@^2.0.0:
|
||||||
version "4.2.8"
|
version "4.2.8"
|
||||||
resolved "https://github.com/bwindels/es6-promise.git#112f78f5829e627055b0ff56a52fecb63f6003b1"
|
resolved "https://github.com/bwindels/es6-promise.git#112f78f5829e627055b0ff56a52fecb63f6003b1"
|
||||||
|
|
||||||
|
esbuild-android-64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.31.tgz#4b7dfbbeee62b3495ba78220b85fb590eb68d5bf"
|
||||||
|
integrity sha512-MYkuJ91w07nGmr4EouejOZK2j/f5TCnsKxY8vRr2+wpKKfHD1LTJK28VbZa+y1+AL7v1V9G98ezTUwsV3CmXNw==
|
||||||
|
|
||||||
esbuild-android-arm64@0.13.15:
|
esbuild-android-arm64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz#3fc3ff0bab76fe35dd237476b5d2b32bb20a3d44"
|
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz#3fc3ff0bab76fe35dd237476b5d2b32bb20a3d44"
|
||||||
integrity sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==
|
integrity sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==
|
||||||
|
|
||||||
|
esbuild-android-arm64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.31.tgz#24c3d693924e044fb0d23206c3e627502b10b930"
|
||||||
|
integrity sha512-0rkH/35s7ZVcsw6nS0IAkR0dekSbjZGWdlOAf3jV0lGoPqqw0x6/TmaV9w7DQgUERTH1ApmPlpAMU4kVkCq9Jg==
|
||||||
|
|
||||||
esbuild-darwin-64@0.13.15:
|
esbuild-darwin-64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz#8e9169c16baf444eacec60d09b24d11b255a8e72"
|
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz#8e9169c16baf444eacec60d09b24d11b255a8e72"
|
||||||
integrity sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==
|
integrity sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==
|
||||||
|
|
||||||
|
esbuild-darwin-64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.31.tgz#285fbdb6dc74d4410f43dee59e6a14ebff82a9d7"
|
||||||
|
integrity sha512-kP6xPZHxtJa36Hb0jC05L3VzQSZBW2f3bpnQS20czXTRGEmM2GDiYpGdI5g2QYaw6vC4PYXjnigq8usd9g9jnQ==
|
||||||
|
|
||||||
esbuild-darwin-arm64@0.13.15:
|
esbuild-darwin-arm64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz#1b07f893b632114f805e188ddfca41b2b778229a"
|
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz#1b07f893b632114f805e188ddfca41b2b778229a"
|
||||||
integrity sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ==
|
integrity sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ==
|
||||||
|
|
||||||
|
esbuild-darwin-arm64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.31.tgz#b39c471a8134ce2c7811eb96fab9c500b256261c"
|
||||||
|
integrity sha512-1ZMog4hkNsdBGtDDtsftUqX6S9N52gEx4vX5aVehsSptgoBFIar1XrPiBTQty7YNH+bJasTpSVaZQgElCVvPKQ==
|
||||||
|
|
||||||
esbuild-freebsd-64@0.13.15:
|
esbuild-freebsd-64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz#0b8b7eca1690c8ec94c75680c38c07269c1f4a85"
|
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz#0b8b7eca1690c8ec94c75680c38c07269c1f4a85"
|
||||||
integrity sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==
|
integrity sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==
|
||||||
|
|
||||||
|
esbuild-freebsd-64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.31.tgz#7ca700ef60ae12154bae63094ad41b21c6ae1a23"
|
||||||
|
integrity sha512-Zo0BYj7QpVFWoUpkv6Ng0RO2eJ4zk/WDaHMO88+jr5HuYmxsOre0imgwaZVPquTuJnCvL1G48BFucJ3tFflSeQ==
|
||||||
|
|
||||||
esbuild-freebsd-arm64@0.13.15:
|
esbuild-freebsd-arm64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz#2e1a6c696bfdcd20a99578b76350b41db1934e52"
|
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz#2e1a6c696bfdcd20a99578b76350b41db1934e52"
|
||||||
integrity sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==
|
integrity sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==
|
||||||
|
|
||||||
|
esbuild-freebsd-arm64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.31.tgz#f793085c7184fcd08649b8d185edc5c2ce112e82"
|
||||||
|
integrity sha512-t85bS6jbRpmdjr4pdr/FY/fpx8lo1vv9S7BAs2EsXKJQhRDMIiC3QW+k2acYJoRuqirlvJcJVFQGCq/PfyC1kA==
|
||||||
|
|
||||||
esbuild-linux-32@0.13.15:
|
esbuild-linux-32@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz#6fd39f36fc66dd45b6b5f515728c7bbebc342a69"
|
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz#6fd39f36fc66dd45b6b5f515728c7bbebc342a69"
|
||||||
integrity sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==
|
integrity sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==
|
||||||
|
|
||||||
|
esbuild-linux-32@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.31.tgz#cac97ec7da6fbde0c21dbe08babd0d2a034f317d"
|
||||||
|
integrity sha512-XYtOk/GodSkv+UOYVwryGpGPuFnszsMvRMKq6cIUfFfdssHuKDsU9IZveyCG44J106J39ABenQ5EetbYtVJHUw==
|
||||||
|
|
||||||
esbuild-linux-64@0.13.15:
|
esbuild-linux-64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz#9cb8e4bcd7574e67946e4ee5f1f1e12386bb6dd3"
|
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz#9cb8e4bcd7574e67946e4ee5f1f1e12386bb6dd3"
|
||||||
integrity sha512-eCKzkNSLywNeQTRBxJRQ0jxRCl2YWdMB3+PkWFo2BBQYC5mISLIVIjThNtn6HUNqua1pnvgP5xX0nHbZbPj5oA==
|
integrity sha512-eCKzkNSLywNeQTRBxJRQ0jxRCl2YWdMB3+PkWFo2BBQYC5mISLIVIjThNtn6HUNqua1pnvgP5xX0nHbZbPj5oA==
|
||||||
|
|
||||||
|
esbuild-linux-64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.31.tgz#ec94cd5228e6777d2feb3c24a1fe1cbf8817d6da"
|
||||||
|
integrity sha512-Zf9CZxAxaXWHLqCg/QZ/hs0RU0XV3IBxV+ENQzy00S4QOTnZAvSLgPciILHHrVJ0lPIlb4XzAqlLM5y6iI2LIw==
|
||||||
|
|
||||||
esbuild-linux-arm64@0.13.15:
|
esbuild-linux-arm64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz#3891aa3704ec579a1b92d2a586122e5b6a2bfba1"
|
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz#3891aa3704ec579a1b92d2a586122e5b6a2bfba1"
|
||||||
integrity sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==
|
integrity sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==
|
||||||
|
|
||||||
|
esbuild-linux-arm64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.31.tgz#d119188fccd6384db5c703de24c46dacaee3e9e8"
|
||||||
|
integrity sha512-V/H0tv+xpQ9IOHM+o85oCKNNidIEc5CcnDWl0V+hPd2F03dqdbFkWPBGphx8rD4JSQn6UefUQ1iH7y1qIzO8Fw==
|
||||||
|
|
||||||
esbuild-linux-arm@0.13.15:
|
esbuild-linux-arm@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz#8a00e99e6a0c6c9a6b7f334841364d8a2b4aecfe"
|
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz#8a00e99e6a0c6c9a6b7f334841364d8a2b4aecfe"
|
||||||
integrity sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==
|
integrity sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==
|
||||||
|
|
||||||
|
esbuild-linux-arm@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.31.tgz#63e10846886901e5632a591d44160f95c5d12ba7"
|
||||||
|
integrity sha512-RpiaeHPRlgCCDskxoyIsI49BhcDtZ4cl8+SLffizDm0yMNWP538SUg0ezQ2TTOPj3/svaGIbkRDwYtAon0Sjkg==
|
||||||
|
|
||||||
esbuild-linux-mips64le@0.13.15:
|
esbuild-linux-mips64le@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz#36b07cc47c3d21e48db3bb1f4d9ef8f46aead4f7"
|
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz#36b07cc47c3d21e48db3bb1f4d9ef8f46aead4f7"
|
||||||
integrity sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==
|
integrity sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==
|
||||||
|
|
||||||
|
esbuild-linux-mips64le@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.31.tgz#1cd44f72cde6489a5d6deea7c54efa6f3d6590ee"
|
||||||
|
integrity sha512-9/oBfAckInRuUg6AEgdCLLn6KJ6UOJDOLmUinTsReVSg6AfV6wxYQJq9iQM2idRogP7GUpomJ+bvCdWXpotQRQ==
|
||||||
|
|
||||||
esbuild-linux-ppc64le@0.13.15:
|
esbuild-linux-ppc64le@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz#f7e6bba40b9a11eb9dcae5b01550ea04670edad2"
|
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz#f7e6bba40b9a11eb9dcae5b01550ea04670edad2"
|
||||||
integrity sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==
|
integrity sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==
|
||||||
|
|
||||||
|
esbuild-linux-ppc64le@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.31.tgz#3b5ccc05e5b8ef5c494f30a61fdd27811d2bbeeb"
|
||||||
|
integrity sha512-NMcb14Pg+8q8raGkzor9/R3vQwKzgxE3694BtO2SDLBwJuL2C1dQ1ZtM1t7ZvArQBgT8RiZVxb0/3fD+qGNk7g==
|
||||||
|
|
||||||
|
esbuild-linux-riscv64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.31.tgz#d74ca78c8ed1d9b40bc907a9e3ef6e83fc06189c"
|
||||||
|
integrity sha512-l13yvmsVfawAnoYfcpuvml+nTlrOmtdceXYufSkXl2DOb0JKcuR6ARlAzuQCDcpo49SOJy1cCxpwlOIsUQBfzA==
|
||||||
|
|
||||||
|
esbuild-linux-s390x@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.31.tgz#1bd547b8b027e323b77a838d265cb56ece2543af"
|
||||||
|
integrity sha512-GIwV9mY3koYja9MCSkKLk1P7rj+MkPV0UsGsZ575hEcIBrXeKN9jBi6X/bxDDPEN/SUAH35cJhBNrZU4x9lEfg==
|
||||||
|
|
||||||
esbuild-netbsd-64@0.13.15:
|
esbuild-netbsd-64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz#a2fedc549c2b629d580a732d840712b08d440038"
|
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz#a2fedc549c2b629d580a732d840712b08d440038"
|
||||||
integrity sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==
|
integrity sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==
|
||||||
|
|
||||||
|
esbuild-netbsd-64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.31.tgz#964a45dbad4fac92aa0a15056e38a182735bd6c6"
|
||||||
|
integrity sha512-bJ+pyLvKQm+Obp5k7/Wk8e9Gdkls56F1aiI3uptoIfOIUqsZImH7pDyTrSufwqsFp62kO9LRuwXnjDwQtPyhFQ==
|
||||||
|
|
||||||
esbuild-node-loader@^0.6.3:
|
esbuild-node-loader@^0.6.3:
|
||||||
version "0.6.3"
|
version "0.6.3"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-node-loader/-/esbuild-node-loader-0.6.3.tgz#3b90012f8bc2fcbb2ef76a659482c2c99840c5e8"
|
resolved "https://registry.yarnpkg.com/esbuild-node-loader/-/esbuild-node-loader-0.6.3.tgz#3b90012f8bc2fcbb2ef76a659482c2c99840c5e8"
|
||||||
|
@ -575,27 +650,52 @@ esbuild-openbsd-64@0.13.15:
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz#b22c0e5806d3a1fbf0325872037f885306b05cd7"
|
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz#b22c0e5806d3a1fbf0325872037f885306b05cd7"
|
||||||
integrity sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==
|
integrity sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==
|
||||||
|
|
||||||
|
esbuild-openbsd-64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.31.tgz#7d2a1d46450321b0459263d3e7072e6d3924ce46"
|
||||||
|
integrity sha512-NRAAPPca05H9j9Xab0kVXK0V6/pyZGGy8d2Y8KS0BMwWEydlD4KCJDmH8/7bWCKYLRGOOCE9/GPBJyPWHFW3sg==
|
||||||
|
|
||||||
esbuild-sunos-64@0.13.15:
|
esbuild-sunos-64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz#d0b6454a88375ee8d3964daeff55c85c91c7cef4"
|
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz#d0b6454a88375ee8d3964daeff55c85c91c7cef4"
|
||||||
integrity sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==
|
integrity sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==
|
||||||
|
|
||||||
|
esbuild-sunos-64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.31.tgz#3b3e4363951cd1dda14a14fee6d94ca426108e0c"
|
||||||
|
integrity sha512-9uA+V8w9Eehu4ldb95lPWdgCMcMO5HH6pXmfkk5usn3JsSZxKdLKsXB4hYgP80wscZvVYXJl2G+KNxsUTfPhZw==
|
||||||
|
|
||||||
esbuild-windows-32@0.13.15:
|
esbuild-windows-32@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz#c96d0b9bbb52f3303322582ef8e4847c5ad375a7"
|
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz#c96d0b9bbb52f3303322582ef8e4847c5ad375a7"
|
||||||
integrity sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==
|
integrity sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==
|
||||||
|
|
||||||
|
esbuild-windows-32@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.31.tgz#997026a41c04535bfb7c014a0458940b49145820"
|
||||||
|
integrity sha512-VGdncQTqoxD9q3v/dk0Yugbmx2FzOkcs0OemBYc1X9KXOLQYH0uQbLJIckZdZOC3J+JKSExbYFrzYCOwWPuNyA==
|
||||||
|
|
||||||
esbuild-windows-64@0.13.15:
|
esbuild-windows-64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz#1f79cb9b1e1bb02fb25cd414cb90d4ea2892c294"
|
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz#1f79cb9b1e1bb02fb25cd414cb90d4ea2892c294"
|
||||||
integrity sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==
|
integrity sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==
|
||||||
|
|
||||||
|
esbuild-windows-64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.31.tgz#5d4b0ea686c9b60036303b3415c472f2761bdafc"
|
||||||
|
integrity sha512-v/2ye5zBqpmCzi3bLCagStbNQlnOsY7WtMrD2Q0xZxeSIXONxji15KYtVee5o7nw4lXWbQSS1BL8G6BBMvtq4A==
|
||||||
|
|
||||||
esbuild-windows-arm64@0.13.15:
|
esbuild-windows-arm64@0.13.15:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz#482173070810df22a752c686509c370c3be3b3c3"
|
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz#482173070810df22a752c686509c370c3be3b3c3"
|
||||||
integrity sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==
|
integrity sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==
|
||||||
|
|
||||||
esbuild@^0.13.12, esbuild@^0.13.2:
|
esbuild-windows-arm64@0.14.31:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.31.tgz#4f3b9fc34c4a33abbd0171df6cbb657ccbdbfc67"
|
||||||
|
integrity sha512-RXeU42FJoG1sriNHg73h4S+5B7L/gw+8T7U9u8IWqSSEbY6fZvBh4uofugiU1szUDqqP00GHwZ09WgYe3lGZiw==
|
||||||
|
|
||||||
|
esbuild@^0.13.12:
|
||||||
version "0.13.15"
|
version "0.13.15"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.13.15.tgz#db56a88166ee373f87dbb2d8798ff449e0450cdf"
|
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.13.15.tgz#db56a88166ee373f87dbb2d8798ff449e0450cdf"
|
||||||
integrity sha512-raCxt02HBKv8RJxE8vkTSCXGIyKHdEdGfUmiYb8wnabnaEmHzyW7DCHb5tEN0xU8ryqg5xw54mcwnYkC4x3AIw==
|
integrity sha512-raCxt02HBKv8RJxE8vkTSCXGIyKHdEdGfUmiYb8wnabnaEmHzyW7DCHb5tEN0xU8ryqg5xw54mcwnYkC4x3AIw==
|
||||||
|
@ -618,6 +718,32 @@ esbuild@^0.13.12, esbuild@^0.13.2:
|
||||||
esbuild-windows-64 "0.13.15"
|
esbuild-windows-64 "0.13.15"
|
||||||
esbuild-windows-arm64 "0.13.15"
|
esbuild-windows-arm64 "0.13.15"
|
||||||
|
|
||||||
|
esbuild@^0.14.27:
|
||||||
|
version "0.14.31"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.31.tgz#f7d0db114edc615f66d84972ee9fbd2b267f4029"
|
||||||
|
integrity sha512-QA0fUM13+JZzcvg1bdrhi7wo8Lr5IRHA9ypNn2znqxGqb66dSK6pAh01TjyBOhzZGazPQJZ1K26VrCAQJ715qA==
|
||||||
|
optionalDependencies:
|
||||||
|
esbuild-android-64 "0.14.31"
|
||||||
|
esbuild-android-arm64 "0.14.31"
|
||||||
|
esbuild-darwin-64 "0.14.31"
|
||||||
|
esbuild-darwin-arm64 "0.14.31"
|
||||||
|
esbuild-freebsd-64 "0.14.31"
|
||||||
|
esbuild-freebsd-arm64 "0.14.31"
|
||||||
|
esbuild-linux-32 "0.14.31"
|
||||||
|
esbuild-linux-64 "0.14.31"
|
||||||
|
esbuild-linux-arm "0.14.31"
|
||||||
|
esbuild-linux-arm64 "0.14.31"
|
||||||
|
esbuild-linux-mips64le "0.14.31"
|
||||||
|
esbuild-linux-ppc64le "0.14.31"
|
||||||
|
esbuild-linux-riscv64 "0.14.31"
|
||||||
|
esbuild-linux-s390x "0.14.31"
|
||||||
|
esbuild-netbsd-64 "0.14.31"
|
||||||
|
esbuild-openbsd-64 "0.14.31"
|
||||||
|
esbuild-sunos-64 "0.14.31"
|
||||||
|
esbuild-windows-32 "0.14.31"
|
||||||
|
esbuild-windows-64 "0.14.31"
|
||||||
|
esbuild-windows-arm64 "0.14.31"
|
||||||
|
|
||||||
escape-string-regexp@^1.0.3, escape-string-regexp@^1.0.5:
|
escape-string-regexp@^1.0.3, escape-string-regexp@^1.0.5:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
|
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
|
||||||
|
@ -966,10 +1092,10 @@ inherits@2:
|
||||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
is-core-module@^2.2.0:
|
is-core-module@^2.8.1:
|
||||||
version "2.5.0"
|
version "2.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.5.0.tgz#f754843617c70bfd29b7bd87327400cda5c18491"
|
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211"
|
||||||
integrity sha512-TXCMSDsEHMEEZ6eCA8rwRDbLu55MRGmrctljsBX/2v1d9/GzqHOxW5c5oPSgrUt2vBFXebu9rGqckXGPWOlYpg==
|
integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==
|
||||||
dependencies:
|
dependencies:
|
||||||
has "^1.0.3"
|
has "^1.0.3"
|
||||||
|
|
||||||
|
@ -1113,10 +1239,10 @@ ms@2.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
||||||
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||||
|
|
||||||
nanoid@^3.1.28:
|
nanoid@^3.3.3:
|
||||||
version "3.1.28"
|
version "3.3.4"
|
||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.28.tgz#3c01bac14cb6c5680569014cc65a2f26424c6bd4"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
|
||||||
integrity sha512-gSu9VZ2HtmoKYe/lmyPFES5nknFrHa+/DT9muUFWFMi6Jh9E1I7bkvlQ8xxf1Kos9pi9o8lBnIOkatMhKX/YUw==
|
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
|
||||||
|
|
||||||
natural-compare@^1.4.0:
|
natural-compare@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
|
@ -1193,20 +1319,20 @@ path-key@^3.1.0:
|
||||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||||
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
||||||
|
|
||||||
path-parse@^1.0.6:
|
path-parse@^1.0.7:
|
||||||
version "1.0.6"
|
version "1.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
|
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
||||||
integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
|
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||||
|
|
||||||
path-type@^4.0.0:
|
path-type@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
|
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
|
||||||
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
|
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
|
||||||
|
|
||||||
picocolors@^0.2.1:
|
picocolors@^1.0.0:
|
||||||
version "0.2.1"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f"
|
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
|
||||||
integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==
|
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
|
||||||
|
|
||||||
picomatch@^2.2.3:
|
picomatch@^2.2.3:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
|
@ -1232,14 +1358,14 @@ postcss-value-parser@^4.2.0:
|
||||||
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
||||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||||
|
|
||||||
postcss@^8.3.8:
|
postcss@^8.4.13:
|
||||||
version "8.3.9"
|
version "8.4.13"
|
||||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.9.tgz#98754caa06c4ee9eb59cc48bd073bb6bd3437c31"
|
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.13.tgz#7c87bc268e79f7f86524235821dfdf9f73e5d575"
|
||||||
integrity sha512-f/ZFyAKh9Dnqytx5X62jgjhhzttjZS7hMsohcI7HEI5tjELX/HxCy3EFhsRxyzGvrzFF+82XPvCS8T9TFleVJw==
|
integrity sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid "^3.1.28"
|
nanoid "^3.3.3"
|
||||||
picocolors "^0.2.1"
|
picocolors "^1.0.0"
|
||||||
source-map-js "^0.6.2"
|
source-map-js "^1.0.2"
|
||||||
|
|
||||||
prelude-ls@^1.2.1:
|
prelude-ls@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
|
@ -1296,13 +1422,14 @@ resolve-from@^4.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||||
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
|
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
|
||||||
|
|
||||||
resolve@^1.20.0:
|
resolve@^1.22.0:
|
||||||
version "1.20.0"
|
version "1.22.0"
|
||||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
|
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
|
||||||
integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
|
integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
|
||||||
dependencies:
|
dependencies:
|
||||||
is-core-module "^2.2.0"
|
is-core-module "^2.8.1"
|
||||||
path-parse "^1.0.6"
|
path-parse "^1.0.7"
|
||||||
|
supports-preserve-symlinks-flag "^1.0.0"
|
||||||
|
|
||||||
reusify@^1.0.4:
|
reusify@^1.0.4:
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
|
@ -1316,10 +1443,10 @@ rimraf@^3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
glob "^7.1.3"
|
glob "^7.1.3"
|
||||||
|
|
||||||
rollup@^2.57.0:
|
rollup@^2.59.0:
|
||||||
version "2.58.0"
|
version "2.70.1"
|
||||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.58.0.tgz#a643983365e7bf7f5b7c62a8331b983b7c4c67fb"
|
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.70.1.tgz#824b1f1f879ea396db30b0fc3ae8d2fead93523e"
|
||||||
integrity sha512-NOXpusKnaRpbS7ZVSzcEXqxcLDOagN6iFS8p45RkoiMqPHDLwJm758UF05KlMoCRbLBTZsPOIa887gZJ1AiXvw==
|
integrity sha512-CRYsI5EuzLbXdxC6RnYhOuRdtz4bhejPMSWjsFLfVM/7w/85n2szZv6yExqUXsBdz5KT8eoubeyDUDjhLHEslA==
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.2"
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
@ -1373,10 +1500,10 @@ slice-ansi@^4.0.0:
|
||||||
astral-regex "^2.0.0"
|
astral-regex "^2.0.0"
|
||||||
is-fullwidth-code-point "^3.0.0"
|
is-fullwidth-code-point "^3.0.0"
|
||||||
|
|
||||||
source-map-js@^0.6.2:
|
source-map-js@^1.0.2:
|
||||||
version "0.6.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e"
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
||||||
integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug==
|
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||||
|
|
||||||
source-map@~0.6.1:
|
source-map@~0.6.1:
|
||||||
version "0.6.1"
|
version "0.6.1"
|
||||||
|
@ -1423,6 +1550,11 @@ supports-color@^7.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag "^4.0.0"
|
has-flag "^4.0.0"
|
||||||
|
|
||||||
|
supports-preserve-symlinks-flag@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
||||||
|
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||||
|
|
||||||
table@^6.0.9:
|
table@^6.0.9:
|
||||||
version "6.7.1"
|
version "6.7.1"
|
||||||
resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2"
|
resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2"
|
||||||
|
@ -1526,15 +1658,15 @@ v8-compile-cache@^2.0.3:
|
||||||
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
|
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
|
||||||
integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
|
integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
|
||||||
|
|
||||||
vite@^2.6.14:
|
vite@^2.9.8:
|
||||||
version "2.6.14"
|
version "2.9.8"
|
||||||
resolved "https://registry.yarnpkg.com/vite/-/vite-2.6.14.tgz#35c09a15e4df823410819a2a239ab11efb186271"
|
resolved "https://registry.yarnpkg.com/vite/-/vite-2.9.8.tgz#2c2cb0790beb0fbe4b8c0995b80fe691a91c2545"
|
||||||
integrity sha512-2HA9xGyi+EhY2MXo0+A2dRsqsAG3eFNEVIo12olkWhOmc8LfiM+eMdrXf+Ruje9gdXgvSqjLI9freec1RUM5EA==
|
integrity sha512-zsBGwn5UT3YS0NLSJ7hnR54+vUKfgzMUh/Z9CxF1YKEBVIe213+63jrFLmZphgGI5zXwQCSmqIdbPuE8NJywPw==
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild "^0.13.2"
|
esbuild "^0.14.27"
|
||||||
postcss "^8.3.8"
|
postcss "^8.4.13"
|
||||||
resolve "^1.20.0"
|
resolve "^1.22.0"
|
||||||
rollup "^2.57.0"
|
rollup "^2.59.0"
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.2"
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
|