diff --git a/.gitignore b/.gitignore index 089600eb..78f9f348 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.sublime-project *.sublime-workspace +.DS_Store node_modules fetchlogs sessionexports diff --git a/.ts-eslintrc.js b/.ts-eslintrc.js index 1974e07b..cf1fc3bf 100644 --- a/.ts-eslintrc.js +++ b/.ts-eslintrc.js @@ -19,6 +19,7 @@ module.exports = { ], rules: { "@typescript-eslint/no-floating-promises": 2, - "@typescript-eslint/no-misused-promises": 2 + "@typescript-eslint/no-misused-promises": 2, + "semi": ["error", "always"] } }; diff --git a/doc/SDK.md b/doc/SDK.md index 3f5bdb09..c8f5197f 100644 --- a/doc/SDK.md +++ b/doc/SDK.md @@ -48,8 +48,8 @@ const assetPaths = { wasmBundle: olmJsPath } }; -import "hydrogen-view-sdk/theme-element-light.css"; -// OR import "hydrogen-view-sdk/theme-element-dark.css"; +import "hydrogen-view-sdk/assets/theme-element-light.css"; +// OR import "hydrogen-view-sdk/assets/theme-element-dark.css"; async function main() { const app = document.querySelector('#app')! diff --git a/doc/THEMING.md b/doc/THEMING.md new file mode 100644 index 00000000..599434bd --- /dev/null +++ b/doc/THEMING.md @@ -0,0 +1,204 @@ +# 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!** + +# Derived Theme(Collection) +This allows users to theme Hydrogen without the need for rebuilding. Derived theme collections can be thought of as extensions (derivations) of some existing build time theme. + +## Creating a derived theme: +Here's how you create a new derived theme: +1. You create a new theme manifest file (eg: theme-awesome.json) and mention which build time theme you're basing your new theme on using the `extends` field. The base css file of the mentioned theme is used for your new theme. +2. You configure the theme manifest as usual by populating the `variants` field with your desired colors. +3. You add your new theme manifest to the list of themes in `config.json`. + +Refresh Hydrogen twice (once to refresh cache, and once to load) and the new theme should show up in the theme chooser. + +## How does it work? + +For every theme collection in hydrogen, the build process emits a runtime css file which like the built theme css file contains variables in the css code. But unlike the theme css file, the runtime css file lacks the definition for these variables: + +CSS for the built theme: +```css +:root { + --background-color-primary: #f2f20f; +} + +body { + background-color: var(--background-color-primary); +} +``` +and the corresponding runtime theme: +```css +/* Notice the lack of definiton for --background-color-primary here! */ +body { + background-color: var(--background-color-primary); +} +``` + +When hydrogen loads a derived theme, it takes the runtime css file of the extended theme and dynamically adds the variable definition based on the values specified in the manifest. Icons are also colored dynamically and injected as variables using Data URIs. diff --git a/doc/UI/ui.md b/doc/UI/ui.md new file mode 100644 index 00000000..d3aa3893 --- /dev/null +++ b/doc/UI/ui.md @@ -0,0 +1,206 @@ +## IView components + +The [interface](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/types.ts) adopted by view components is agnostic of how they are rendered to the DOM. This has several benefits: + - it allows Hydrogen to not ship a [heavy view framework](https://bundlephobia.com/package/react-dom@18.2.0) that may or may not be used by its SDK users, and also keep bundle size of the app down. + - Given the interface is quite simple, is should be easy to integrate this interface into the render lifecycle of other frameworks. + - The main implementations used in Hydrogen are [`ListView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/ListView.ts) (rendering [`ObservableList`](https://github.com/vector-im/hydrogen-web/blob/master/src/observable/list/BaseObservableList.ts)s) and [`TemplateView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/TemplateView.ts) (templating and one-way databinding), each only a few 100 lines of code and tailored towards their specific use-case. They work straight with the DOM API and have no other dependencies. + - a common inteface allows us to mix and match between these different implementations (and gradually shift if need be in the future) with the code. + +## Templates + +### Template language + +Templates use a mini-DSL language in pure javascript to express declarative templates. This is basically a very thin wrapper around `document.createElement`, `document.createTextNode`, `node.setAttribute` and `node.appendChild` to quickly create DOM trees. The general syntax is as follows: +```js +t.tag_name({attribute1: value, attribute2: value, ...}, [child_elements]); +t.tag_name(child_element); +t.tag_name([child_elements]); +``` +**tag_name** can be [most HTML or SVG tags](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/html.ts#L102-L110). + +eg: +Here is an example HTML segment followed with the code to create it in Hydrogen. +```html +
+

Demo

+ +
+``` +```js +t.section({className: "main-section"},[ + t.h1("Demo"), + t.button({className:"btn_cool"}, "Click me") +]); +``` + +All these functions return DOM element nodes, e.g. the result of `document.createElement`. + +### TemplateView + +`TemplateView` builds on top of templating by adopting the IView component model and adding event handling attributes, sub views and one-way databinding. +In views based on `TemplateView`, you will see a render method with a `t` argument. +`t` is `TemplateBuilder` object passed to the render function in `TemplateView`. It also takes a data object to render and bind to, often called `vm`, short for view model from the MVVM pattern Hydrogen uses. + +You either subclass `TemplateView` and override the `render` method: +```js +class MyView extends TemplateView { + render(t, vm) { + return t.div(...); + } +} +``` + +Or you pass a render function to `InlineTemplateView`: +```js +new InlineTemplateView(vm, (t, vm) => { + return t.div(...); +}); +``` + +**Note:** the render function is only called once to build the initial DOM tree and setup bindings, etc ... Any subsequent updates to the DOM of a component happens through bindings. + +#### Event handlers + +Any attribute starting with `on` and having a function as a value will be attached as an event listener on the given node. The event handler will be removed during unmounting. + +```js +t.button({onClick: evt => { + vm.doSomething(evt.target.value); +}}, "Click me"); +``` + +#### Subviews + +`t.view(instance)` will mount the sub view (can be any IView) and return its root node so it can be attached in the DOM tree. +All subviews will be unmounted when the parent view gets unmounted. + +```js +t.div({className: "Container"}, t.view(new ChildView(vm.childViewModel))); +``` + +#### One-way data-binding + +A binding couples a part of the DOM to a value on the view model. The view model emits an update when any of its properties change, to which the view can subscribe. When an update is received by the view, it will reevaluate all the bindings, and update the DOM accordingly. + +A binding can appear in many places where a static value can usually be used in the template tree. +To create a binding, you pass a function that maps the view value to a static value. + +##### Text binding + +```js +t.p(["I've got ", vm => vm.counter, " beans"]) +``` + +##### Attribute binding + +```js +t.button({disabled: vm => vm.isBusy}, "Submit"); +``` + +##### Class-name binding +```js +t.div({className: { + button: true, + active: vm => vm.isActive +}}) +``` +##### Subview binding + +So far, all the bindings can only change node values within our tree, but don't change the structure of the DOM. A sub view binding allows you to conditionally add a subview based on the result of a binding function. + +All sub view bindings return a DOM (element or comment) node and can be directly added to the DOM tree by including them in your template. + +###### map + +`t.mapView` allows you to choose a view based on the result of the binding function: + +```js +t.mapView(vm => vm.count, count => { + return count > 5 ? new LargeView(count) : new SmallView(count); +}); +``` + +Every time the first or binding function returns a different value, the second function is run to create a new view to replace the previous view. + +You can also return `null` or `undefined` from the second function to indicate a view should not be rendered. In this case a comment node will be used as a placeholder. + +There is also a `t.map` which will create a new template view (with the same value) and you directly provide a render function for it: + +```js +t.map(vm => vm.shape, (shape, t, vm) => { + switch (shape) { + case "rect": return t.rect(); + case "circle": return t.circle(); + } +}) +``` + +###### if + +`t.ifView` will render the subview if the binding returns a truthy value: + +```js +t.ifView(vm => vm.isActive, vm => new View(vm.someValue)); +``` + +You equally have `t.if`, which creates a `TemplateView` and passes you the `TemplateBuilder`: + +```js +t.if(vm => vm.isActive, (t, vm) => t.div("active!")); +``` + +##### Side-effects + +Sometimes you want to imperatively modify your DOM tree based on the value of a binding. +`mapSideEffect` makes this easy to do: + +```js +let node = t.div(); +t.mapSideEffect(vm => vm.color, (color, oldColor) => node.style.background = color); +return node; +``` + +**Note:** you shouldn't add any bindings, subviews or event handlers from the side-effect callback, +the safest is to not use the `t` argument at all. +If you do, they will be added every time the callback is run and only cleaned up when the view is unmounted. + +#### `tag` vs `t` + +If you don't need a view component with data-binding, sub views and event handler attributes, the template language also is available in `ui/general/html.js` without any of these bells and whistles, exported as `tag`. As opposed to static templates with `tag`, you always use +`TemplateView` as an instance of a class, as there is some extra state to keep track (bindings, event handlers and subviews). + +Although syntactically similar, `TemplateBuilder` and `tag` are not functionally equivalent. +Primarily `t` **supports** bindings and event handlers while `tag` **does not**. This is because to remove event listeners, we need to keep track of them, and thus we need to keep this state somewhere which +we can't do with a simple function call but we can insite the TemplateView class. + +```js + // The onClick here wont work!! + tag.button({className:"awesome-btn", onClick: () => this.foo()}); + +class MyView extends TemplateView { + render(t, vm){ + // The onClick works here. + t.button({className:"awesome-btn", onClick: () => this.foo()}); + } +} +``` + +## ListView + +A view component that renders and updates a list of sub views for every item in a `ObservableList`. + +```js +const list = new ListView({ + list: someObservableList +}, listValue => return new ChildView(listValue)) +``` + +As items are added, removed, moved (change position) and updated, the DOM will be kept in sync. + +There is also a `LazyListView` that only renders items in and around the current viewport, with the restriction that all items in the list must be rendered with the same height. + +### Sub view updates + +Unless the `parentProvidesUpdates` option in the constructor is set to `false`, the ListView will call the `update` method on the child `IView` component when it receives an update event for one of the items in the `ObservableList`. + +This way, not every sub view has to have an individual listener on it's view model (a value from the observable list), and all updates go from the observable list to the list view, who then notifies the correct sub view. diff --git a/doc/images/coloring-process.png b/doc/images/coloring-process.png new file mode 100644 index 00000000..5de0d79a Binary files /dev/null and b/doc/images/coloring-process.png differ diff --git a/doc/images/svg-icon-example.png b/doc/images/svg-icon-example.png new file mode 100644 index 00000000..daf40258 Binary files /dev/null and b/doc/images/svg-icon-example.png differ diff --git a/doc/images/theming-architecture.png b/doc/images/theming-architecture.png new file mode 100644 index 00000000..165f7faf Binary files /dev/null and b/doc/images/theming-architecture.png differ diff --git a/package.json b/package.json index 4fa5f34b..82588f10 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,24 @@ { "name": "hydrogen-web", - "version": "0.2.29", + "version": "0.2.33", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "directories": { "doc": "doc" }, + "enginesStrict": { + "node": ">=15" + }, "scripts": { "lint": "eslint --cache src/", "lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts", "lint-ci": "eslint src/", "test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/", "test:postcss": "impunity --entry-point scripts/postcss/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", "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": { "type": "git", @@ -47,11 +52,11 @@ "regenerator-runtime": "^0.13.7", "text-encoding": "^0.7.0", "typescript": "^4.3.5", - "vite": "^2.6.14", + "vite": "^2.9.8", "xxhashjs": "^0.2.2" }, "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", "base64-arraybuffer": "^0.2.0", "dompurify": "^2.3.0", diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index da2db73b..438203b7 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -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 limitations under the License. */ -const path = require('path'); +const path = require('path').posix; async function readCSSSource(location) { const fs = require("fs").promises; @@ -43,29 +43,15 @@ function addThemesToConfig(bundle, manifestLocations, defaultThemes) { } } -function parseBundle(bundle) { +/** + * Returns a mapping from location (of manifest file) to an array containing all the chunks (of css files) generated from that location. + * To understand what chunk means in this context, see https://rollupjs.org/guide/en/#generatebundle. + * @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo + */ +function getMappingFromLocationToChunkArray(bundle) { const chunkMap = new Map(); - const assetMap = new Map(); - let runtimeThemeChunk; for (const [fileName, info] of Object.entries(bundle)) { - if (!fileName.endsWith(".css")) { - continue; - } - if (info.type === "asset") { - /** - * So this is the css assetInfo that contains the asset hashed file name. - * We'll store it in a separate map indexed via fileName (unhashed) to avoid - * searching through the bundle array later. - */ - assetMap.set(info.name, info); - continue; - } - if (info.facadeModuleId?.includes("type=runtime")) { - /** - * We have a separate field in manifest.source just for the runtime theme, - * so store this separately. - */ - runtimeThemeChunk = info; + if (!fileName.endsWith(".css") || info.type === "asset" || info.facadeModuleId?.includes("type=runtime")) { continue; } const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1]; @@ -80,7 +66,56 @@ function parseBundle(bundle) { array.push(info); } } - return { chunkMap, assetMap, runtimeThemeChunk }; + return chunkMap; +} + +/** + * Returns a mapping from unhashed file name (of css files) to AssetInfo. + * To understand what AssetInfo means in this context, see https://rollupjs.org/guide/en/#generatebundle. + * @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo + */ +function getMappingFromFileNameToAssetInfo(bundle) { + const assetMap = new Map(); + for (const [fileName, info] of Object.entries(bundle)) { + if (!fileName.endsWith(".css")) { + continue; + } + if (info.type === "asset") { + /** + * So this is the css assetInfo that contains the asset hashed file name. + * We'll store it in a separate map indexed via fileName (unhashed) to avoid + * searching through the bundle array later. + */ + assetMap.set(info.name, info); + } + } + return assetMap; +} + +/** + * Returns a mapping from location (of manifest file) to ChunkInfo of the runtime css asset + * To understand what ChunkInfo means in this context, see https://rollupjs.org/guide/en/#generatebundle. + * @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo + */ +function getMappingFromLocationToRuntimeChunk(bundle) { + let runtimeThemeChunkMap = new Map(); + for (const [fileName, info] of Object.entries(bundle)) { + if (!fileName.endsWith(".css") || info.type === "asset") { + continue; + } + const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1]; + if (!location) { + throw new Error("Cannot find location of css chunk!"); + } + if (info.facadeModuleId?.includes("type=runtime")) { + /** + * We have a separate field in manifest.source just for the runtime theme, + * so store this separately. + */ + runtimeThemeChunkMap.set(location, info); + } + } + return runtimeThemeChunkMap; } module.exports = function buildThemes(options) { @@ -88,6 +123,7 @@ module.exports = function buildThemes(options) { let isDevelopment = false; const virtualModuleId = '@theme/' const resolvedVirtualModuleId = '\0' + virtualModuleId; + const themeToManifestLocation = new Map(); return { name: "build-themes", @@ -100,37 +136,34 @@ module.exports = function buildThemes(options) { }, async buildStart() { - if (isDevelopment) { return; } const { themeConfig } = options; - for (const [name, location] of Object.entries(themeConfig.themes)) { + for (const location of themeConfig.themes) { manifest = require(`${location}/manifest.json`); + const themeCollectionId = manifest.id; + themeToManifestLocation.set(themeCollectionId, location); variants = manifest.values.variants; for (const [variant, details] of Object.entries(variants)) { - const fileName = `theme-${name}-${variant}.css`; - if (name === themeConfig.default && details.default) { + const fileName = `theme-${themeCollectionId}-${variant}.css`; + if (themeCollectionId === themeConfig.default && details.default) { // This is the default theme, stash the file name for later if (details.dark) { defaultDark = fileName; - defaultThemes["dark"] = `${name}-${variant}`; + defaultThemes["dark"] = `${themeCollectionId}-${variant}`; } else { defaultLight = fileName; - defaultThemes["light"] = `${name}-${variant}`; + defaultThemes["light"] = `${themeCollectionId}-${variant}`; } } // emit the css as built theme bundle - this.emitFile({ - type: "chunk", - id: `${location}/theme.css?variant=${variant}${details.dark? "&dark=true": ""}`, - fileName, - }); + if (!isDevelopment) { + this.emitFile({ type: "chunk", id: `${location}/theme.css?variant=${variant}${details.dark ? "&dark=true" : ""}`, fileName, }); + } } // emit the css as runtime theme bundle - this.emitFile({ - type: "chunk", - id: `${location}/theme.css?type=runtime`, - fileName: `theme-${name}-runtime.css`, - }); + if (!isDevelopment) { + this.emitFile({ type: "chunk", id: `${location}/theme.css?type=runtime`, fileName: `theme-${themeCollectionId}-runtime.css`, }); + } } }, @@ -152,7 +185,7 @@ module.exports = function buildThemes(options) { if (theme === "default") { theme = options.themeConfig.default; } - const location = options.themeConfig.themes[theme]; + const location = themeToManifestLocation.get(theme); const manifest = require(`${location}/manifest.json`); const variants = manifest.values.variants; if (!variant || variant === "default") { @@ -246,30 +279,36 @@ module.exports = function buildThemes(options) { }, generateBundle(_, bundle) { - const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle); + const assetMap = getMappingFromFileNameToAssetInfo(bundle); + const chunkMap = getMappingFromLocationToChunkArray(bundle); + const runtimeThemeChunkMap = getMappingFromLocationToRuntimeChunk(bundle); const manifestLocations = []; + // Location of the directory containing manifest relative to the root of the build output + const manifestLocation = "assets"; for (const [location, chunkArray] of chunkMap) { const manifest = require(`${location}/manifest.json`); const compiledVariables = options.compiledVariables.get(location); const derivedVariables = compiledVariables["derived-variables"]; const icon = compiledVariables["icon"]; const builtAssets = {}; - /** - * Generate a mapping from theme name to asset hashed location of said theme in build output. - * This can be used to enumerate themes during runtime. - */ + let themeKey; for (const chunk of chunkArray) { const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/); - builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName; + themeKey = name; + const locationRelativeToBuildRoot = assetMap.get(chunk.fileName).fileName; + const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot); + builtAssets[`${name}-${variant}`] = locationRelativeToManifest; } + const runtimeThemeChunk = runtimeThemeChunkMap.get(location); + const runtimeAssetLocation = path.relative(manifestLocation, assetMap.get(runtimeThemeChunk.fileName).fileName); manifest.source = { "built-assets": builtAssets, - "runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName, + "runtime-asset": runtimeAssetLocation, "derived-variables": derivedVariables, "icon": icon }; - const name = `theme-${manifest.name}.json`; - manifestLocations.push(`assets/${name}`); + const name = `theme-${themeKey}.json`; + manifestLocations.push(`${manifestLocation}/${name}`); this.emitFile({ type: "asset", name, diff --git a/scripts/postcss/css-compile-variables.js b/scripts/postcss/css-compile-variables.js index 0f58a635..80aedf60 100644 --- a/scripts/postcss/css-compile-variables.js +++ b/scripts/postcss/css-compile-variables.js @@ -30,12 +30,7 @@ const valueParser = require("postcss-value-parser"); * The actual derivation is done outside the plugin in a callback. */ -let aliasMap; -let resolvedMap; -let baseVariables; -let isDark; - -function getValueFromAlias(alias) { +function getValueFromAlias(alias, {aliasMap, baseVariables, resolvedMap}) { const derivedVariable = aliasMap.get(alias); return baseVariables.get(derivedVariable) ?? resolvedMap.get(derivedVariable); } @@ -68,14 +63,15 @@ function parseDeclarationValue(value) { return variables; } -function resolveDerivedVariable(decl, derive) { +function resolveDerivedVariable(decl, derive, maps, isDark) { + const { baseVariables, resolvedMap } = maps; const RE_VARIABLE_VALUE = /(?:--)?((.+)--(.+)-(.+))/; const variableCollection = parseDeclarationValue(decl.value); for (const variable of variableCollection) { const matches = variable.match(RE_VARIABLE_VALUE); if (matches) { const [, wholeVariable, baseVariable, operation, argument] = matches; - const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable); + const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable, maps); if (!value) { 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) { // see if right side is of form "var(--foo)" 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 }); // Add derived css variables to :root resolvedMap.forEach((value, key) => { @@ -110,13 +106,20 @@ function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) { root.append(newRule); } -function populateMapWithDerivedVariables(map, cssFileLocation) { +function populateMapWithDerivedVariables(map, cssFileLocation, {resolvedMap, aliasMap}) { const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1]; const derivedVariables = [ ...([...resolvedMap.keys()].filter(v => !aliasMap.has(v))), ...([...aliasMap.entries()].map(([alias, variable]) => `${alias}=${variable}`)) ]; - map.set(location, { "derived-variables": derivedVariables }); + const sharedObject = map.get(location); + const output = { "derived-variables": derivedVariables }; + if (sharedObject) { + Object.assign(sharedObject, output); + } + else { + map.set(location, output); + } } /** @@ -133,10 +136,10 @@ function populateMapWithDerivedVariables(map, cssFileLocation) { * @param {Map} opts.compiledVariables - A map that stores derived variables so that manifest source sections can be produced */ module.exports = (opts = {}) => { - aliasMap = new Map(); - resolvedMap = new Map(); - baseVariables = new Map(); - isDark = false; + const aliasMap = new Map(); + const resolvedMap = new Map(); + const baseVariables = new Map(); + const maps = { aliasMap, resolvedMap, baseVariables }; return { postcssPlugin: "postcss-compile-variables", @@ -147,16 +150,16 @@ module.exports = (opts = {}) => { // If this is a runtime theme, don't derive variables. 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. We use these when resolving derived variables later. */ - root.walkDecls(decl => extract(decl)); - root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive)); - addResolvedVariablesToRootSelector(root, {Rule, Declaration}); + root.walkDecls(decl => extract(decl, maps)); + root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive, maps, isDark)); + addResolvedVariablesToRootSelector(root, {Rule, Declaration}, maps); if (opts.compiledVariables){ - populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation); + populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation, maps); } // Also produce a mapping from alias to completely resolved color const resolvedAliasMap = new Map(); diff --git a/scripts/postcss/css-url-processor.js b/scripts/postcss/css-url-processor.js index f58818f1..8308e106 100644 --- a/scripts/postcss/css-url-processor.js +++ b/scripts/postcss/css-url-processor.js @@ -16,7 +16,6 @@ limitations under the License. const valueParser = require("postcss-value-parser"); const resolve = require("path").resolve; -let cssPath; function colorsFromURL(url, colorMap) { const params = new URL(`file://${url}`).searchParams; @@ -36,7 +35,7 @@ function colorsFromURL(url, colorMap) { return [primaryColor, secondaryColor]; } -function processURL(decl, replacer, colorMap) { +function processURL(decl, replacer, colorMap, cssPath) { const value = decl.value; const parsed = valueParser(value); 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 of running replacer(url) */ - cssPath = root.source?.input.file.replace(/[^/]*$/, ""); - root.walkDecls(decl => processURL(decl, opts.replacer, colorMap)); + const cssPath = root.source?.input.file.replace(/[^/]*$/, ""); + root.walkDecls(decl => processURL(decl, opts.replacer, colorMap, cssPath)); }, }; }; diff --git a/scripts/postcss/css-url-to-variables.js b/scripts/postcss/css-url-to-variables.js index 1d4666f4..f9588434 100644 --- a/scripts/postcss/css-url-to-variables.js +++ b/scripts/postcss/css-url-to-variables.js @@ -20,11 +20,9 @@ const valueParser = require("postcss-value-parser"); * This plugin extracts content inside url() into css variables and adds the variables to the root section. * This plugin is used in conjunction with css-url-processor plugin to colorize svg icons. */ -let counter; -let urlVariables; const idToPrepend = "icon-url"; -function findAndReplaceUrl(decl) { +function findAndReplaceUrl(decl, urlVariables, counter) { const value = decl.value; const parsed = valueParser(value); parsed.walk(node => { @@ -35,7 +33,8 @@ function findAndReplaceUrl(decl) { if (!url.match(/\.svg\?primary=.+/)) { return; } - const variableName = `${idToPrepend}-${counter++}`; + const count = counter.next().value; + const variableName = `${idToPrepend}-${count}`; urlVariables.set(variableName, url); node.value = "var"; node.nodes = [{ type: "word", value: `--${variableName}` }]; @@ -43,7 +42,7 @@ function findAndReplaceUrl(decl) { 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 }); // Add derived css variables to :root urlVariables.forEach((value, key) => { @@ -53,29 +52,41 @@ function addResolvedVariablesToRootSelector(root, { Rule, Declaration }) { root.append(newRule); } -function populateMapWithIcons(map, cssFileLocation) { +function populateMapWithIcons(map, cssFileLocation, urlVariables) { const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1]; const sharedObject = map.get(location); - sharedObject["icon"] = Object.fromEntries(urlVariables); + const output = {"icon": Object.fromEntries(urlVariables)}; + if (sharedObject) { + Object.assign(sharedObject, output); + } + else { + map.set(location, output); + } +} + +function *createCounter() { + for (let i = 0; ; ++i) { + yield i; + } } /* * * @type {import('postcss').PluginCreator} */ module.exports = (opts = {}) => { - urlVariables = new Map(); - counter = 0; return { postcssPlugin: "postcss-url-to-variable", Once(root, { Rule, Declaration }) { - root.walkDecls(decl => findAndReplaceUrl(decl)); + const urlVariables = new Map(); + const counter = createCounter(); + root.walkDecls(decl => findAndReplaceUrl(decl, urlVariables, counter)); if (urlVariables.size) { - addResolvedVariablesToRootSelector(root, { Rule, Declaration }); + addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables); } if (opts.compiledVariables){ const cssFileLocation = root.source.input.from; - populateMapWithIcons(opts.compiledVariables, cssFileLocation); + populateMapWithIcons(opts.compiledVariables, cssFileLocation, urlVariables); } }, }; diff --git a/scripts/release.sh b/scripts/release.sh index e11bdf14..3357a42b 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,3 +1,4 @@ +set -e if [ -z "$1" ]; then echo "provide a new version, current version is $(jq '.version' package.json)" exit 1 diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index 0ed9fdab..25342baa 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,19 @@ { "name": "hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.0.12", - "main": "./hydrogen.es.js", - "type": "module" + "version": "0.0.15", + "main": "./lib-build/hydrogen.cjs.js", + "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/*" + } } diff --git a/scripts/sdk/build.sh b/scripts/sdk/build.sh index ae3a794e..5e1632d3 100755 --- a/scripts/sdk/build.sh +++ b/scripts/sdk/build.sh @@ -2,8 +2,12 @@ # Exit whenever one of the commands fail with a non-zero exit code set -e 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-lib-config.js 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 # ./scripts/sdk/transform-paths.js ./src/platform/web/sdk/paths/vite.js ./target/paths/vite.js cp doc/SDK.md target/README.md -pushd target -pushd asset-build/assets -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 ./* ../../ +pushd target/asset-build +rm index.html popd -rm -rf asset-build -mv lib-build/* . -rm -rf lib-build +pushd target/asset-build/assets +# Remove all `*.wasm` and `*.js` files except for `main.js` +rm !(main).js *.wasm popd diff --git a/scripts/sdk/create-manifest.js b/scripts/sdk/create-manifest.js index b420e679..9d5cebb2 100755 --- a/scripts/sdk/create-manifest.js +++ b/scripts/sdk/create-manifest.js @@ -3,21 +3,7 @@ const fs = require("fs"); const appManifest = require("../../package.json"); const baseSDKManifest = require("./base-manifest.json"); /* - need to leave exports out of base-manifest.json because of #vite-bug, - 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 + Need to leave typescript type definitions out until the typescript conversion is complete and all imports in the d.ts files exists. ``` diff --git a/scripts/sdk/test/.gitignore b/scripts/sdk/test/.gitignore new file mode 100644 index 00000000..cf762fe6 --- /dev/null +++ b/scripts/sdk/test/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +yarn.lock diff --git a/scripts/sdk/test/deps.d.ts b/scripts/sdk/test/deps.d.ts new file mode 100644 index 00000000..4c7d0327 --- /dev/null +++ b/scripts/sdk/test/deps.d.ts @@ -0,0 +1,2 @@ +// Keep TypeScripts from complaining about hydrogen-view-sdk not having types yet +declare module "hydrogen-view-sdk"; diff --git a/scripts/sdk/test/esm-entry.ts b/scripts/sdk/test/esm-entry.ts new file mode 100644 index 00000000..17bbd8bb --- /dev/null +++ b/scripts/sdk/test/esm-entry.ts @@ -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 ✅'); diff --git a/scripts/sdk/test/index.html b/scripts/sdk/test/index.html new file mode 100644 index 00000000..2ee14116 --- /dev/null +++ b/scripts/sdk/test/index.html @@ -0,0 +1,12 @@ + + + + + + Vite App + + +
+ + + diff --git a/scripts/sdk/test/package.json b/scripts/sdk/test/package.json new file mode 100644 index 00000000..a81da82c --- /dev/null +++ b/scripts/sdk/test/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-sdk", + "version": "0.0.0", + "description": "", + "dependencies": { + "hydrogen-view-sdk": "link:../../../target" + } +} diff --git a/scripts/sdk/test/test-sdk-in-commonjs-env.js b/scripts/sdk/test/test-sdk-in-commonjs-env.js new file mode 100644 index 00000000..333f1573 --- /dev/null +++ b/scripts/sdk/test/test-sdk-in-commonjs-env.js @@ -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 ✅'); diff --git a/scripts/sdk/test/test-sdk-in-esm-vite-build-env.js b/scripts/sdk/test/test-sdk-in-esm-vite-build-env.js new file mode 100644 index 00000000..6fc87da7 --- /dev/null +++ b/scripts/sdk/test/test-sdk-in-esm-vite-build-env.js @@ -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(); diff --git a/src/domain/LogoutViewModel.ts b/src/domain/LogoutViewModel.ts index 9a39f601..b7aecc2c 100644 --- a/src/domain/LogoutViewModel.ts +++ b/src/domain/LogoutViewModel.ts @@ -14,19 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Options, ViewModel} from "./ViewModel"; +import {Options as BaseOptions, ViewModel} from "./ViewModel"; import {Client} from "../matrix/Client.js"; import {SegmentType} from "./navigation/index"; -type LogoutOptions = { sessionId: string; } & Options; +type Options = { sessionId: string; } & BaseOptions; -export class LogoutViewModel extends ViewModel { +export class LogoutViewModel extends ViewModel { private _sessionId: string; private _busy: boolean; private _showConfirm: boolean; private _error?: Error; - constructor(options: LogoutOptions) { + constructor(options: Options) { super(options); this._sessionId = options.sessionId; this._busy = false; diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 2711cd2f..4094d864 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -17,7 +17,7 @@ limitations under the License. import {Client} from "../matrix/Client.js"; import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; -import {LoginViewModel} from "./login/LoginViewModel.js"; +import {LoginViewModel} from "./login/LoginViewModel"; import {LogoutViewModel} from "./LogoutViewModel"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {ViewModel} from "./ViewModel"; @@ -118,7 +118,7 @@ export class RootViewModel extends ViewModel { // but we also want the change of screen to go through the navigation // so we store the session container in a temporary variable that will be // consumed by _applyNavigation, triggered by the navigation change - // + // // Also, we should not call _setSection before the navigation is in the correct state, // as url creation (e.g. in RoomTileViewModel) // won't be using the correct navigation base path. diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index f63f569d..62da159f 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -60,11 +60,11 @@ export class ViewModel = Op return this._options[name]; } - observeNavigation(type: T, onChange: (value: N[T], type: T) => void) { + observeNavigation(type: T, onChange: (value: N[T], type: T) => void): void { const segmentObservable = this.navigation.observe(type); const unsubscribe = segmentObservable.subscribe((value: N[T]) => { onChange(value, type); - }) + }); this.track(unsubscribe); } @@ -102,10 +102,10 @@ export class ViewModel = Op // TODO: this will need to support binding // if any of the expr is a function, assume the function is a binding, and return a binding function ourselves - // + // // translated string should probably always be bindings, unless we're fine with a refresh when changing the language? // we probably are, if we're using routing with a url, we could just refresh. - i18n(parts: TemplateStringsArray, ...expr: any[]) { + i18n(parts: TemplateStringsArray, ...expr: any[]): string { // just concat for now let result = ""; for (let i = 0; i < parts.length; ++i) { diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.ts similarity index 65% rename from src/domain/login/LoginViewModel.js rename to src/domain/login/LoginViewModel.ts index bf77e624..8eb11a9e 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.ts @@ -15,101 +15,145 @@ limitations under the License. */ import {Client} from "../../matrix/Client.js"; -import {ViewModel} from "../ViewModel"; +import {Options as BaseOptions, ViewModel} from "../ViewModel"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; import {LoadStatus} from "../../matrix/Client.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; +import {SegmentType} from "../navigation/index"; -export class LoginViewModel extends ViewModel { - constructor(options) { +import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login"; + +type Options = { + defaultHomeserver: string; + ready: ReadyFn; + loginToken?: string; +} & BaseOptions; + +export class LoginViewModel extends ViewModel { + private _ready: ReadyFn; + private _loginToken?: string; + private _client: Client; + private _loginOptions?: LoginOptions; + private _passwordLoginViewModel?: PasswordLoginViewModel; + private _startSSOLoginViewModel?: StartSSOLoginViewModel; + private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel; + private _loadViewModel?: SessionLoadViewModel; + private _loadViewModelSubscription?: () => void; + private _homeserver: string; + private _queriedHomeserver?: string; + private _abortHomeserverQueryTimeout?: () => void; + private _abortQueryOperation?: () => void; + + private _hideHomeserver: boolean = false; + private _isBusy: boolean = false; + private _errorMessage: string = ""; + + constructor(options: Readonly) { super(options); const {ready, defaultHomeserver, loginToken} = options; this._ready = ready; this._loginToken = loginToken; this._client = new Client(this.platform); - this._loginOptions = null; - this._passwordLoginViewModel = null; - this._startSSOLoginViewModel = null; - this._completeSSOLoginViewModel = null; - this._loadViewModel = null; - this._loadViewModelSubscription = null; this._homeserver = defaultHomeserver; - this._queriedHomeserver = null; - this._errorMessage = ""; - this._hideHomeserver = false; - this._isBusy = false; - this._abortHomeserverQueryTimeout = null; - this._abortQueryOperation = null; this._initViewModels(); } - get passwordLoginViewModel() { return this._passwordLoginViewModel; } - get startSSOLoginViewModel() { return this._startSSOLoginViewModel; } - get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; } - get homeserver() { return this._homeserver; } - get resolvedHomeserver() { return this._loginOptions?.homeserver; } - get errorMessage() { return this._errorMessage; } - get showHomeserver() { return !this._hideHomeserver; } - get loadViewModel() {return this._loadViewModel; } - get isBusy() { return this._isBusy; } - get isFetchingLoginOptions() { return !!this._abortQueryOperation; } + get passwordLoginViewModel(): PasswordLoginViewModel { + return this._passwordLoginViewModel; + } - goBack() { + get startSSOLoginViewModel(): StartSSOLoginViewModel { + return this._startSSOLoginViewModel; + } + + get completeSSOLoginViewModel(): CompleteSSOLoginViewModel { + return this._completeSSOLoginViewModel; + } + + get homeserver(): string { + return this._homeserver; + } + + get resolvedHomeserver(): string | undefined { + return this._loginOptions?.homeserver; + } + + get errorMessage(): string { + return this._errorMessage; + } + + get showHomeserver(): boolean { + return !this._hideHomeserver; + } + + get loadViewModel(): SessionLoadViewModel { + return this._loadViewModel; + } + + get isBusy(): boolean { + return this._isBusy; + } + + get isFetchingLoginOptions(): boolean { + return !!this._abortQueryOperation; + } + + goBack(): void { this.navigation.push("session"); } - async _initViewModels() { + private _initViewModels(): void { if (this._loginToken) { this._hideHomeserver = true; this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel( this.childOptions( { client: this._client, - attemptLogin: loginMethod => this.attemptLogin(loginMethod), + attemptLogin: (loginMethod: TokenLoginMethod) => this.attemptLogin(loginMethod), loginToken: this._loginToken }))); this.emitChange("completeSSOLoginViewModel"); } else { - await this.queryHomeserver(); + void this.queryHomeserver(); } } - _showPasswordLogin() { + private _showPasswordLogin(): void { this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( this.childOptions({ loginOptions: this._loginOptions, - attemptLogin: loginMethod => this.attemptLogin(loginMethod) + attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod) }))); this.emitChange("passwordLoginViewModel"); } - _showSSOLogin() { + private _showSSOLogin(): void { this._startSSOLoginViewModel = this.track( new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); this.emitChange("startSSOLoginViewModel"); } - _showError(message) { + private _showError(message: string): void { this._errorMessage = message; this.emitChange("errorMessage"); } - _setBusy(status) { + private _setBusy(status: boolean): void { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } - async attemptLogin(loginMethod) { + async attemptLogin(loginMethod: ILoginMethod): Promise { this._setBusy(true); - this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); + void this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); const loadStatus = this._client.loadStatus; - const handle = loadStatus.waitFor(status => status !== LoadStatus.Login); + const handle = loadStatus.waitFor((status: LoadStatus) => status !== LoadStatus.Login); await handle.promise; this._setBusy(false); const status = loadStatus.get(); @@ -119,11 +163,11 @@ export class LoginViewModel extends ViewModel { this._hideHomeserver = true; this.emitChange("hideHomeserver"); this._disposeViewModels(); - this._createLoadViewModel(); + void this._createLoadViewModel(); return null; } - _createLoadViewModel() { + private _createLoadViewModel(): void { this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); this._loadViewModel = this.disposeTracked(this._loadViewModel); this._loadViewModel = this.track( @@ -139,7 +183,7 @@ export class LoginViewModel extends ViewModel { }) ) ); - this._loadViewModel.start(); + void this._loadViewModel.start(); this.emitChange("loadViewModel"); this._loadViewModelSubscription = this.track( this._loadViewModel.disposableOn("change", () => { @@ -151,22 +195,22 @@ export class LoginViewModel extends ViewModel { ); } - _disposeViewModels() { - this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel); + private _disposeViewModels(): void { + this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); this.emitChange("disposeViewModels"); } - async setHomeserver(newHomeserver) { + async setHomeserver(newHomeserver: string): Promise { this._homeserver = newHomeserver; // clear everything set by queryHomeserver - this._loginOptions = null; - this._queriedHomeserver = null; + this._loginOptions = undefined; + this._queriedHomeserver = undefined; this._showError(""); this._disposeViewModels(); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); - this.emitChange(); // multiple fields changing + this.emitChange("loginViewModels"); // multiple fields changing // also clear the timeout if it is still running this.disposeTracked(this._abortHomeserverQueryTimeout); const timeout = this.clock.createTimeout(1000); @@ -181,10 +225,10 @@ export class LoginViewModel extends ViewModel { } } this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); - this.queryHomeserver(); + void this.queryHomeserver(); } - - async queryHomeserver() { + + async queryHomeserver(): Promise { // don't repeat a query we've just done if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { return; @@ -210,7 +254,7 @@ export class LoginViewModel extends ViewModel { if (e.name === "AbortError") { return; //aborted, bail out } else { - this._loginOptions = null; + this._loginOptions = undefined; } } finally { this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); @@ -221,19 +265,29 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions.password) { this._showPasswordLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password) { this._showError("This homeserver supports neither SSO nor password based login flows"); - } + } } else { this._showError(`Could not query login methods supported by ${this.homeserver}`); } } - dispose() { + dispose(): void { super.dispose(); if (this._client) { // if we move away before we're done with initial sync // delete the session - this._client.deleteSession(); + void this._client.deleteSession(); } } } + +type ReadyFn = (client: Client) => void; + +// TODO: move to Client.js when its converted to typescript. +type LoginOptions = { + homeserver: string; + password?: (username: string, password: string) => PasswordLoginMethod; + sso?: SSOLoginHelper; + token?: (loginToken: string) => TokenLoginMethod; +}; diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index 923c9b43..bf1c218d 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -54,7 +54,7 @@ export class URLRouter implements IURLRou } private _getLastSessionId(): string | undefined { - const navPath = this._urlAsNavPath(this._history.getLastUrl() || ""); + const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || ""); const sessionId = navPath.get("session")?.value; if (typeof sessionId === "string") { return sessionId; @@ -111,7 +111,7 @@ export class URLRouter implements IURLRou } tryRestoreLastUrl(): boolean { - const lastNavPath = this._urlAsNavPath(this._history.getLastUrl() || ""); + const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || ""); if (lastNavPath.segments.length !== 0) { this._applyNavPathToNavigation(lastNavPath); return true; diff --git a/src/domain/rageshake.ts b/src/domain/rageshake.ts new file mode 100644 index 00000000..cb06e638 --- /dev/null +++ b/src/domain/rageshake.ts @@ -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 { + const formData = new Map(); + 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 = 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. +} diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 66042ae5..75f90730 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -23,6 +23,7 @@ import {imageToInfo} from "../common.js"; // TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry // this is a breaking SDK change though to make this option mandatory import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index"; +import {RoomStatus} from "../../../matrix/room/common"; export class RoomViewModel extends ViewModel { constructor(options) { @@ -37,9 +38,9 @@ export class RoomViewModel extends ViewModel { this._sendError = null; this._composerVM = null; if (room.isArchived) { - this._composerVM = new ArchivedViewModel(this.childOptions({archivedRoom: room})); + this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room}))); } else { - this._composerVM = new ComposerViewModel(this); + this._recreateComposerOnPowerLevelChange(); } this._clearUnreadTimout = null; this._closeUrl = this.urlCreator.urlUntilSegment("session"); @@ -67,6 +68,30 @@ export class RoomViewModel extends ViewModel { this._clearUnreadAfterDelay(); } + async _recreateComposerOnPowerLevelChange() { + const powerLevelObservable = await this._room.observePowerLevels(); + const canSendMessage = () => powerLevelObservable.get().canSendType("m.room.message"); + let oldCanSendMessage = canSendMessage(); + const recreateComposer = newCanSendMessage => { + this._composerVM = this.disposeTracked(this._composerVM); + if (newCanSendMessage) { + this._composerVM = this.track(new ComposerViewModel(this)); + } + else { + this._composerVM = this.track(new LowerPowerLevelViewModel(this.childOptions())); + } + this.emitChange("powerLevelObservable") + }; + this.track(powerLevelObservable.subscribe(() => { + const newCanSendMessage = canSendMessage(); + if (oldCanSendMessage !== newCanSendMessage) { + recreateComposer(newCanSendMessage); + oldCanSendMessage = newCanSendMessage; + } + })); + recreateComposer(oldCanSendMessage); + } + async _clearUnreadAfterDelay() { if (this._room.isArchived || this._clearUnreadTimout) { return; @@ -173,18 +198,89 @@ export class RoomViewModel extends ViewModel { } } + async _processCommandJoin(roomName) { + try { + const roomId = await this._options.client.session.joinRoom(roomName); + const roomStatusObserver = await this._options.client.session.observeRoomStatus(roomId); + await roomStatusObserver.waitFor(status => status === RoomStatus.Joined); + this.navigation.push("room", roomId); + } catch (err) { + let exc; + if ((err.statusCode ?? err.status) === 400) { + exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`); + } else if ((err.statusCode ?? err.status) === 404 || (err.statusCode ?? err.status) === 502 || err.message == "Internal Server Error") { + exc = new Error(`/join : room '${roomName}' not found`); + } else if ((err.statusCode ?? err.status) === 403) { + exc = new Error(`/join : you're not invited to join '${roomName}'`); + } else { + exc = err; + } + this._sendError = exc; + this._timelineError = null; + this.emitChange("error"); + } + } + + async _processCommand (message) { + let msgtype; + const [commandName, ...args] = message.substring(1).split(" "); + switch (commandName) { + case "me": + message = args.join(" "); + msgtype = "m.emote"; + break; + case "join": + if (args.length === 1) { + const roomName = args[0]; + await this._processCommandJoin(roomName); + } else { + this._sendError = new Error("join syntax: /join "); + this._timelineError = null; + this.emitChange("error"); + } + break; + case "shrug": + message = "¯\\_(ツ)_/¯ " + args.join(" "); + msgtype = "m.text"; + break; + case "tableflip": + message = "(╯°□°)╯︵ ┻━┻ " + args.join(" "); + msgtype = "m.text"; + break; + case "unflip": + message = "┬──┬ ノ( ゜-゜ノ) " + args.join(" "); + msgtype = "m.text"; + break; + case "lenny": + message = "( ͡° ͜ʖ ͡°) " + args.join(" "); + msgtype = "m.text"; + break; + default: + this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`); + this._timelineError = null; + this.emitChange("error"); + message = undefined; + } + return {type: msgtype, message: message}; + } + async _sendMessage(message, replyingTo) { if (!this._room.isArchived && message) { + let messinfo = {type : "m.text", message : message}; + if (message.startsWith("//")) { + messinfo.message = message.substring(1).trim(); + } else if (message.startsWith("/")) { + messinfo = await this._processCommand(message); + } try { - let msgtype = "m.text"; - if (message.startsWith("/me ")) { - message = message.substr(4).trim(); - msgtype = "m.emote"; - } - if (replyingTo) { - await replyingTo.reply(msgtype, message); - } else { - await this._room.sendEvent("m.room.message", {msgtype, body: message}); + const msgtype = messinfo.type; + const message = messinfo.message; + if (msgtype && message) { + if (replyingTo) { + await replyingTo.reply(msgtype, message); + } else { + await this._room.sendEvent("m.room.message", {msgtype, body: message}); + } } } catch (err) { console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); @@ -329,6 +425,11 @@ export class RoomViewModel extends ViewModel { this._composerVM.setReplyingTo(entry); } } + + dismissError() { + this._sendError = null; + this.emitChange("error"); + } } function videoToInfo(video) { @@ -362,6 +463,16 @@ class ArchivedViewModel extends ViewModel { } get kind() { - return "archived"; + return "disabled"; + } +} + +class LowerPowerLevelViewModel extends ViewModel { + get description() { + return this.i18n`You do not have the powerlevel necessary to send messages`; + } + + get kind() { + return "disabled"; } } diff --git a/src/domain/session/room/timeline/tiles/BaseMediaTile.js b/src/domain/session/room/timeline/tiles/BaseMediaTile.js index 0ba5b9a9..aa53661c 100644 --- a/src/domain/session/room/timeline/tiles/BaseMediaTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMediaTile.js @@ -27,6 +27,29 @@ export class BaseMediaTile extends BaseMessageTile { this._decryptedFile = null; this._isVisible = false; 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() { @@ -38,7 +61,7 @@ export class BaseMediaTile extends BaseMessageTile { return pendingEvent && Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100); } - get sendStatus() { + get status() { const {pendingEvent} = this._entry; switch (pendingEvent?.status) { case SendStatus.Waiting: @@ -53,6 +76,12 @@ export class BaseMediaTile extends BaseMessageTile { case SendStatus.Error: return this.i18n`Error: ${pendingEvent.error.message}`; default: + if (this._downloadError) { + return `Download failed`; + } + if (this._downloading) { + return this.i18n`Downloading…`; + } return ""; } } diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 5c89236f..4dcdb111 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -16,6 +16,7 @@ limitations under the License. import {ViewModel} from "../../ViewModel"; import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; +import {submitLogsToRageshakeServer} from "../../../domain/rageshake"; class PushNotificationStatus { constructor() { @@ -51,6 +52,7 @@ export class SettingsViewModel extends ViewModel { this.maxSentImageSizeLimit = 4000; this.pushNotifications = new PushNotificationStatus(); this._activeTheme = undefined; + this._logsFeedbackMessage = undefined; } get _session() { @@ -131,18 +133,14 @@ export class SettingsViewModel extends ViewModel { return this._formatBytes(this._estimate?.usage); } - get themes() { - return this.platform.themeLoader.themes; + get themeMapping() { + return this.platform.themeLoader.themeMapping; } get activeTheme() { return this._activeTheme; } - setTheme(name) { - this.platform.themeLoader.setTheme(name); - } - _formatBytes(n) { if (typeof n === "number") { return Math.round(n / (1024 * 1024)).toFixed(1) + " MB"; @@ -156,6 +154,51 @@ export class SettingsViewModel extends ViewModel { this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); } + 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() { this.pushNotifications.updating = true; this.pushNotifications.enabledOnServer = null; @@ -185,5 +228,11 @@ export class SettingsViewModel extends ViewModel { this.emitChange("pushNotifications.serverError"); } } + + changeThemeOption(themeName, themeVariant) { + this.platform.themeLoader.setTheme(themeName, themeVariant); + // emit so that radio-buttons become displayed/hidden + this.emitChange("themeOption"); + } } diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 21175a7f..44643cc1 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -100,6 +100,8 @@ export class Client { }); } + // TODO: When converted to typescript this should return the same type + // as this._loginOptions is in LoginViewModel.ts (LoginOptions). _parseLoginOptions(options, homeserver) { /* Take server response and return new object which has two props password and sso which @@ -136,7 +138,7 @@ export class Client { const request = this._platform.request; const hsApi = new HomeServerApi({homeserver, request}); const registration = new Registration(hsApi, { - username, + username, password, initialDeviceDisplayName, }, @@ -196,7 +198,7 @@ export class Client { sessionInfo.deviceId = dehydratedDevice.deviceId; } } - await this._platform.sessionInfoStorage.add(sessionInfo); + await this._platform.sessionInfoStorage.add(sessionInfo); // loading the session can only lead to // LoadStatus.Error in case of an error, // so separate try/catch @@ -266,7 +268,7 @@ export class Client { this._status.set(LoadStatus.SessionSetup); await log.wrap("createIdentity", log => this._session.createIdentity(log)); } - + this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session, logger: this._platform.logger}); // notify sync and session when back online this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => { @@ -311,7 +313,7 @@ export class Client { this._waitForFirstSyncHandle = this._sync.status.waitFor(s => { if (s === SyncStatus.Stopped) { // keep waiting if there is a ConnectionError - // as the reconnector above will call + // as the reconnector above will call // sync.start again to retry in this case return this._sync.error?.name !== "ConnectionError"; } diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index 0068a1f9..f8c3bca8 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -214,11 +214,12 @@ export class DeviceTracker { const allDeviceIdentities = []; const deviceIdentitiesToStore = []; // 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)) { const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId); if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) { allDeviceIdentities.push(existingDevice); + return; } } allDeviceIdentities.push(deviceIdentity); @@ -363,3 +364,154 @@ export class DeviceTracker { 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"); + } + } +} diff --git a/src/matrix/login/index.ts b/src/matrix/login/index.ts new file mode 100644 index 00000000..ba133a26 --- /dev/null +++ b/src/matrix/login/index.ts @@ -0,0 +1,7 @@ +import {ILoginMethod} from "./LoginMethod"; +import {PasswordLoginMethod} from "./PasswordLoginMethod"; +import {SSOLoginHelper} from "./SSOLoginHelper"; +import {TokenLoginMethod} from "./TokenLoginMethod"; + + +export {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod}; \ No newline at end of file diff --git a/src/matrix/net/common.ts b/src/matrix/net/common.ts index 4ba42395..9f0fade4 100644 --- a/src/matrix/net/common.ts +++ b/src/matrix/net/common.ts @@ -17,9 +17,12 @@ limitations under the License. import {BlobHandle} from "../../platform/web/dom/BlobHandle.js"; +export type RequestBody = BlobHandle | string | Map; + export type EncodedBody = { mimeType: string; - body: BlobHandle | string; + // the map gets transformed to a FormData object on the web + body: RequestBody } export function encodeQueryParams(queryParams?: object): string { @@ -41,6 +44,11 @@ export function encodeBody(body: BlobHandle | object): EncodedBody { mimeType: blob.mimeType, 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") { const json = JSON.stringify(body); return { diff --git a/src/matrix/room/RoomBeingCreated.ts b/src/matrix/room/RoomBeingCreated.ts index 78202203..b2c9dafb 100644 --- a/src/matrix/room/RoomBeingCreated.ts +++ b/src/matrix/room/RoomBeingCreated.ts @@ -37,7 +37,8 @@ type CreateRoomPayload = { invite?: string[]; room_alias_name?: string; creation_content?: {"m.federate": boolean}; - initial_state: {type: string; state_key: string; content: Record}[] + initial_state: { type: string; state_key: string; content: Record }[]; + power_level_content_override?: Record; } type ImageInfo = { @@ -62,6 +63,7 @@ type Options = { invites?: string[]; avatar?: Avatar; alias?: string; + powerLevelContentOverride?: Record; } function defaultE2EEStatusForType(type: RoomType): boolean { @@ -151,6 +153,9 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { "m.federate": false }; } + if (this.options.powerLevelContentOverride) { + createOptions.power_level_content_override = this.options.powerLevelContentOverride; + } if (this.isEncrypted) { createOptions.initial_state.push(createRoomEncryptionEvent()); } diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index 3e520608..4458e1c5 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -163,7 +163,7 @@ export class GapWriter { if (!Array.isArray(chunk)) { throw new Error("Invalid chunk in response"); } - if (typeof end !== "string") { + if (typeof end !== "string" && typeof end !== "undefined") { throw new Error("Invalid end token in response"); } diff --git a/src/matrix/storage/idb/StorageFactory.ts b/src/matrix/storage/idb/StorageFactory.ts index 5cb1b6e5..1f64baf3 100644 --- a/src/matrix/storage/idb/StorageFactory.ts +++ b/src/matrix/storage/idb/StorageFactory.ts @@ -42,6 +42,7 @@ async function requestPersistedStorage(): Promise { await glob.document.requestStorageAccess(); return true; } catch (err) { + console.warn("requestStorageAccess threw an error:", err); return false; } } else { diff --git a/src/platform/types/config.ts b/src/platform/types/config.ts new file mode 100644 index 00000000..8a5eabf2 --- /dev/null +++ b/src/platform/types/config.ts @@ -0,0 +1,64 @@ +/* +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. +*/ + +export type Config = { + /** + * The default homeserver used by Hydrogen; auto filled in the login UI. + * eg: https://matrix.org + * REQUIRED + */ + defaultHomeServer: string; + /** + * The submit endpoint for your preferred rageshake server. + * eg: https://element.io/bugreports/submit + * Read more about rageshake at https://github.com/matrix-org/rageshake + * OPTIONAL + */ + bugReportEndpointUrl?: string; + /** + * Paths to theme-manifests + * eg: ["assets/theme-element.json", "assets/theme-awesome.json"] + * REQUIRED + */ + themeManifests: string[]; + /** + * This configures the default theme(s) used by Hydrogen. + * These themes appear as "Default" option in the theme chooser UI and are also + * used as a fallback when other themes fail to load. + * Whether the dark or light variant is used depends on the system preference. + * OPTIONAL + */ + defaultTheme?: { + // id of light theme + light: string; + // id of dark theme + dark: string; + }; + /** + * Configuration for push notifications. + * See https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3pushersset + * and https://github.com/matrix-org/sygnal/blob/main/docs/applications.md#webpush + * OPTIONAL + */ + push?: { + // See app_id in the request body in above link + appId: string; + // The host used for pushing notification + gatewayUrl: string; + // See pushkey in above link + applicationServerKey: string; + }; +}; diff --git a/src/platform/types/theme.ts b/src/platform/types/theme.ts new file mode 100644 index 00000000..9a984277 --- /dev/null +++ b/src/platform/types/theme.ts @@ -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; + // Location of css file that will be used for themes derived from this theme. + "runtime-asset": string; + // Array of derived-variables + "derived-variables": Array; + }; + 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; + }; +}>; + +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; +}>; diff --git a/src/platform/types/types.ts b/src/platform/types/types.ts index da8ec8e7..1d359a09 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -15,13 +15,13 @@ limitations under the License. */ 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"; export interface IRequestOptions { uploadProgress?: (loadedBytes: number) => void; timeout?: number; - body?: EncodedBody; + body?: RequestBody; headers?: Map; cache?: boolean; method?: string; diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 8e079c85..c2eef17e 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -187,9 +187,13 @@ export class Platform { this._serviceWorkerHandler, this._config.push ); - const manifests = this.config["themeManifests"]; - await this._themeLoader?.init(manifests); - this._themeLoader?.setTheme(await this._themeLoader.getActiveTheme(), log); + 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; @@ -334,13 +338,17 @@ export class Platform { document.querySelectorAll(".theme").forEach(e => e.remove()); // add new theme const styleTag = document.createElement("link"); - styleTag.href = `./${newPath}`; + styleTag.href = newPath; styleTag.rel = "stylesheet"; styleTag.type = "text/css"; styleTag.className = "theme"; head.appendChild(styleTag); } + get description() { + return navigator.userAgent ?? ""; + } + dispose() { this._disposables.dispose(); } diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts index d9aaabb6..89430663 100644 --- a/src/platform/web/ThemeLoader.ts +++ b/src/platform/web/ThemeLoader.ts @@ -17,59 +17,201 @@ 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 = {}; + private _themeMapping: Record; constructor(platform: Platform) { this._platform = platform; } - async init(manifestLocations: string[]): Promise { - for (const manifestLocation of manifestLocations) { - const { body } = await this._platform - .request(manifestLocation, { - method: "GET", - format: "json", - cache: true, - }) - .response(); - /* - After build has finished, the source section of each theme manifest - contains `built-assets` which is a mapping from the theme-name to the - location of the css file in build. - */ - Object.assign(this._themeMapping, body["source"]["built-assets"]); - } + async init(manifestLocations: string[], log?: ILogItem): Promise { + 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 }, i) => this._populateThemeMap(body, manifestLocations[i], log)); + }); } - setTheme(themeName: string, log?: ILogItem) { - this._platform.logger.wrapOrRun(log, {l: "change theme", id: themeName}, () => { - const themeLocation = this._themeMapping[themeName]; - if (!themeLocation) { - throw new Error( `Cannot find theme location for theme "${themeName}"!`); + private _populateThemeMap(manifest, manifestLocation: string, 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 = manifest.source?.["built-assets"]; + const themeName = manifest.name; + let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; + for (let [themeId, cssLocation] of Object.entries(builtAssets)) { + try { + /** + * This cssLocation is relative to the location of the manifest file. + * So we first need to resolve it relative to the root of this hydrogen instance. + */ + cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href; + } + catch { + continue; + } + const variant = themeId.match(/.+-(.+)/)?.[1]; + const { name: variantName, default: isDefault, dark } = manifest.values.variants[variant!]; + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + /** + * This is a default variant! + * We'll add these to the themeMapping (separately) keyed with just the + * theme-name (i.e "Element" instead of "Element Dark"). + * We need to be able to distinguish them from other variants! + * + * This allows us to render radio-buttons with "dark" and + * "light" options. + */ + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + defaultVariant.variantName = variantName; + defaultVariant.id = themeId + defaultVariant.cssLocation = cssLocation; + continue; + } + // Non-default variants are keyed in themeMapping with "theme_name variant_name" + // eg: "Element Dark" + this._themeMapping[themeDisplayName] = { + cssLocation, + id: themeId + }; } - this._platform.replaceStylesheet(themeLocation); - this._platform.settingsStorage.setString("theme", themeName); + 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 }); }); } - get themes(): string[] { - return Object.keys(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"); + } + }); } - async getActiveTheme(): Promise { - // check if theme is set via settings - let theme = await this._platform.settingsStorage.getString("theme"); - if (theme) { - return theme; + /** Maps theme display name to theme information */ + get themeMapping(): Record { + 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 default theme + return { themeName, themeVariant }; + } + + getDefaultTheme(): string | undefined { + switch (this.preferredColorScheme) { + case ColorSchemePreference.Dark: + return this._platform.config["defaultTheme"]?.dark; + case ColorSchemePreference.Light: + return this._platform.config["defaultTheme"]?.light; + } + } + + private _findThemeDetailsFromId(themeId: string): {themeName: string, cssLocation: string, variant?: string} | undefined { + for (const [themeName, themeData] of Object.entries(this._themeMapping)) { + if ("id" in themeData && themeData.id === themeId) { + return { themeName, cssLocation: themeData.cssLocation }; + } + else if ("light" in themeData && themeData.light?.id === themeId) { + return { themeName, cssLocation: themeData.light.cssLocation, variant: "light" }; + } + else if ("dark" in themeData && themeData.dark?.id === themeId) { + return { themeName, cssLocation: themeData.dark.cssLocation, variant: "dark" }; + } + } + } + + get preferredColorScheme(): ColorSchemePreference | undefined { if (window.matchMedia("(prefers-color-scheme: dark)").matches) { - return this._platform.config["defaultTheme"].dark; - } else if (window.matchMedia("(prefers-color-scheme: light)").matches) { - return this._platform.config["defaultTheme"].light; + return ColorSchemePreference.Dark; + } + else if (window.matchMedia("(prefers-color-scheme: light)").matches) { + return ColorSchemePreference.Light; } - return undefined; } } diff --git a/src/platform/web/assets/config.json b/src/platform/web/assets/config.json index 703ae1e6..fd46fcbc 100644 --- a/src/platform/web/assets/config.json +++ b/src/platform/web/assets/config.json @@ -4,5 +4,6 @@ "gatewayUrl": "https://matrix.org", "applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" }, - "defaultHomeServer": "matrix.org" + "defaultHomeServer": "matrix.org", + "bugReportEndpointUrl": "https://element.io/bugreports/submit" } diff --git a/src/platform/web/dom/History.js b/src/platform/web/dom/History.js index d51974bb..d40f501b 100644 --- a/src/platform/web/dom/History.js +++ b/src/platform/web/dom/History.js @@ -17,6 +17,12 @@ limitations under the License. import {BaseObservableValue} from "../../../observable/ObservableValue"; export class History extends BaseObservableValue { + + constructor() { + super(); + this._lastSessionHash = undefined; + } + handleEvent(event) { if (event.type === "hashchange") { this.emit(this.get()); @@ -65,6 +71,7 @@ export class History extends BaseObservableValue { } onSubscribeFirst() { + this._lastSessionHash = window.localStorage?.getItem("hydrogen_last_url_hash"); window.addEventListener('hashchange', this); } @@ -76,7 +83,7 @@ export class History extends BaseObservableValue { window.localStorage?.setItem("hydrogen_last_url_hash", hash); } - getLastUrl() { - return window.localStorage?.getItem("hydrogen_last_url_hash"); + getLastSessionUrl() { + return this._lastSessionHash; } } diff --git a/src/platform/web/dom/request/common.js b/src/platform/web/dom/request/common.js index d97456aa..d6ed5074 100644 --- a/src/platform/web/dom/request/common.js +++ b/src/platform/web/dom/request/common.js @@ -27,6 +27,20 @@ export function addCacheBuster(urlStr, random = Math.random) { 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() { return { "add cache buster": assert => { diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index adf833ef..497ad553 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -20,7 +20,7 @@ import { ConnectionError } from "../../../../matrix/error.js"; import {abortOnTimeout} from "../../../../utils/timeout"; -import {addCacheBuster} from "./common.js"; +import {addCacheBuster, mapAsFormData} from "./common.js"; import {xhrRequest} from "./xhr.js"; class RequestResult { @@ -70,6 +70,9 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) { if (body?.nativeBlob) { body = body.nativeBlob; } + if (body instanceof Map) { + body = mapAsFormData(body); + } let options = {method, body}; if (controller) { options = Object.assign(options, { diff --git a/src/platform/web/dom/request/xhr.js b/src/platform/web/dom/request/xhr.js index c3ad8ae8..fba7123e 100644 --- a/src/platform/web/dom/request/xhr.js +++ b/src/platform/web/dom/request/xhr.js @@ -18,7 +18,7 @@ import { AbortError, ConnectionError } from "../../../../matrix/error.js"; -import {addCacheBuster} from "./common.js"; +import {addCacheBuster, mapAsFormData} from "./common.js"; class RequestResult { constructor(promise, xhr) { @@ -94,6 +94,9 @@ export function xhrRequest(url, options) { if (body?.nativeBlob) { body = body.nativeBlob; } + if (body instanceof Map) { + body = mapAsFormData(body); + } xhr.send(body || null); return new RequestResult(promise, xhr); diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 5bc019cb..547d4b51 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -31,7 +31,7 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) { avatarClasses += ` ${extraClasses}`; } 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) { setAttribute(avatar, "data-avatar-letter", vm.avatarLetter); setAttribute(avatar, "data-avatar-color", vm.avatarColorNumber); diff --git a/src/platform/web/ui/css/themes/element/manifest.json b/src/platform/web/ui/css/themes/element/manifest.json index ec1852cb..cb21eaad 100644 --- a/src/platform/web/ui/css/themes/element/manifest.json +++ b/src/platform/web/ui/css/themes/element/manifest.json @@ -1,45 +1,40 @@ { "version": 1, - "name": "element", + "name": "Element", + "id": "element", "values": { - "font-faces": [ - { - "font-family": "Inter", - "src": [{"asset": "/fonts/Inter.ttf", "format": "ttf"}] - } - ], "variants": { - "light": { - "base": true, - "default": true, - "name": "Light", - "variables": { - "background-color-primary": "#fff", + "light": { + "base": true, + "default": true, + "name": "Light", + "variables": { + "background-color-primary": "#fff", "background-color-secondary": "#f6f6f6", - "text-color": "#2E2F32", + "text-color": "#2E2F32", "accent-color": "#03b381", "error-color": "#FF4B55", "fixed-white": "#fff", "room-badge": "#61708b", "link-color": "#238cf5" - } - }, - "dark": { - "dark": true, - "default": true, - "name": "Dark", - "variables": { - "background-color-primary": "#21262b", + } + }, + "dark": { + "dark": true, + "default": true, + "name": "Dark", + "variables": { + "background-color-primary": "#21262b", "background-color-secondary": "#2D3239", - "text-color": "#fff", + "text-color": "#fff", "accent-color": "#03B381", "error-color": "#FF4B55", "fixed-white": "#fff", "room-badge": "#61708b", "link-color": "#238cf5" - } - } - } + } + } + } } } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 113ea254..05681cbb 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -521,6 +521,62 @@ a { .RoomView_error { color: var(--error-color); + background : #efefef; + height : 0px; + font-weight : bold; + transition : 0.25s all ease-out; + padding-right : 20px; + padding-left : 20px; +} + +.RoomView_error div{ + overflow : hidden; + height: 100%; + width: 100%; + position : relative; + display : flex; + align-items : center; +} + +.RoomView_error:not(:empty) { + height : auto; + padding-top : 20px; + padding-bottom : 20px; +} + +.RoomView_error p { + position : relative; + display : block; + width : 100%; + height : auto; + margin : 0; +} + +.RoomView_error button { + width : 40px; + padding-top : 20px; + padding-bottom : 20px; + background : none; + border : none; + position : relative; + border-radius : 5px; + transition: 0.1s all ease-out; + cursor: pointer; +} + +.RoomView_error button:hover { + background : #cfcfcf; +} + +.RoomView_error button:before { + content:"\274c"; + position : absolute; + top : 15px; + left: 9px; + width : 20px; + height : 10px; + font-size : 10px; + align-self : middle; } .MessageComposer_replyPreview .Timeline_message { @@ -894,12 +950,12 @@ button.link { width: 100%; } -.RoomArchivedView { +.DisabledComposerView { padding: 12px; background-color: var(--background-color-secondary); } -.RoomArchivedView h3 { +.DisabledComposerView h3 { margin: 0; } diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index bac4b4a5..43c57d19 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -233,7 +233,7 @@ only loads when the top comes into view*/ align-self: stretch; } -.Timeline_messageBody .media > .sendStatus { +.Timeline_messageBody .media > .status { align-self: end; justify-self: start; font-size: 0.8em; @@ -251,7 +251,7 @@ only loads when the top comes into view*/ } .Timeline_messageBody .media > time, -.Timeline_messageBody .media > .sendStatus { +.Timeline_messageBody .media > .status { color: var(--text-color); display: block; padding: 2px; diff --git a/src/platform/web/ui/general/utils.ts b/src/platform/web/ui/general/utils.ts index f8d407e9..b310571f 100644 --- a/src/platform/web/ui/general/utils.ts +++ b/src/platform/web/ui/general/utils.ts @@ -54,3 +54,11 @@ export function insertAt(parentNode: Element, idx: number, childNode: Node): voi export function removeChildren(parentNode: Element): void { parentNode.innerHTML = ''; } + +export function disableTargetCallback(callback: (evt: Event) => Promise): (evt: Event) => Promise { + return async (evt: Event) => { + (evt.target as HTMLElement)?.setAttribute("disabled", "disabled"); + await callback(evt); + (evt.target as HTMLElement)?.removeAttribute("disabled"); + } +} diff --git a/src/platform/web/ui/session/room/RoomArchivedView.js b/src/platform/web/ui/session/room/DisabledComposerView.js similarity index 82% rename from src/platform/web/ui/session/room/RoomArchivedView.js rename to src/platform/web/ui/session/room/DisabledComposerView.js index 1db1c2d2..caa8eeb9 100644 --- a/src/platform/web/ui/session/room/RoomArchivedView.js +++ b/src/platform/web/ui/session/room/DisabledComposerView.js @@ -16,8 +16,8 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView"; -export class RoomArchivedView extends TemplateView { +export class DisabledComposerView extends TemplateView { render(t) { - return t.div({className: "RoomArchivedView"}, t.h3(vm => vm.description)); + return t.div({className: "DisabledComposerView"}, t.h3(vm => vm.description)); } } diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 76e26eab..e3eb0587 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -21,7 +21,7 @@ import {Menu} from "../../general/Menu.js"; import {TimelineView} from "./TimelineView"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; -import {RoomArchivedView} from "./RoomArchivedView.js"; +import {DisabledComposerView} from "./DisabledComposerView.js"; import {AvatarView} from "../../AvatarView.js"; export class RoomView extends TemplateView { @@ -32,12 +32,6 @@ export class RoomView extends TemplateView { } render(t, vm) { - let bottomView; - if (vm.composerViewModel.kind === "composer") { - bottomView = new MessageComposer(vm.composerViewModel, this._viewClassForTile); - } else if (vm.composerViewModel.kind === "archived") { - bottomView = new RoomArchivedView(vm.composerViewModel); - } return t.main({className: "RoomView middle"}, [ t.div({className: "RoomHeader middle-header"}, [ t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), @@ -52,17 +46,31 @@ export class RoomView extends TemplateView { }) ]), t.div({className: "RoomView_body"}, [ - t.div({className: "RoomView_error"}, vm => vm.error), + t.div({className: "RoomView_error"}, [ + t.if(vm => vm.error, t => t.div( + [ + t.p({}, vm => vm.error), + t.button({ className: "RoomView_error_closerButton", onClick: evt => vm.dismissError(evt) }) + ]) + )]), t.mapView(vm => vm.timelineViewModel, timelineViewModel => { return timelineViewModel ? new TimelineView(timelineViewModel, this._viewClassForTile) : new TimelineLoadingView(vm); // vm is just needed for i18n }), - t.view(bottomView), + t.mapView(vm => vm.composerViewModel, + composerViewModel => { + switch (composerViewModel?.kind) { + case "composer": + return new MessageComposer(vm.composerViewModel, this._viewClassForTile); + case "disabled": + return new DisabledComposerView(vm.composerViewModel); + } + }), ]) ]); } - + _toggleOptionsMenu(evt) { if (this._optionsPopup && this._optionsPopup.isOpen) { this._optionsPopup.close(); diff --git a/src/platform/web/ui/session/room/timeline/BaseMediaView.js b/src/platform/web/ui/session/room/timeline/BaseMediaView.js index c52fbaed..9d534fd1 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMediaView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMediaView.js @@ -15,6 +15,7 @@ limitations under the License. */ import {BaseMessageView} from "./BaseMessageView.js"; +import {Menu} from "../../../general/Menu.js"; export class BaseMediaView extends BaseMessageView { renderMessageBody(t, vm) { @@ -35,24 +36,39 @@ export class BaseMediaView extends BaseMessageView { this.renderMedia(t, vm), 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) { - const sendStatus = t.div({ - className: { - sendStatus: true, - hidden: vm => !vm.sendStatus - }, - }, vm => vm.sendStatus); const progress = t.progress({ min: 0, max: 100, value: vm => vm.uploadPercentage, className: {hidden: vm => !vm.isUploading} }); - children.push(sendStatus, progress); + children.push(progress); } 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)) ]); } + + 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; + } } diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index 1668b09c..19591606 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -24,6 +24,6 @@ export class ImageView extends BaseMediaView { title: vm => vm.label, style: `max-width: ${vm.width}px; max-height: ${vm.height}px;` }); - return vm.isPending ? img : t.a({href: vm.lightboxUrl}, img); + return vm.isPending || !vm.lightboxUrl ? img : t.a({href: vm.lightboxUrl}, img); } } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index dd7bbc03..c4405e82 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -15,6 +15,7 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView"; +import {disableTargetCallback} from "../../general/utils"; import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js" export class SettingsView extends TemplateView { @@ -101,11 +102,17 @@ export class SettingsView extends TemplateView { return row(t, vm.i18n`Use the following theme`, this._themeOptions(t, vm)); }), ); + const logButtons = []; + if (vm.canSendLogsToServer) { + logButtons.push(t.button({onClick: disableTargetCallback(() => vm.sendLogsToServer())}, `Submit logs to ${vm.logsServer}`)); + } + logButtons.push(t.button({onClick: () => vm.exportLogs()}, "Download logs")); settingNodes.push( t.h3("Application"), row(t, vm.i18n`Version`, version), row(t, vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), - row(t, vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")), + row(t, vm.i18n`Debug logs`, logButtons), + t.p({className: {hidden: vm => !vm.logsFeedbackMessage}}, vm => vm.logsFeedbackMessage), t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ", t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]), ); @@ -140,11 +147,52 @@ export class SettingsView extends TemplateView { } _themeOptions(t, vm) { - const activeTheme = vm.activeTheme; + const { themeName: activeThemeName, themeVariant: activeThemeVariant } = vm.activeTheme; const optionTags = []; - for (const name of vm.themes) { - optionTags.push(t.option({value: name, selected: name === activeTheme}, name)); + // 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)); } - return t.select({onChange: (e) => vm.setTheme(e.target.value)}, optionTags); + const select = t.select({ + onChange: (e) => { + const themeName = e.target.value; + if(!("id" in vm.themeMapping[themeName])) { + const colorScheme = darkRadioButton.checked ? "dark" : lightRadioButton.checked ? "light" : "default"; + // execute the radio-button callback so that the theme actually changes! + // otherwise the theme would only change when another radio-button is selected. + radioButtonCallback(colorScheme); + return; + } + vm.changeThemeOption(themeName); + } + }, optionTags); + // 2. render the radio-buttons used to choose variant + const radioButtonCallback = (colorScheme) => { + const selectedThemeName = select.options[select.selectedIndex].value; + vm.changeThemeOption(selectedThemeName, colorScheme); + }; + const isDarkSelected = activeThemeVariant === "dark"; + const isLightSelected = activeThemeVariant === "light"; + const darkRadioButton = t.input({ type: "radio", name: "radio-chooser", value: "dark", id: "dark", checked: isDarkSelected }); + const defaultRadioButton = t.input({ type: "radio", name: "radio-chooser", value: "default", id: "default", checked: !(isDarkSelected || isLightSelected) }); + const lightRadioButton = t.input({ type: "radio", name: "radio-chooser", value: "light", id: "light", checked: isLightSelected }); + const radioButtons = t.form({ + className: { + hidden: () => { + const themeName = select.options[select.selectedIndex].value; + return "id" in vm.themeMapping[themeName]; + } + }, + onChange: (e) => radioButtonCallback(e.target.value) + }, + [ + defaultRadioButton, + t.label({for: "default"}, "Match system theme"), + darkRadioButton, + t.label({for: "dark"}, "dark"), + lightRadioButton, + t.label({for: "light"}, "light"), + ]); + return t.div({ className: "theme-chooser" }, [select, radioButtons]); } } diff --git a/vite.common-config.js b/vite.common-config.js index 8a82a9da..5d65f8e2 100644 --- a/vite.common-config.js +++ b/vite.common-config.js @@ -40,7 +40,7 @@ const commonOptions = { postcss: { plugins: [ compileVariables({derive, compiledVariables}), - urlVariables({compileVariables}), + urlVariables({compiledVariables}), urlProcessor({replacer}), // cssvariables({ // preserve: (declaration) => { diff --git a/vite.config.js b/vite.config.js index 72be0184..10348218 100644 --- a/vite.config.js +++ b/vite.config.js @@ -33,9 +33,7 @@ export default defineConfig(({mode}) => { plugins: [ themeBuilder({ themeConfig: { - themes: { - element: "./src/platform/web/ui/css/themes/element", - }, + themes: ["./src/platform/web/ui/css/themes/element"], default: "element", }, compiledVariables, diff --git a/vite.sdk-assets-config.js b/vite.sdk-assets-config.js index 5fb9c82f..d7d4d064 100644 --- a/vite.sdk-assets-config.js +++ b/vite.sdk-assets-config.js @@ -3,11 +3,35 @@ const mergeOptions = require('merge-options'); const themeBuilder = require("./scripts/build-plugins/rollup-plugin-build-themes"); 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, { root: "src/", base: "./", 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: [ themeBuilder({ diff --git a/yarn.lock b/yarn.lock index 7bcefdd4..28efc3bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -52,9 +52,9 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf" 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": - version "3.2.3" - resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" +"@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.8" + resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856" "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -498,66 +498,141 @@ entities@^2.0.0: version "4.2.8" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz#3fc3ff0bab76fe35dd237476b5d2b32bb20a3d44" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz#8e9169c16baf444eacec60d09b24d11b255a8e72" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz#1b07f893b632114f805e188ddfca41b2b778229a" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz#0b8b7eca1690c8ec94c75680c38c07269c1f4a85" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz#2e1a6c696bfdcd20a99578b76350b41db1934e52" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz#6fd39f36fc66dd45b6b5f515728c7bbebc342a69" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz#9cb8e4bcd7574e67946e4ee5f1f1e12386bb6dd3" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz#3891aa3704ec579a1b92d2a586122e5b6a2bfba1" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz#8a00e99e6a0c6c9a6b7f334841364d8a2b4aecfe" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz#36b07cc47c3d21e48db3bb1f4d9ef8f46aead4f7" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz#f7e6bba40b9a11eb9dcae5b01550ea04670edad2" 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: version "0.13.15" 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== +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: version "0.6.3" resolved "https://registry.yarnpkg.com/esbuild-node-loader/-/esbuild-node-loader-0.6.3.tgz#3b90012f8bc2fcbb2ef76a659482c2c99840c5e8" @@ -570,27 +645,52 @@ esbuild-openbsd-64@0.13.15: resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz#b22c0e5806d3a1fbf0325872037f885306b05cd7" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz#d0b6454a88375ee8d3964daeff55c85c91c7cef4" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz#c96d0b9bbb52f3303322582ef8e4847c5ad375a7" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz#1f79cb9b1e1bb02fb25cd414cb90d4ea2892c294" 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: version "0.13.15" resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz#482173070810df22a752c686509c370c3be3b3c3" 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" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.13.15.tgz#db56a88166ee373f87dbb2d8798ff449e0450cdf" integrity sha512-raCxt02HBKv8RJxE8vkTSCXGIyKHdEdGfUmiYb8wnabnaEmHzyW7DCHb5tEN0xU8ryqg5xw54mcwnYkC4x3AIw== @@ -613,6 +713,32 @@ esbuild@^0.13.12, esbuild@^0.13.2: esbuild-windows-64 "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: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -961,10 +1087,10 @@ inherits@2: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -is-core-module@^2.2.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.5.0.tgz#f754843617c70bfd29b7bd87327400cda5c18491" - integrity sha512-TXCMSDsEHMEEZ6eCA8rwRDbLu55MRGmrctljsBX/2v1d9/GzqHOxW5c5oPSgrUt2vBFXebu9rGqckXGPWOlYpg== +is-core-module@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211" + integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA== dependencies: has "^1.0.3" @@ -1108,10 +1234,10 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -nanoid@^3.1.28: - version "3.1.28" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.28.tgz#3c01bac14cb6c5680569014cc65a2f26424c6bd4" - integrity sha512-gSu9VZ2HtmoKYe/lmyPFES5nknFrHa+/DT9muUFWFMi6Jh9E1I7bkvlQ8xxf1Kos9pi9o8lBnIOkatMhKX/YUw== +nanoid@^3.3.3: + version "3.3.4" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" + integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== natural-compare@^1.4.0: version "1.4.0" @@ -1188,20 +1314,20 @@ path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -path-parse@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" - integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -picocolors@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" - integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA== +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== picomatch@^2.2.3: version "2.3.0" @@ -1227,14 +1353,14 @@ postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.3.8: - version "8.3.9" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.9.tgz#98754caa06c4ee9eb59cc48bd073bb6bd3437c31" - integrity sha512-f/ZFyAKh9Dnqytx5X62jgjhhzttjZS7hMsohcI7HEI5tjELX/HxCy3EFhsRxyzGvrzFF+82XPvCS8T9TFleVJw== +postcss@^8.4.13: + version "8.4.13" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.13.tgz#7c87bc268e79f7f86524235821dfdf9f73e5d575" + integrity sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA== dependencies: - nanoid "^3.1.28" - picocolors "^0.2.1" - source-map-js "^0.6.2" + nanoid "^3.3.3" + picocolors "^1.0.0" + source-map-js "^1.0.2" prelude-ls@^1.2.1: version "1.2.1" @@ -1291,13 +1417,14 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve@^1.20.0: - version "1.20.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" - integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== +resolve@^1.22.0: + version "1.22.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" + integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== dependencies: - is-core-module "^2.2.0" - path-parse "^1.0.6" + is-core-module "^2.8.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" reusify@^1.0.4: version "1.0.4" @@ -1311,10 +1438,10 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup@^2.57.0: - version "2.58.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.58.0.tgz#a643983365e7bf7f5b7c62a8331b983b7c4c67fb" - integrity sha512-NOXpusKnaRpbS7ZVSzcEXqxcLDOagN6iFS8p45RkoiMqPHDLwJm758UF05KlMoCRbLBTZsPOIa887gZJ1AiXvw== +rollup@^2.59.0: + version "2.70.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.70.1.tgz#824b1f1f879ea396db30b0fc3ae8d2fead93523e" + integrity sha512-CRYsI5EuzLbXdxC6RnYhOuRdtz4bhejPMSWjsFLfVM/7w/85n2szZv6yExqUXsBdz5KT8eoubeyDUDjhLHEslA== optionalDependencies: fsevents "~2.3.2" @@ -1368,10 +1495,10 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" -source-map-js@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" - integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== source-map@~0.6.1: version "0.6.1" @@ -1418,6 +1545,11 @@ supports-color@^7.1.0: dependencies: 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: version "6.7.1" resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2" @@ -1521,15 +1653,15 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== -vite@^2.6.14: - version "2.6.14" - resolved "https://registry.yarnpkg.com/vite/-/vite-2.6.14.tgz#35c09a15e4df823410819a2a239ab11efb186271" - integrity sha512-2HA9xGyi+EhY2MXo0+A2dRsqsAG3eFNEVIo12olkWhOmc8LfiM+eMdrXf+Ruje9gdXgvSqjLI9freec1RUM5EA== +vite@^2.9.8: + version "2.9.8" + resolved "https://registry.yarnpkg.com/vite/-/vite-2.9.8.tgz#2c2cb0790beb0fbe4b8c0995b80fe691a91c2545" + integrity sha512-zsBGwn5UT3YS0NLSJ7hnR54+vUKfgzMUh/Z9CxF1YKEBVIe213+63jrFLmZphgGI5zXwQCSmqIdbPuE8NJywPw== dependencies: - esbuild "^0.13.2" - postcss "^8.3.8" - resolve "^1.20.0" - rollup "^2.57.0" + esbuild "^0.14.27" + postcss "^8.4.13" + resolve "^1.22.0" + rollup "^2.59.0" optionalDependencies: fsevents "~2.3.2"