merge master into bwindels/calls

This commit is contained in:
Bruno Windels 2023-01-20 16:17:22 +01:00
commit 920fedae5e
252 changed files with 7167 additions and 1728 deletions

7
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,7 @@
# Docker related files are not maintained by the core Hydrogen team
/.dockerignore @hughns @sandhose
/Dockerfile @hughns @sandhose
/Dockerfile-dev @hughns @sandhose
/.github/workflows/docker-publish.yml @hughns @sandhose
/docker/ @hughns @sandhose
/doc/docker.md @hughns @sandhose

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ lib
*.tar.gz
.eslintcache
.tmp
playwright/synapselogs

View File

@ -19,6 +19,11 @@ module.exports = {
],
rules: {
"@typescript-eslint/no-floating-promises": 2,
"@typescript-eslint/no-misused-promises": 2
"@typescript-eslint/no-misused-promises": 2,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn"],
"no-undef": "off",
"semi": ["error", "always"],
"@typescript-eslint/explicit-function-return-type": ["error"]
}
};

View File

@ -1,9 +1,19 @@
FROM docker.io/node:alpine as builder
RUN apk add --no-cache git python3 build-base
COPY . /app
WORKDIR /app
RUN yarn install \
&& yarn build
# Copy package.json and yarn.lock and install dependencies first to speed up subsequent builds
COPY package.json yarn.lock /app/
RUN yarn install
COPY . /app
RUN yarn build
FROM docker.io/nginx:alpine
# Copy the dynamic config script
COPY ./docker/dynamic-config.sh /docker-entrypoint.d/99-dynamic-config.sh
# Copy the built app from the first build stage
COPY --from=builder /app/target /usr/share/nginx/html

View File

@ -1,7 +1,12 @@
FROM docker.io/node:alpine
RUN apk add --no-cache git python3 build-base
COPY . /code
WORKDIR /code
# Copy package.json and yarn.lock and install dependencies first to speed up subsequent builds
COPY package.json yarn.lock /code/
RUN yarn install
COPY . /code
EXPOSE 3000
ENTRYPOINT ["yarn", "start"]

View File

@ -10,6 +10,10 @@ TorBrowser ships a crippled IndexedDB implementation and will not work. At some
It used work in pre-webkit Edge, to have it work on Windows Phone, but that support has probably bit-rotted as it isn't tested anymore.
The following browser extensions are known to break Hydrogen
- uBlock Origin (Some custom filters seem to block the service worker script)
- Try locating the filter that is blocking the service worker script in the uBlock Origin logger, and disabling that filter. Otherwise, the easiest solution is to disable uBlock Origin for the Hydrogen site (by opening the uBlock Origin popup and clicking the large power button symbol). It is possible to re-enable it after logging in, but it may possibly break again when there is an update.
## Is there a way to run the app as a desktop app?
You can install Hydrogen as a PWA using Chrome/Chromium on any platform or Edge on Windows. Gnome Web/Ephiphany also allows to "Install site as web application". There is no Electron build of Hydrogen, and there will likely be none in the near future, as Electron complicates the release process considerably. Once Hydrogen is more mature and feature complete, we might reconsider and use [Tauri](https://tauri.studio) if there are compelling use cases not possible with PWAs. For now though, we want to keep development and releasing fast and nimble ;)
@ -32,4 +36,4 @@ Published builds can be found at https://github.com/vector-im/hydrogen-web/relea
## I want to embed Hydrogen in my website, how should I do that?
Hydrogen aims to be usable as an SDK, and while it is still early days, you can find some documentation how to do that in [SDK.md](SDK.md).
Hydrogen aims to be usable as an SDK, and while it is still early days, you can find some documentation how to do that in [SDK.md](doc/SDK.md).

View File

@ -39,6 +39,8 @@ You can run Hydrogen locally by the following commands in the terminal:
Now point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md).
PS: You need nodejs, running yarn on top of any other js platform is not supported.
# FAQ
Some frequently asked questions are answered [here](doc/FAQ.md).
Some frequently asked questions are answered [here](FAQ.md).

View File

@ -1,8 +0,0 @@
goal:
write client that works on lumia 950 phone, so I can use matrix on my phone.
try approach offline to indexeddb. go low-memory, and test the performance of storing every event individually in indexeddb.
try to use little bandwidth, mainly by being an offline application and storing all requested data in indexeddb.
be as functional as possible while offline

View File

@ -85,7 +85,7 @@ async function main() {
room,
ownUserId: session.userId,
platform,
urlCreator: urlRouter,
urlRouter: urlRouter,
navigation,
});
await vm.load();

View File

@ -1,22 +0,0 @@
# Replacing javascript files
Any source file can be replaced at build time by mapping the path in a JSON file passed in to the build command, e.g. `yarn build --override-imports customizations.json`. The file should be written like so:
```json
{
"src/platform/web/ui/session/room/timeline/TextMessageView.js": "src/platform/web/ui/session/room/timeline/MyTextMessageView.js"
}
```
The paths are relative to the location of the mapping file, but the mapping file should be in a parent directory of the files you want to replace.
You should see a "replacing x with y" line (twice actually, for the normal and legacy build).
# Injecting CSS
You can override the location of the main css file with the `--override-css <file>` option to the build script. The default is `src/platform/web/ui/css/main.css`, which you probably want to import from your custom css file like so:
```css
@import url('src/platform/web/ui/css/main.css');
/* additions */
```

View File

@ -1,77 +0,0 @@
# Minimal thing to get working
- DONE: finish summary store
- DONE: move "sdk" bits over to "matrix" directory
- DONE: add eventemitter
- DONE: make sync work
- DONE: store summaries
- DONE: setup editorconfig
- DONE: setup linting (also in editor)
- DONE: store timeline
- DONE: store state
- DONE: make summary work better (name and joined/inviteCount doesn't seem to work well)
- DONE: timeline doesn't seem to recover it's key well upon loading, the query in load seems to never yield an event in the persister
- DONE: map DOMException to something better
- it's pretty opaque now when something idb related fails. DOMException has these fields:
code: 0
message: "Key already exists in the object store."
name: "ConstraintError"
- DONE: emit events so we can start showing something on the screen maybe?
- DONE: move session._rooms over to Map, so we can iterate over it, ...
- DONE: build a very basic interface with
- DONE: a start/stop sync button
- DONE: a room list sorted alphabetically
- DONE: do some preprocessing on sync response which can then be used by persister, summary, timeline
- DONE: support timeline
- DONE: clicking on a room list, you see messages (userId -> body)
- DONE: style minimal UI
- DONE: implement gap filling and fragments (see FRAGMENTS.md)
- DONE: allow collection items (especially tiles) to self-update
- improve fragmentidcomparer::add
- DONE: better UI
- fix MappedMap update mechanism
- see if in BaseObservableMap we need to change ...params
- DONE: put sync button and status label inside SessionView
- fix some errors:
- find out if `(this._emitCollectionUpdate)(this)` is different than `this._emitCollectionUpdate(this)`
- got "database tried to mutate when not allowed" or something error as well
- find out why when RoomPersister.(\_createGapEntry/\_createEventEntry) we remove .buffer the transaction fails (good), but upon fixing and refreshing is missing a message! syncToken should not be saved, so why isn't this again in the sync response and now the txn does succeed?
- DONE: take access token out of IDB? this way it can be stored in a more secure thing for non-web clients, together wit encryption key for olm sessions ... ? like macos keychain, gnome keyring, ... maybe using https://www.npmjs.com/package/keytar
- DONE: experiment with using just a normal array with 2 numbers for sortkeys, to work in Edge as well.
- DONE: send messages
- DONE: fill gaps with call to /messages
- DONE: build script
- DONE: take dev index.html, run some dom modifications to change script tag with `parse5`.
- DONE: create js bundle, rollup
- DONE: create css bundle, postcss, probably just need postcss-import for now, but good to have more options
- DONE: put all in /target
- have option to run it locally to test
- deploy script
- upload /target to github pages
- DONE: offline available
- both offline mechanisms have (filelist, version) as input for their template:
- create appcache manifest with (index.html, brawl.js, brawl.css) and print version number in it
- create service worker wit file list to cache (at top const files = "%%FILES_ARRAY%%", version = "%%VERSION%%")
- write web manifest
- DONE: delete and clear sessions from picker
- option to close current session and go back to picker
- accept invite
- member list
- e2e encryption
- sync retry strategy
- instead of stopping sync on fetch error, show spinner and status and have auto retry strategy
- create room
- join room
- leave room
- unread rooms, badge count, sort rooms by activity
- DONE: create sync filter
- DONE: lazy loading members
- decide denormalized data in summary vs reading from multiple stores PER room on load
- allow Room/Summary class to be subclassed and store additional data?
- store account data, support read markers

View File

@ -1,90 +0,0 @@
Session
properties:
rooms -> Rooms
# storage
Storage
key...() -> KeyRange
start...Txn() -> Transaction
Transaction
store(name) -> ObjectStore
finish()
rollback()
ObjectStore : QueryTarget
index(name)
Index : QueryTarget
Rooms: EventEmitter, Iterator<RoomSummary>
get(id) -> RoomSummary ?
InternalRoom: EventEmitter
applySync(roomResponse, membership, txn)
- this method updates the room summary
- persists the room summary
- persists room state & timeline with RoomPersister
- updates the OpenRoom if present
applyAndPersistSync(roomResponse, membership, txn) {
this._summary.applySync(roomResponse, membership);
this._summary.persist(txn);
this._roomPersister.persist(roomResponse, membership, txn);
if (this._openRoom) {
this._openRoom.applySync(roomResponse);
}
}
RoomPersister
RoomPersister (persists timeline and room state)
RoomSummary (persists room summary)
RoomSummary : EventEmitter
methods:
async open()
id
name
lastMessage
unreadCount
mentionCount
isEncrypted
isDirectMessage
membership
should this have a custom reducer for custom fields?
events
propChange(fieldName)
OpenRoom : EventEmitter
properties:
timeline
events:
RoomState: EventEmitter
[room_id, event_type, state_key] -> [sort_key, event]
Timeline: EventEmitter
// should have a cache of recently lookup sender members?
// can we disambiguate members like this?
methods:
lastEvents(amount)
firstEvents(amount)
eventsAfter(sortKey, amount)
eventsBefore(sortKey, amount)
events:
eventsApppended
RoomMembers : EventEmitter, Iterator
// no order, but need to be able to get all members somehow, needs to map to a ReactiveMap or something
events:
added(ids, values)
removed(ids, values)
changed(id, fieldName)
RoomMember: EventEmitter
properties:
id
name
powerLevel
membership
avatar
events:
propChange(fieldName)

View File

@ -80,6 +80,7 @@ Currently supported operations are:
| -------- | -------- | -------- |
| darker | percentage | color |
| lighter | percentage | color |
| alpha | alpha percentage | color |
## Aliases
It is possible give aliases to variables in the `theme.css` file:
@ -167,3 +168,38 @@ To find the theme-id of some theme, you can look at the built-asset section of t
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.

206
doc/architecture/UI/ui.md Normal file
View File

@ -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
<section class="main-section">
<h1>Demo</h1>
<button class="btn_cool">Click me</button>
</section>
```
```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.

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,58 @@
# Updates
How updates flow from the model to the view model to the UI.
## EventEmitter, single values
When interested in updates from a single object, chances are it inherits from `EventEmitter` and it supports a `change` event.
`ViewModel` by default follows this pattern, but it can be overwritten, see Collections below.
### Parameters
Often a `parameters` or `params` argument is passed with the name of the field who's value has now changed. This parameter is currently only sometimes used, e.g. when it is too complicated or costly to check every possible field. An example of this is `TilesListView.onUpdate` to see if the `shape` property of a tile changed and hence the view needs to be recreated. Other than that, bindings in the web UI just reevaluate all bindings when receiving an update. This is a soft convention that could probably be more standardized, and it's not always clear what to pass (e.g. when multiple fields are being updated).
Another reason to keep this convention around is that if one day we decide to add support for a different platform with a different UI, it may not be feasible to reevaluate all data-bindings in the UI for a given view model when receiving an update.
## Collections
As an optimization, Hydrogen uses a pattern to let updates flow over an observable collection where this makes sense. There is an `update` event for this in both `ObservableMap` and `ObservableList`. This prevents having to listen for updates on each individual item in large collections. The `update` event uses the same `params` argument as explained above.
Some values like `BaseRoom` emit both with a `change` event on the event emitter and also over the collection. This way consumers can use what fits best for their case: the left panel can listen for updates on the room over the collection to power the room list, and the room view model can listen to the event emitter to get updates from the current room only.
### MappedMap and mapping models to `ViewModel`s
This can get a little complicated when using `MappedMap`, e.g. when mapping a model from `matrix/`
to a view model in `domain/`. Often, view models will want to emit updates _spontanously_,
e.g. without a prior update being sent from the lower-lying model. An example would be to change the value of a field after the view has called a method on the view model.
To support this pattern while having updates still flow over the collection requires some extra work;
`ViewModel` has a `emitChange` option which you can pass in to override
what `ViewModel.emitChange` does (by default it emits the `change` event on the view model).
`MappedMap` passes a callback to emit an update over the collection to the mapper function.
You can pass this callback as the `emitChange` option and updates will now flow over the collection.
`MappedMap` also accepts an updater function, which you can use to make the view model respond to updates
from the lower-lying model.
Here is an example:
```ts
const viewModels = someCollection.mapValues(
(model, emitChange) => new SomeViewModel(this.childOptions({
model,
// will make ViewModel.emitChange go over
// the collection rather than emit a "change" event
emitChange,
})),
// an update came in from the model, let the vm know
(vm: SomeViewModel) => vm.onUpdate(),
);
```
### `ListView` & the `parentProvidesUpdates` flag.
`ObservableList` is always rendered in the UI using `ListView`. When receiving an update over the collection, it will find the child view for the given index and call `update(params)` on it. Views will typically need to be told whether they should listen to the `change` event in their view model or rather wait for their `update()` method to be called by their parent view, `ListView`. That's why the `mount(args)` method on a view supports a `parentProvidesUpdates` flag. If `true`, the view should not subscribe to its view model, but rather updates the DOM when its `update()` method is called. Also see `BaseUpdateView` and `TemplateView` for how this is implemented in the child view.
## `ObservableValue`
When some method wants to return an object that can be updated, often an `ObservableValue` is used rather than an `EventEmitter`. It's not 100% clear cut when to use the former or the latter, but `ObservableValue` is often used when the returned value in it's entirety will change rather than just a property on it. `ObservableValue` also has some nice facilities like lazy evaluation when subscribed to and the `waitFor` method to work with promises.

View File

@ -39,11 +39,11 @@ In this repository, create a Docker image:
docker build -t hydrogen .
```
Or, pull the docker image from GitLab:
Or, pull the docker image from GitHub Container Registry:
```
docker pull registry.gitlab.com/jcgruenhage/hydrogen-web
docker tag registry.gitlab.com/jcgruenhage/hydrogen-web hydrogen
docker pull ghcr.io/vector-im/hydrogen-web
docker tag ghcr.io/vector-im/hydrogen-web hydrogen
```
### Start container image
@ -56,3 +56,27 @@ docker run \
--publish 80:80 \
hydrogen
```
You can override the default `config.json` using the `CONFIG_OVERRIDE` environment variable. For example to specify a different Homeserver and :
```
docker run \
--name hydrogen \
--publish 80:80 \
--env CONFIG_OVERRIDE='{
"push": {
"appId": "io.element.hydrogen.web",
"gatewayUrl": "https://matrix.org",
"applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM"
},
"defaultHomeServer": "https://fosdem.org",
"themeManifests": [
"assets/theme-element.json"
],
"defaultTheme": {
"light": "element-light",
"dark": "element-dark"
}
}' \
hydrogen
```

View File

@ -6,6 +6,10 @@ We could do top to bottom gradients in default avatars to make them look a bit c
Can take ideas/adopt from OOCSS and SMACSS.
## Documentation
Whether we use OOCSS, SMACSS or BEM, we should write a tool that uses a JS parser (acorn?) to find all css classes used in the view code by looking for a `{className: "..."}` pattern. E.g. if using BEM, use all the found classes to construct a doc with a section for every block, with therein all elements and modifiers.
### Root
- maybe we should not assume `body` is the root, but rather a `.brawl` class. The root is where we'd set root level css variables, fonts?, etc. Should we scope all css to this root class? That could get painful with just vanilla css. We could use something like https://github.com/domwashburn/postcss-parent-selector to only do this at build time. Other useful plugin for postcss: https://github.com/postcss/postcss-selector-parser

View File

@ -30,7 +30,7 @@ if (loginOptions.sso) {
// store the homeserver for when we get redirected back after the sso flow
platform.settingsStorage.setString("sso_homeserver", loginOptions.homeserver);
// create the redirect url
const callbackUrl = urlCreator.createSSOCallbackURL(); // will just return the document url without any fragment
const callbackUrl = urlRouter.createSSOCallbackURL(); // will just return the document url without any fragment
const redirectUrl = sso.createRedirectUrl(callbackUrl, provider);
// and open it
platform.openURL(redirectUrl);

View File

@ -0,0 +1,55 @@
/*
different room types create different kind of "sync listeners", who implement the sync lifecycle handlers
they would each have a factory,
*/
interface IRoomSyncHandler {
prepareSync()
afterPrepareSync()
writeSync()
afterSync()
afterSyncCompleted()
}
interface IRoom extends IRoomSyncHandler {
start(): void;
load(): void;
get id(): string;
}
interface IRoomFactory<T extends IRoom> {
createRoom(type, roomId, syncResponse): T
createSchema(db, txn, oldVersion, version, log)
get storesForSync(): string[];
get rooms(): ObservableMap<string, T>
}
class InstantMessageRoom implements IRoom {
}
class InstantMessageRoomFactory implements IRoomFactory<InstantMessageRoom>{
loadLastMessages(): Promise<void>
/*
get all room ids and sort them according to idb sorting order
open cursor 'f' on `timelineFragments`
open a cursor 'e' on `timelineEvents`
for each room:
with cursor 'f', go to last fragment id and go up from there to find live fragment
with cursor 'e', go to last event index for fragment id and room id and go up until we have acceptable event type
for encrypted rooms:
decrypt message if needed (m.room.encrypted is likely something we want to display)
*/
}
class SpaceRoom implements IRoom {}
class SpaceRoomFactory implements IRoomFactory<SpaceRoom> {
createRoom(type, roomId, syncResponse): IRoomSyncHandler
}
class Session {
constructor(roomFactoriesByType: Map<string, IRoomFactory>) {
}
}

View File

@ -0,0 +1,11 @@
## How to import common-js dependency using ES6 syntax
---
Until [#6632](https://github.com/vitejs/vite/issues/6632) is fixed, such imports should be done as follows:
```ts
import * as pkg from "off-color";
// @ts-ignore
const offColor = pkg.offColor ?? pkg.default.offColor;
```
This way build, dev server and unit tests should all work.

View File

@ -1,21 +0,0 @@
view hierarchy:
```
BrawlView
SwitchView
SessionView
SyncStatusBar
ListView(left-panel)
RoomTile
SwitchView
RoomPlaceholderView
RoomView
MiddlePanel
ListView(timeline)
event tiles (see ui/session/room/timeline/)
ComposerView
RightPanel
SessionPickView
ListView
SessionPickerItemView
LoginView
```

8
docker/dynamic-config.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
set -eux
# Use config override environment variable if set
if [ -n "${CONFIG_OVERRIDE:-}" ]; then
echo "$CONFIG_OVERRIDE" > /usr/share/nginx/html/config.json
fi

View File

@ -1,6 +1,6 @@
{
"name": "hydrogen-web",
"version": "0.2.33",
"version": "0.3.6",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"directories": {
"doc": "doc"
@ -18,7 +18,8 @@
"start": "vite --port 3000",
"build": "vite build && ./scripts/cleanup.sh",
"build:sdk": "./scripts/sdk/build.sh",
"watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch"
"watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch",
"test:app": "./scripts/test-app.sh"
},
"repository": {
"type": "git",
@ -32,6 +33,7 @@
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
"devDependencies": {
"@matrixdotorg/structured-logviewer": "^0.0.3",
"@playwright/test": "^1.27.1",
"@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2",
"acorn": "^8.6.0",
@ -51,8 +53,9 @@
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-value-parser": "^4.2.0",
"regenerator-runtime": "^0.13.7",
"svgo": "^2.8.0",
"text-encoding": "^0.7.0",
"typescript": "^4.4",
"typescript": "^4.7.0",
"vite": "^2.9.8",
"xxhashjs": "^0.2.2"
},

21
playwright.config.ts Normal file
View File

@ -0,0 +1,21 @@
import type { PlaywrightTestConfig } from "@playwright/test";
const BASE_URL = process.env["BASE_URL"] ?? "http://127.0.0.1:3000";
const config: PlaywrightTestConfig = {
use: {
headless: false,
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
video: "on-first-retry",
baseURL: BASE_URL,
},
testDir: "./playwright/tests",
globalSetup: require.resolve("./playwright/global-setup"),
webServer: {
command: "yarn start",
url: `${BASE_URL}/#/login`,
},
workers: 1
};
export default config;

View File

@ -0,0 +1,28 @@
/*
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.
*/
const env = {
SYNAPSE_IP_ADDRESS: "172.18.0.5",
SYNAPSE_PORT: "8008",
DEX_IP_ADDRESS: "172.18.0.4",
DEX_PORT: "5556",
}
export default function setupEnvironmentVariables() {
for (const [key, value] of Object.entries(env)) {
process.env[key] = value;
}
}

View File

@ -0,0 +1,108 @@
/*
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.
*/
/// <reference types="cypress" />
import * as path from "path";
import * as os from "os";
import * as fse from "fs-extra";
import {dockerRun, dockerStop } from "../docker";
// A cypress plugins to add command to start & stop dex instances
interface DexConfig {
configDir: string;
baseUrl: string;
port: number;
host: string;
}
export interface DexInstance extends DexConfig {
dexId: string;
}
const dexConfigs = new Map<string, DexInstance>();
async function produceConfigWithSynapseURLAdded(): Promise<DexConfig> {
const templateDir = path.join(__dirname, "template");
const stats = await fse.stat(templateDir);
if (!stats?.isDirectory) {
throw new Error(`Template directory at ${templateDir} not found!`);
}
const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), 'hydrogen-testing-dex-'));
// copy the contents of the template dir, omitting config.yaml as we'll template that
console.log(`Copy ${templateDir} -> ${tempDir}`);
await fse.copy(templateDir, tempDir, { filter: f => path.basename(f) !== 'config.yaml' });
// now copy config.yaml, applying substitutions
console.log(`Gen ${path.join(templateDir, "config.yaml")}`);
let hsYaml = await fse.readFile(path.join(templateDir, "config.yaml"), "utf8");
const synapseHost = process.env.SYNAPSE_IP_ADDRESS;
const synapsePort = process.env.SYNAPSE_PORT;
const synapseAddress = `${synapseHost}:${synapsePort}`;
hsYaml = hsYaml.replace(/{{SYNAPSE_ADDRESS}}/g, synapseAddress);
const dexHost = process.env.DEX_IP_ADDRESS!;
const dexPort = parseInt(process.env.DEX_PORT!, 10);
const dexAddress = `${dexHost}:${dexPort}`;
hsYaml = hsYaml.replace(/{{DEX_ADDRESS}}/g, dexAddress);
await fse.writeFile(path.join(tempDir, "config.yaml"), hsYaml);
const baseUrl = `http://${dexHost}:${dexPort}`;
return {
host: dexHost,
port: dexPort,
baseUrl,
configDir: tempDir,
};
}
export async function dexStart(): Promise<DexInstance> {
const dexCfg = await produceConfigWithSynapseURLAdded();
console.log(`Starting dex with config dir ${dexCfg.configDir}...`);
const dexId = await dockerRun({
image: "bitnami/dex:latest",
containerName: "hydrogen-dex",
dockerParams: [
"--rm",
"-v", `${dexCfg.configDir}:/data`,
`--ip=${dexCfg.host}`,
"-p", `${dexCfg.port}:5556/tcp`,
"--network=hydrogen"
],
applicationParams: [
"serve",
"data/config.yaml",
]
});
console.log(`Started dex with id ${dexId} on port ${dexCfg.port}.`);
const dex: DexInstance = { dexId, ...dexCfg };
dexConfigs.set(dexId, dex);
return dex;
}
export async function dexStop(id: string): Promise<void> {
const dexCfg = dexConfigs.get(id);
if (!dexCfg) throw new Error("Unknown dex ID");
await dockerStop({ containerId: id, });
await fse.remove(dexCfg.configDir);
dexConfigs.delete(id);
console.log(`Stopped dex id ${id}.`);
}

View File

@ -0,0 +1,56 @@
issuer: http://{{DEX_ADDRESS}}/dex
storage:
type: sqlite3
config:
file: data/dev.db
# Configuration for the HTTP endpoints.
web:
http: 0.0.0.0:5556
# Uncomment for HTTPS options.
# https: 127.0.0.1:5554
# tlsCert: /etc/dex/tls.crt
# tlsKey: /etc/dex/tls.key
# Configuration for telemetry
telemetry:
http: 0.0.0.0:5558
# enableProfiling: true
staticClients:
- id: synapse
secret: secret
redirectURIs:
- 'http://{{SYNAPSE_ADDRESS}}/_synapse/client/oidc/callback'
name: 'Synapse'
connectors:
- type: mockCallback
id: mock
name: Example
# - type: google
# id: google
# name: Google
# config:
# issuer: https://accounts.google.com
# # Connector config values starting with a "$" will read from the environment.
# clientID: $GOOGLE_CLIENT_ID
# clientSecret: $GOOGLE_CLIENT_SECRET
# redirectURI: http://127.0.0.1:5556/dex/callback
# hostedDomains:
# - $GOOGLE_HOSTED_DOMAIN
# Let dex keep a list of passwords which can be used to login to dex.
enablePasswordDB: true
# A static list of passwords to login the end user. By identifying here, dex
# won't look in its underlying storage for passwords.
#
# If this option isn't chosen users may be added through the gRPC API.
staticPasswords:
- email: "admin@example.com"
# bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2)
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "admin"
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"

Binary file not shown.

View File

@ -0,0 +1,151 @@
/*
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.
*/
/// <reference types="cypress" />
import * as os from "os";
import * as childProcess from "child_process";
import * as fse from "fs-extra";
export function dockerRun(args: {
image: string;
containerName: string;
dockerParams?: string[];
applicationParams?: string[];
}): Promise<string> {
const userInfo = os.userInfo();
const params = args.dockerParams ?? [];
const appParams = args.applicationParams ?? [];
if (userInfo.uid >= 0) {
// On *nix we run the docker container as our uid:gid otherwise cleaning it up its media_store can be difficult
params.push("-u", `${userInfo.uid}:${userInfo.gid}`);
}
return new Promise<string>((resolve, reject) => {
childProcess.execFile('docker', [
"run",
"--name", args.containerName,
"-d",
...params,
args.image,
... appParams
], (err, stdout) => {
if (err) {
reject(err);
}
resolve(stdout.trim());
});
});
}
export function dockerExec(args: {
containerId: string;
params: string[];
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
childProcess.execFile("docker", [
"exec", args.containerId,
...args.params,
], { encoding: 'utf8' }, (err, stdout, stderr) => {
if (err) {
console.log(stdout);
console.log(stderr);
reject(err);
return;
}
resolve();
});
});
}
/**
* Create a docker network; does not fail if network already exists
*/
export function dockerCreateNetwork(args: {
networkName: string;
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
childProcess.execFile("docker", [
"network",
"create",
args.networkName
], { encoding: 'utf8' }, (err, stdout, stderr) => {
if(err) {
if (stderr.includes(`network with name ${args.networkName} already exists`)) {
// Don't consider this as error
resolve();
}
reject(err);
return;
}
resolve();
})
});
}
export async function dockerLogs(args: {
containerId: string;
stdoutFile?: string;
stderrFile?: string;
}): Promise<void> {
const stdoutFile = args.stdoutFile ? await fse.open(args.stdoutFile, "w") : "ignore";
const stderrFile = args.stderrFile ? await fse.open(args.stderrFile, "w") : "ignore";
await new Promise<void>((resolve) => {
childProcess.spawn("docker", [
"logs",
args.containerId,
], {
stdio: ["ignore", stdoutFile, stderrFile],
}).once('close', resolve);
});
if (args.stdoutFile) await fse.close(<number>stdoutFile);
if (args.stderrFile) await fse.close(<number>stderrFile);
}
export function dockerStop(args: {
containerId: string;
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
childProcess.execFile('docker', [
"stop",
args.containerId,
], err => {
if (err) {
reject(err);
}
resolve();
});
});
}
export function dockerRm(args: {
containerId: string;
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
childProcess.execFile('docker', [
"rm",
args.containerId,
], err => {
if (err) {
reject(err);
}
resolve();
});
});
}

View File

@ -0,0 +1,203 @@
/*
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.
*/
/// <reference types="cypress" />
import * as path from "path";
import * as os from "os";
import * as crypto from "crypto";
import * as fse from "fs-extra";
import {dockerCreateNetwork, dockerExec, dockerLogs, dockerRun, dockerStop} from "../docker";
import {request} from "@playwright/test";
// A cypress plugins to add command to start & stop synapses in
// docker with preset templates.
interface SynapseConfig {
configDir: string;
registrationSecret: string;
// Synapse must be configured with its public_baseurl so we have to allocate a port & url at this stage
baseUrl: string;
port: number;
host: string;
}
export interface SynapseInstance extends SynapseConfig {
synapseId: string;
}
const synapses = new Map<string, SynapseInstance>();
function randB64Bytes(numBytes: number): string {
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
}
async function cfgDirFromTemplate(template: string): Promise<SynapseConfig> {
const templateDir = path.join(__dirname, "templates", template);
const stats = await fse.stat(templateDir);
if (!stats?.isDirectory) {
throw new Error(`No such template: ${template}`);
}
const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), 'synapsedocker-'));
// copy the contents of the template dir, omitting homeserver.yaml as we'll template that
console.log(`Copy ${templateDir} -> ${tempDir}`);
await fse.copy(templateDir, tempDir, { filter: f => path.basename(f) !== 'homeserver.yaml' });
const registrationSecret = randB64Bytes(16);
const macaroonSecret = randB64Bytes(16);
const formSecret = randB64Bytes(16);
const synapseHost = process.env["SYNAPSE_IP_ADDRESS"]!!;
const synapsePort = parseInt(process.env["SYNAPSE_PORT"]!, 10);
const baseUrl = `http://${synapseHost}:${synapsePort}`;
// now copy homeserver.yaml, applying substitutions
console.log(`Gen ${path.join(templateDir, "homeserver.yaml")}`);
let hsYaml = await fse.readFile(path.join(templateDir, "homeserver.yaml"), "utf8");
hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret);
hsYaml = hsYaml.replace(/{{MACAROON_SECRET_KEY}}/g, macaroonSecret);
hsYaml = hsYaml.replace(/{{FORM_SECRET}}/g, formSecret);
hsYaml = hsYaml.replace(/{{PUBLIC_BASEURL}}/g, baseUrl);
const dexHost = process.env["DEX_IP_ADDRESS"];
const dexPort = process.env["DEX_PORT"];
const dexUrl = `http://${dexHost}:${dexPort}/dex`;
hsYaml = hsYaml.replace(/{{OIDC_ISSUER}}/g, dexUrl);
await fse.writeFile(path.join(tempDir, "homeserver.yaml"), hsYaml);
// now generate a signing key (we could use synapse's config generation for
// this, or we could just do this...)
// NB. This assumes the homeserver.yaml specifies the key in this location
const signingKey = randB64Bytes(32);
console.log(`Gen ${path.join(templateDir, "localhost.signing.key")}`);
await fse.writeFile(path.join(tempDir, "localhost.signing.key"), `ed25519 x ${signingKey}`);
return {
port: synapsePort,
host: synapseHost,
baseUrl,
configDir: tempDir,
registrationSecret,
};
}
// Start a synapse instance: the template must be the name of
// one of the templates in the cypress/plugins/synapsedocker/templates
// directory
export async function synapseStart(template: string): Promise<SynapseInstance> {
const synCfg = await cfgDirFromTemplate(template);
console.log(`Starting synapse with config dir ${synCfg.configDir}...`);
await dockerCreateNetwork({ networkName: "hydrogen" });
const synapseId = await dockerRun({
image: "matrixdotorg/synapse:develop",
containerName: `hydrogen-synapse`,
dockerParams: [
"--rm",
"-v", `${synCfg.configDir}:/data`,
`--ip=${synCfg.host}`,
/**
* When using -p flag with --ip, the docker internal port must be used to access from the host
*/
"-p", `${synCfg.port}:8008/tcp`,
"--network=hydrogen",
],
applicationParams: [
"run"
]
});
console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`);
// Await Synapse healthcheck
await dockerExec({
containerId: synapseId,
params: [
"curl",
"--connect-timeout", "30",
"--retry", "30",
"--retry-delay", "1",
"--retry-all-errors",
"--silent",
"http://localhost:8008/health",
],
});
const synapse: SynapseInstance = { synapseId, ...synCfg };
synapses.set(synapseId, synapse);
return synapse;
}
export async function synapseStop(id: string): Promise<void> {
const synCfg = synapses.get(id);
if (!synCfg) throw new Error("Unknown synapse ID");
const synapseLogsPath = path.join("playwright", "synapselogs", id);
await fse.ensureDir(synapseLogsPath);
await dockerLogs({
containerId: id,
stdoutFile: path.join(synapseLogsPath, "stdout.log"),
stderrFile: path.join(synapseLogsPath, "stderr.log"),
});
await dockerStop({
containerId: id,
});
await fse.remove(synCfg.configDir);
synapses.delete(id);
console.log(`Stopped synapse id ${id}.`);
}
interface Credentials {
accessToken: string;
userId: string;
deviceId: string;
homeServer: string;
}
export async function registerUser(synapse: SynapseInstance, username: string, password: string, displayName?: string,): Promise<Credentials> {
const url = `${synapse.baseUrl}/_synapse/admin/v1/register`;
const context = await request.newContext({ baseURL: url });
const { nonce } = await (await context.get(url)).json();
const mac = crypto.createHmac('sha1', synapse.registrationSecret).update(
`${nonce}\0${username}\0${password}\0notadmin`,
).digest('hex');
const response = await (await context.post(url, {
data: {
nonce,
username,
password,
mac,
admin: false,
displayname: displayName,
}
})).json();
return {
homeServer: response.home_server,
accessToken: response.access_token,
userId: response.user_id,
deviceId: response.device_id,
};
}

View File

@ -0,0 +1,3 @@
# Meta-template for synapse templates
To make another template, you can copy this directory

View File

@ -0,0 +1,72 @@
server_name: "localhost"
pid_file: /data/homeserver.pid
# XXX: This won't actually be right: it lets docker allocate an ephemeral port,
# so we have a chicken-and-egg problem
public_baseurl: http://localhost:8008/
# Listener is always port 8008 (configured in the container)
listeners:
- port: 8008
tls: false
bind_addresses: ['::']
type: http
x_forwarded: true
resources:
- names: [client, federation, consent]
compress: false
# An sqlite in-memory database is fast & automatically wipes each time
database:
name: "sqlite3"
args:
database: ":memory:"
# Needs to be configured to log to the console like a good docker process
log_config: "/data/log.config"
rc_messages_per_second: 10000
rc_message_burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
media_store_path: "/data/media_store"
uploads_path: "/data/uploads"
enable_registration: true
enable_registration_without_verification: true
disable_msisdn_registration: false
# These placeholders will be be replaced with values generated at start
registration_shared_secret: "{{REGISTRATION_SECRET}}"
report_stats: false
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
form_secret: "{{FORM_SECRET}}"
# Signing key must be here: it will be generated to this file
signing_key_path: "/data/localhost.signing.key"
email:
enable_notifs: false
smtp_host: "localhost"
smtp_port: 25
smtp_user: "exampleusername"
smtp_pass: "examplepassword"
require_transport_security: False
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>"
app_name: Matrix
notif_template_html: notif_mail.html
notif_template_text: notif_mail.txt
notif_for_new_users: True
client_base_url: "http://localhost/element"
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true

View File

@ -0,0 +1,50 @@
# Log configuration for Synapse.
#
# This is a YAML file containing a standard Python logging configuration
# dictionary. See [1] for details on the valid settings.
#
# Synapse also supports structured logging for machine readable logs which can
# be ingested by ELK stacks. See [2] for details.
#
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
# A handler that writes logs to stderr. Unused by default, but can be used
# instead of "buffer" and "file" in the logger handlers.
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: INFO
twisted:
# We send the twisted logging directly to the file handler,
# to work around https://github.com/matrix-org/synapse/issues/3471
# when using "buffer" logger. Use "console" to log to stderr instead.
handlers: [console]
propagate: false
root:
level: INFO
# Write logs to the `buffer` handler, which will buffer them together in memory,
# then write them to a file.
#
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
# also need to update the configuration for the `twisted` logger above, in
# this case.)
#
handlers: [console]
disable_existing_loggers: false

View File

@ -0,0 +1 @@
A synapse configured with user privacy consent enabled

View File

@ -0,0 +1,84 @@
server_name: "localhost"
pid_file: /data/homeserver.pid
public_baseurl: "{{PUBLIC_BASEURL}}"
listeners:
- port: 8008
tls: false
bind_addresses: ['::']
type: http
x_forwarded: true
resources:
- names: [client, federation, consent]
compress: false
database:
name: "sqlite3"
args:
database: ":memory:"
log_config: "/data/log.config"
rc_messages_per_second: 10000
rc_message_burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
media_store_path: "/data/media_store"
uploads_path: "/data/uploads"
enable_registration: true
enable_registration_without_verification: true
disable_msisdn_registration: false
registration_shared_secret: "{{REGISTRATION_SECRET}}"
report_stats: false
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
form_secret: "{{FORM_SECRET}}"
signing_key_path: "/data/localhost.signing.key"
email:
enable_notifs: false
smtp_host: "localhost"
smtp_port: 25
smtp_user: "exampleusername"
smtp_pass: "examplepassword"
require_transport_security: False
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>"
app_name: Matrix
notif_template_html: notif_mail.html
notif_template_text: notif_mail.txt
notif_for_new_users: True
client_base_url: "http://localhost/element"
user_consent:
template_dir: /data/res/templates/privacy
version: 1.0
server_notice_content:
msgtype: m.text
body: >-
To continue using this homeserver you must review and agree to the
terms and conditions at %(consent_uri)s
send_server_notice_to_guests: True
block_events_error: >-
To continue using this homeserver you must review and agree to the
terms and conditions at %(consent_uri)s
require_at_registration: true
server_notices:
system_mxid_localpart: notices
system_mxid_display_name: "Server Notices"
system_mxid_avatar_url: "mxc://localhost:5005/oumMVlgDnLYFaPVkExemNVVZ"
room_name: "Server Notices"
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true

View File

@ -0,0 +1,50 @@
# Log configuration for Synapse.
#
# This is a YAML file containing a standard Python logging configuration
# dictionary. See [1] for details on the valid settings.
#
# Synapse also supports structured logging for machine readable logs which can
# be ingested by ELK stacks. See [2] for details.
#
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
# A handler that writes logs to stderr. Unused by default, but can be used
# instead of "buffer" and "file" in the logger handlers.
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: DEBUG
twisted:
# We send the twisted logging directly to the file handler,
# to work around https://github.com/matrix-org/synapse/issues/3471
# when using "buffer" logger. Use "console" to log to stderr instead.
handlers: [console]
propagate: false
root:
level: DEBUG
# Write logs to the `buffer` handler, which will buffer them together in memory,
# then write them to a file.
#
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
# also need to update the configuration for the `twisted` logger above, in
# this case.)
#
handlers: [console]
disable_existing_loggers: false

View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<title>Test Privacy policy</title>
</head>
<body>
{% if has_consented %}
<p>
Thank you, you've already accepted the license.
</p>
{% else %}
<p>
Please accept the license!
</p>
<form method="post" action="consent">
<input type="hidden" name="v" value="{{version}}"/>
<input type="hidden" name="u" value="{{user}}"/>
<input type="hidden" name="h" value="{{userhmac}}"/>
<input type="submit" value="Sure thing!"/>
</form>
{% endif %}
</body>
</html>

View File

@ -0,0 +1,9 @@
<!doctype html>
<html lang="en">
<head>
<title>Test Privacy policy</title>
</head>
<body>
<p>Danke schon</p>
</body>
</html>

View File

@ -0,0 +1 @@
A synapse configured with user privacy consent disabled

View File

@ -0,0 +1,76 @@
server_name: "localhost"
pid_file: /data/homeserver.pid
public_baseurl: "{{PUBLIC_BASEURL}}"
listeners:
- port: 8008
tls: false
bind_addresses: ['::']
type: http
x_forwarded: true
resources:
- names: [client]
compress: false
database:
name: "sqlite3"
args:
database: ":memory:"
log_config: "/data/log.config"
rc_messages_per_second: 10000
rc_message_burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000
rc_joins:
local:
per_second: 9999
burst_count: 9999
remote:
per_second: 9999
burst_count: 9999
rc_joins_per_room:
per_second: 9999
burst_count: 9999
rc_3pid_validation:
per_second: 1000
burst_count: 1000
rc_invites:
per_room:
per_second: 1000
burst_count: 1000
per_user:
per_second: 1000
burst_count: 1000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
media_store_path: "/data/media_store"
uploads_path: "/data/uploads"
enable_registration: true
enable_registration_without_verification: true
disable_msisdn_registration: false
registration_shared_secret: "{{REGISTRATION_SECRET}}"
report_stats: false
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
form_secret: "{{FORM_SECRET}}"
signing_key_path: "/data/localhost.signing.key"
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true
ui_auth:
session_timeout: "300s"

View File

@ -0,0 +1,50 @@
# Log configuration for Synapse.
#
# This is a YAML file containing a standard Python logging configuration
# dictionary. See [1] for details on the valid settings.
#
# Synapse also supports structured logging for machine readable logs which can
# be ingested by ELK stacks. See [2] for details.
#
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
# A handler that writes logs to stderr. Unused by default, but can be used
# instead of "buffer" and "file" in the logger handlers.
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: DEBUG
twisted:
# We send the twisted logging directly to the file handler,
# to work around https://github.com/matrix-org/synapse/issues/3471
# when using "buffer" logger. Use "console" to log to stderr instead.
handlers: [console]
propagate: false
root:
level: DEBUG
# Write logs to the `buffer` handler, which will buffer them together in memory,
# then write them to a file.
#
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
# also need to update the configuration for the `twisted` logger above, in
# this case.)
#
handlers: [console]
disable_existing_loggers: false

View File

@ -0,0 +1,89 @@
server_name: "localhost"
pid_file: /data/homeserver.pid
public_baseurl: "{{PUBLIC_BASEURL}}"
listeners:
- port: 8008
tls: false
bind_addresses: ['::']
type: http
x_forwarded: true
resources:
- names: [client]
compress: false
database:
name: "sqlite3"
args:
database: ":memory:"
log_config: "/data/log.config"
rc_messages_per_second: 10000
rc_message_burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000
rc_joins:
local:
per_second: 9999
burst_count: 9999
remote:
per_second: 9999
burst_count: 9999
rc_joins_per_room:
per_second: 9999
burst_count: 9999
rc_3pid_validation:
per_second: 1000
burst_count: 1000
rc_invites:
per_room:
per_second: 1000
burst_count: 1000
per_user:
per_second: 1000
burst_count: 1000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
media_store_path: "/data/media_store"
uploads_path: "/data/uploads"
enable_registration: true
enable_registration_without_verification: true
disable_msisdn_registration: false
registration_shared_secret: "{{REGISTRATION_SECRET}}"
report_stats: false
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
form_secret: "{{FORM_SECRET}}"
signing_key_path: "/data/localhost.signing.key"
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true
ui_auth:
session_timeout: "300s"
oidc_providers:
- idp_id: dex
idp_name: "My Dex server"
skip_verification: true # This is needed as Dex is served on an insecure endpoint
issuer: "{{OIDC_ISSUER}}"
client_id: "synapse"
client_secret: "secret"
scopes: ["openid", "profile"]
user_mapping_provider:
config:
localpart_template: "{{ user.name }}"
display_name_template: "{{ user.name|capitalize }}"

View File

@ -0,0 +1,50 @@
# Log configuration for Synapse.
#
# This is a YAML file containing a standard Python logging configuration
# dictionary. See [1] for details on the valid settings.
#
# Synapse also supports structured logging for machine readable logs which can
# be ingested by ELK stacks. See [2] for details.
#
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
# A handler that writes logs to stderr. Unused by default, but can be used
# instead of "buffer" and "file" in the logger handlers.
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: DEBUG
twisted:
# We send the twisted logging directly to the file handler,
# to work around https://github.com/matrix-org/synapse/issues/3471
# when using "buffer" logger. Use "console" to log to stderr instead.
handlers: [console]
propagate: false
root:
level: DEBUG
# Write logs to the `buffer` handler, which will buffer them together in memory,
# then write them to a file.
#
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
# also need to update the configuration for the `twisted` logger above, in
# this case.)
#
handlers: [console]
disable_existing_loggers: false

View File

@ -0,0 +1,59 @@
/*
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 {test} from '@playwright/test';
import {synapseStart, synapseStop, registerUser} from "../plugins/synapsedocker";
import {dexStart, dexStop} from "../plugins/dex";
import type {DexInstance} from "../plugins/dex";
import type {SynapseInstance} from "../plugins/synapsedocker";
test.describe("Login", () => {
let synapse: SynapseInstance;
let dex: DexInstance;
test.beforeEach(async () => {
dex = await dexStart();
synapse = await synapseStart("sso");
});
test.afterEach(async () => {
await synapseStop(synapse.synapseId);
await dexStop(dex.dexId);
});
test("Login using username/password", async ({ page }) => {
const username = "foobaraccount";
const password = "password123";
await registerUser(synapse, username, password);
await page.goto("/");
await page.locator("#homeserver").fill("");
await page.locator("#homeserver").type(synapse.baseUrl);
await page.locator("#username").type(username);
await page.locator("#password").type(password);
await page.getByText('Log In', { exact: true }).click();
await page.locator(".SessionView").waitFor();
});
test("Login using SSO", async ({ page }) => {
await page.goto("/");
await page.locator("#homeserver").fill("");
await page.locator("#homeserver").type(synapse.baseUrl);
await page.locator(".StartSSOLoginView_button").click();
await page.getByText("Log in with Example").click();
await page.locator(".dex-btn-text", {hasText: "Grant Access"}).click();
await page.locator(".primary-button", {hasText: "Continue"}).click();
await page.locator(".SessionView").waitFor();
});
});

View File

@ -0,0 +1,21 @@
/*
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 {test} from '@playwright/test';
test("App has no startup errors that prevent UI render", async ({ page }) => {
await page.goto("/");
await page.getByText("Log In", { exact: true }).waitFor();
});

View File

@ -13,11 +13,17 @@ 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').posix;
// Use the path implementation native to the platform so paths from disk play
// well with resolving against the relative location (think Windows `C:\` and
// backslashes).
const path = require('path');
// Use the posix (forward slash) implementation when working with `import` paths
// to reference resources
const posixPath = require('path').posix;
const {optimize} = require('svgo');
async function readCSSSource(location) {
const fs = require("fs").promises;
const path = require("path");
const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`);
const data = await fs.readFile(resolvedLocation);
return data;
@ -43,29 +49,54 @@ function addThemesToConfig(bundle, manifestLocations, defaultThemes) {
}
}
function parseBundle(bundle) {
/**
* Returns an object where keys are the svg file names and the values
* are the svg code (optimized)
* @param {*} icons Object where keys are css variable names and values are locations of the svg
* @param {*} manifestLocation Location of manifest used for resolving path
*/
async function generateIconSourceMap(icons, manifestLocation) {
const sources = {};
const fileNames = [];
const promises = [];
const fs = require("fs").promises;
for (const icon of Object.values(icons)) {
const [location] = icon.split("?");
// resolve location against manifestLocation
const resolvedLocation = path.resolve(manifestLocation, location);
const iconData = fs.readFile(resolvedLocation);
promises.push(iconData);
const fileName = path.basename(resolvedLocation);
fileNames.push(fileName);
}
const results = await Promise.all(promises);
for (let i = 0; i < results.length; ++i) {
const svgString = results[i].toString();
const result = optimize(svgString, {
plugins: [
{
name: "preset-default",
params: {
overrides: { convertColors: false, },
},
},
],
});
const optimizedSvgString = result.data;
sources[fileNames[i]] = optimizedSvgString;
}
return sources;
}
/**
* 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 +111,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 +168,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 +181,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 +230,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") {
@ -166,7 +244,7 @@ module.exports = function buildThemes(options) {
switch (file) {
case "index.js": {
const isDark = variants[variant].dark;
return `import "${path.resolve(`${location}/theme.css`)}${isDark? "?dark=true": ""}";` +
return `import "${posixPath.resolve(`${location}/theme.css`)}${isDark? "?dark=true": ""}";` +
`import "@theme/${theme}/${variant}/variables.css"`;
}
case "variables.css": {
@ -245,30 +323,53 @@ module.exports = function buildThemes(options) {
];
},
generateBundle(_, bundle) {
// assetMap: Mapping from asset-name (eg: element-dark.css) to AssetInfo
// chunkMap: Mapping from theme-location (eg: hydrogen-web/src/.../css/themes/element) to a list of ChunkInfo
// types of AssetInfo and ChunkInfo can be found at https://rollupjs.org/guide/en/#generatebundle
const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle);
async generateBundle(_, 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 = {};
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;
}
// Emit the base svg icons as asset
const nameToAssetHashedLocation = [];
const nameToSource = await generateIconSourceMap(icon, location);
for (const [name, source] of Object.entries(nameToSource)) {
const ref = this.emitFile({ type: "asset", name, source });
const assetHashedName = this.getFileName(ref);
nameToAssetHashedLocation[name] = assetHashedName;
}
// Update icon section in output manifest with paths to the icon in build output
for (const [variable, location] of Object.entries(icon)) {
const [locationWithoutQueryParameters, queryParameters] = location.split("?");
const name = path.basename(locationWithoutQueryParameters);
const locationRelativeToBuildRoot = nameToAssetHashedLocation[name];
const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot);
icon[variable] = `${locationRelativeToManifest}?${queryParameters}`;
}
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
"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,

View File

@ -112,7 +112,14 @@ function populateMapWithDerivedVariables(map, cssFileLocation, {resolvedMap, ali
...([...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);
}
}
/**

View File

@ -61,7 +61,13 @@ function addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVari
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() {
@ -81,7 +87,8 @@ module.exports = (opts = {}) => {
const urlVariables = new Map();
const counter = createCounter();
root.walkDecls(decl => findAndReplaceUrl(decl, urlVariables, counter));
if (urlVariables.size) {
const cssFileLocation = root.source.input.from;
if (urlVariables.size && !cssFileLocation.includes("type=runtime")) {
addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables);
}
if (opts.compiledVariables){

View File

@ -14,12 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const fs = require("fs");
const path = require("path");
const xxhash = require('xxhashjs');
import {readFileSync, mkdirSync, writeFileSync} from "fs";
import {resolve} from "path";
import {h32} from "xxhashjs";
import {getColoredSvgString} from "../../src/platform/web/theming/shared/svg-colorizer.mjs";
function createHash(content) {
const hasher = new xxhash.h32(0);
const hasher = new h32(0);
hasher.update(content);
return hasher.digest();
}
@ -30,18 +31,14 @@ function createHash(content) {
* @param {string} primaryColor Primary color for the new svg
* @param {string} secondaryColor Secondary color for the new svg
*/
module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondaryColor) {
const svgCode = fs.readFileSync(svgLocation, { encoding: "utf8"});
let coloredSVGCode = svgCode.replaceAll("#ff00ff", primaryColor);
coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor);
if (svgCode === coloredSVGCode) {
throw new Error("svg-colorizer made no color replacements! The input svg should only contain colors #ff00ff (primary, case-sensitive) and #00ffff (secondary, case-sensitive).");
}
export function buildColorizedSVG(svgLocation, primaryColor, secondaryColor) {
const svgCode = readFileSync(svgLocation, { encoding: "utf8"});
const coloredSVGCode = getColoredSvgString(svgCode, primaryColor, secondaryColor);
const fileName = svgLocation.match(/.+[/\\](.+\.svg)/)[1];
const outputName = `${fileName.substring(0, fileName.length - 4)}-${createHash(coloredSVGCode)}.svg`;
const outputPath = path.resolve(__dirname, "../../.tmp");
const outputPath = resolve(__dirname, "./.tmp");
try {
fs.mkdirSync(outputPath);
mkdirSync(outputPath);
}
catch (e) {
if (e.code !== "EEXIST") {
@ -49,6 +46,6 @@ module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondar
}
}
const outputFile = `${outputPath}/${outputName}`;
fs.writeFileSync(outputFile, coloredSVGCode);
writeFileSync(outputFile, coloredSVGCode);
return outputFile;
}

View File

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

19
scripts/test-app.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# Make sure docker is available
if ! docker info > /dev/null 2>&1; then
echo "You need to intall docker before you can run the tests!"
exit 1
fi
# Stop running containers
if docker stop hydrogen-synapse > /dev/null 2>&1; then
echo "Existing 'hydrogen-synapse' container stopped ✔"
fi
if docker stop hydrogen-dex > /dev/null 2>&1; then
echo "Existing 'hydrogen-dex' container stopped ✔"
fi
# Run playwright
yarn playwright test

View File

@ -0,0 +1,5 @@
#!/bin/sh
cp scripts/test-derived-theme/theme.json target/assets/theme-customer.json
cat target/config.json | jq '.themeManifests += ["assets/theme-customer.json"]' | cat > target/config.temp.json
rm target/config.json
mv target/config.temp.json target/config.json

View File

@ -0,0 +1,51 @@
{
"name": "Customer",
"extends": "element",
"id": "customer",
"values": {
"variants": {
"dark": {
"dark": true,
"default": true,
"name": "Dark",
"variables": {
"background-color-primary": "#21262b",
"background-color-secondary": "#2D3239",
"text-color": "#fff",
"accent-color": "#F03F5B",
"error-color": "#FF4B55",
"fixed-white": "#fff",
"room-badge": "#61708b",
"link-color": "#238cf5"
}
},
"light": {
"default": true,
"name": "Dark",
"variables": {
"background-color-primary": "#21262b",
"background-color-secondary": "#2D3239",
"text-color": "#fff",
"accent-color": "#F03F5B",
"error-color": "#FF4B55",
"fixed-white": "#fff",
"room-badge": "#61708b",
"link-color": "#238cf5"
}
},
"red": {
"name": "Gruvbox",
"variables": {
"background-color-primary": "#282828",
"background-color-secondary": "#3c3836",
"text-color": "#fbf1c7",
"accent-color": "#8ec07c",
"error-color": "#fb4934",
"fixed-white": "#fff",
"room-badge": "#cc241d",
"link-color": "#fe8019"
}
}
}
}
}

View File

@ -20,15 +20,15 @@ import type { Session } from "../matrix/Session";
import { ErrorViewModel } from "./ErrorViewModel";
import type { LogCallback, LabelOrValues } from "../logging/types";
export type Options = BaseOptions & {
export type Options<N extends object> = BaseOptions<N> & {
session: Session
};
/** Base class for view models that need to report errors to the UI. */
export class ErrorReportViewModel<O extends Options = Options> extends ViewModel<O> {
private _errorViewModel?: ErrorViewModel;
export class ErrorReportViewModel<N extends object, O extends Options<N> = Options<N>> extends ViewModel<N, O> {
private _errorViewModel?: ErrorViewModel<N>;
get errorViewModel(): ErrorViewModel | undefined {
get errorViewModel(): ErrorViewModel<N> | undefined {
return this._errorViewModel;
}

View File

@ -17,16 +17,17 @@ limitations under the License.
import { ViewModel, Options as BaseOptions } from "./ViewModel";
import {submitLogsFromSessionToDefaultServer} from "./rageshake";
import type { Session } from "../matrix/Session";
import type {SegmentType} from "./navigation/index";
type Options = {
type Options<N extends object> = {
error: Error
session: Session,
onClose: () => void
} & BaseOptions;
} & BaseOptions<N>;
export class ErrorViewModel extends ViewModel<Options> {
export class ErrorViewModel<N extends object = SegmentType, O extends Options<N> = Options<N>> extends ViewModel<N, O> {
get message(): string {
return this.getOption("error")?.message;
return this.error.message;
}
get error(): Error {
@ -45,4 +46,4 @@ export class ErrorViewModel extends ViewModel<Options> {
return false;
}
}
}
}

View File

@ -0,0 +1,81 @@
/*
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.
*/
import {Options as BaseOptions, ViewModel} from "./ViewModel";
import {Client} from "../matrix/Client.js";
import {SegmentType} from "./navigation/index";
type Options = { sessionId: string; } & BaseOptions;
export class ForcedLogoutViewModel extends ViewModel<SegmentType, Options> {
private _sessionId: string;
private _error?: Error;
private _logoutPromise: Promise<void>;
private _showStatus: boolean = false;
private _showSpinner: boolean = false;
constructor(options: Options) {
super(options);
this._sessionId = options.sessionId;
// Start the logout process immediately without any user interaction
this._logoutPromise = this.forceLogout();
}
async forceLogout(): Promise<void> {
try {
const client = new Client(this.platform);
await client.startForcedLogout(this._sessionId);
}
catch (err) {
this._error = err;
// Show the error in the UI
this._showSpinner = false;
this._showStatus = true;
this.emitChange("error");
}
}
async proceed(): Promise<void> {
/**
* The logout should already be completed because we started it from the ctor.
* In case the logout is still proceeding, we will show a message with a spinner.
*/
this._showSpinner = true;
this._showStatus = true;
this.emitChange("showStatus");
await this._logoutPromise;
// At this point, the logout is completed for sure.
if (!this._error) {
this.navigation.push("login", true);
}
}
get status(): string {
if (this._error) {
return this.i18n`Could not log out of device: ${this._error.message}`;
} else {
return this.i18n`Logging out… Please don't close the app.`;
}
}
get showStatus(): boolean {
return this._showStatus;
}
get showSpinner(): boolean {
return this._showSpinner;
}
}

View File

@ -14,18 +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<LogoutOptions> {
export class LogoutViewModel extends ViewModel<SegmentType, Options> {
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;
@ -41,8 +42,8 @@ export class LogoutViewModel extends ViewModel<LogoutOptions> {
return this._busy;
}
get cancelUrl(): string {
return this.urlCreator.urlForSegment("session", true);
get cancelUrl(): string | undefined {
return this.urlRouter.urlForSegment("session", true);
}
async logout(): Promise<void> {

View File

@ -17,8 +17,9 @@ 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 {ForcedLogoutViewModel} from "./ForcedLogoutViewModel";
import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
import {ViewModel} from "./ViewModel";
@ -30,6 +31,7 @@ export class RootViewModel extends ViewModel {
this._sessionLoadViewModel = null;
this._loginViewModel = null;
this._logoutViewModel = null;
this._forcedLogoutViewModel = null;
this._sessionViewModel = null;
this._pendingClient = null;
}
@ -38,18 +40,24 @@ export class RootViewModel extends ViewModel {
this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("logout").subscribe(() => this._applyNavigation()));
this._applyNavigation(true);
}
async _applyNavigation(shouldRestoreLastUrl) {
const isLogin = this.navigation.path.get("login");
const logoutSessionId = this.navigation.path.get("logout")?.value;
const isForcedLogout = this.navigation.path.get("forced")?.value;
const sessionId = this.navigation.path.get("session")?.value;
const loginToken = this.navigation.path.get("sso")?.value;
if (isLogin) {
if (this.activeSection !== "login") {
this._showLogin();
}
} else if (logoutSessionId && isForcedLogout) {
if (this.activeSection !== "forced-logout") {
this._showForcedLogout(logoutSessionId);
}
} else if (logoutSessionId) {
if (this.activeSection !== "logout") {
this._showLogout(logoutSessionId);
@ -75,14 +83,14 @@ export class RootViewModel extends ViewModel {
}
}
} else if (loginToken) {
this.urlCreator.normalizeUrl();
this.urlRouter.normalizeUrl();
if (this.activeSection !== "login") {
this._showLogin(loginToken);
}
}
else {
try {
if (!(shouldRestoreLastUrl && this.urlCreator.tryRestoreLastUrl())) {
if (!(shouldRestoreLastUrl && this.urlRouter.tryRestoreLastUrl())) {
const sessionInfos = await this.platform.sessionInfoStorage.getAll();
if (sessionInfos.length === 0) {
this.navigation.push("login");
@ -118,7 +126,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.
@ -136,6 +144,12 @@ export class RootViewModel extends ViewModel {
});
}
_showForcedLogout(sessionId) {
this._setSection(() => {
this._forcedLogoutViewModel = new ForcedLogoutViewModel(this.childOptions({sessionId}));
});
}
_showSession(client) {
this._setSection(() => {
this._sessionViewModel = new SessionViewModel(this.childOptions({client}));
@ -164,6 +178,8 @@ export class RootViewModel extends ViewModel {
return "login";
} else if (this._logoutViewModel) {
return "logout";
} else if (this._forcedLogoutViewModel) {
return "forced-logout";
} else if (this._sessionPickerViewModel) {
return "picker";
} else if (this._sessionLoadViewModel) {
@ -180,6 +196,7 @@ export class RootViewModel extends ViewModel {
this._sessionLoadViewModel = this.disposeTracked(this._sessionLoadViewModel);
this._loginViewModel = this.disposeTracked(this._loginViewModel);
this._logoutViewModel = this.disposeTracked(this._logoutViewModel);
this._forcedLogoutViewModel = this.disposeTracked(this._forcedLogoutViewModel);
this._sessionViewModel = this.disposeTracked(this._sessionViewModel);
// now set it again
setter();
@ -187,6 +204,7 @@ export class RootViewModel extends ViewModel {
this._sessionLoadViewModel && this.track(this._sessionLoadViewModel);
this._loginViewModel && this.track(this._loginViewModel);
this._logoutViewModel && this.track(this._logoutViewModel);
this._forcedLogoutViewModel && this.track(this._forcedLogoutViewModel);
this._sessionViewModel && this.track(this._sessionViewModel);
this.emitChange("activeSection");
}
@ -195,6 +213,7 @@ export class RootViewModel extends ViewModel {
get sessionViewModel() { return this._sessionViewModel; }
get loginViewModel() { return this._loginViewModel; }
get logoutViewModel() { return this._logoutViewModel; }
get forcedLogoutViewModel() { return this._forcedLogoutViewModel; }
get sessionPickerViewModel() { return this._sessionPickerViewModel; }
get sessionLoadViewModel() { return this._sessionLoadViewModel; }
}

View File

@ -29,7 +29,7 @@ export class SessionLoadViewModel extends ViewModel {
this._deleteSessionOnCancel = deleteSessionOnCancel;
this._loading = false;
this._error = null;
this.backUrl = this.urlCreator.urlForSegment("session", true);
this.backUrl = this.urlRouter.urlForSegment("session", true);
this._accountSetupViewModel = undefined;
}
@ -154,7 +154,7 @@ export class SessionLoadViewModel extends ViewModel {
}
async logout() {
await this._client.logout();
await this._client.startLogout(this.navigation.path.get("session").value);
this.navigation.push("session", true);
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SortedArray} from "../observable/index";
import {SortedArray} from "../observable";
import {ViewModel} from "./ViewModel";
import {avatarInitials, getIdentifierColorNumber} from "./avatar";
@ -38,7 +38,7 @@ class SessionItemViewModel extends ViewModel {
}
get openUrl() {
return this.urlCreator.urlForSegment("session", this.id);
return this.urlRouter.urlForSegment("session", this.id);
}
get label() {
@ -94,6 +94,6 @@ export class SessionPickerViewModel extends ViewModel {
}
get cancelUrl() {
return this.urlCreator.urlForSegment("login");
return this.urlRouter.urlForSegment("login");
}
}

View File

@ -27,17 +27,20 @@ import type {Platform} from "../platform/web/Platform";
import type {Clock} from "../platform/web/dom/Clock";
import type {ILogger} from "../logging/types";
import type {Navigation} from "./navigation/Navigation";
import type {URLRouter} from "./navigation/URLRouter";
import type {SegmentType} from "./navigation/index";
import type {IURLRouter} from "./navigation/URLRouter";
import type { ITimeFormatter } from "../platform/types/types";
export type Options = {
platform: Platform
logger: ILogger
urlCreator: URLRouter
navigation: Navigation
emitChange?: (params: any) => void
export type Options<T extends object = SegmentType> = {
platform: Platform;
logger: ILogger;
urlRouter: IURLRouter<T>;
navigation: Navigation<T>;
emitChange?: (params: any) => void;
}
export class ViewModel<O extends Options = Options> extends EventEmitter<{change: never}> {
export class ViewModel<N extends object = SegmentType, O extends Options<N> = Options<N>> extends EventEmitter<{change: never}> {
private disposables?: Disposables;
private _isDisposed = false;
private _options: Readonly<O>;
@ -58,11 +61,11 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this._options[name];
}
observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) {
observeNavigation<T extends keyof N>(type: T, onChange: (value: N[T], type: T) => void): void {
const segmentObservable = this.navigation.observe(type);
const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => {
const unsubscribe = segmentObservable.subscribe((value: N[T]) => {
onChange(value, type);
})
});
this.track(unsubscribe);
}
@ -100,10 +103,10 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
// 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) {
@ -135,11 +138,16 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this.platform.logger;
}
get urlCreator(): URLRouter {
return this._options.urlCreator;
get urlRouter(): IURLRouter<N> {
return this._options.urlRouter;
}
get navigation(): Navigation {
return this._options.navigation;
get navigation(): Navigation<N> {
// typescript needs a little help here
return this._options.navigation as unknown as Navigation<N>;
}
get timeFormatter(): ITimeFormatter {
return this._options.platform.timeFormatter;
}
}

View File

@ -14,11 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ViewModel} from "../ViewModel";
import {Options as BaseOptions, ViewModel} from "../ViewModel";
import {LoginFailure} from "../../matrix/Client.js";
import type {TokenLoginMethod} from "../../matrix/login";
import { Client } from "../../matrix/Client.js";
type Options = {
client: Client;
attemptLogin: (loginMethod: TokenLoginMethod) => Promise<null>;
loginToken: string;
} & BaseOptions
export class CompleteSSOLoginViewModel extends ViewModel {
constructor(options) {
private _loginToken: string;
private _client: Client;
private _attemptLogin: (loginMethod: TokenLoginMethod) => Promise<null>;
private _errorMessage = "";
constructor(options: Options) {
super(options);
const {
loginToken,
@ -29,22 +42,22 @@ export class CompleteSSOLoginViewModel extends ViewModel {
this._client = client;
this._attemptLogin = attemptLogin;
this._errorMessage = "";
this.performSSOLoginCompletion();
void this.performSSOLoginCompletion();
}
get errorMessage() { return this._errorMessage; }
get errorMessage(): string { return this._errorMessage; }
_showError(message) {
_showError(message: string): void {
this._errorMessage = message;
this.emitChange("errorMessage");
}
async performSSOLoginCompletion() {
async performSSOLoginCompletion(): Promise<void> {
if (!this._loginToken) {
return;
}
const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver");
let loginOptions;
let loginOptions: { token?: (loginToken: string) => TokenLoginMethod; };
try {
loginOptions = await this._client.queryLogin(homeserver).result;
}

View File

@ -15,101 +15,145 @@ limitations under the License.
*/
import {Client} from "../../matrix/Client.js";
import {ViewModel} from "../ViewModel";
import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js";
import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js";
import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js";
import {Options as BaseOptions, ViewModel} from "../ViewModel";
import {PasswordLoginViewModel} from "./PasswordLoginViewModel";
import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel";
import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel";
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<SegmentType, Options> {
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<Options>) {
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 | undefined {
return this._passwordLoginViewModel;
}
goBack() {
get startSSOLoginViewModel(): StartSSOLoginViewModel | undefined {
return this._startSSOLoginViewModel;
}
get completeSSOLoginViewModel(): CompleteSSOLoginViewModel | undefined {
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<null> {
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<void> {
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<void> {
// 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.
export type LoginOptions = {
homeserver: string;
password?: (username: string, password: string) => PasswordLoginMethod;
sso?: SSOLoginHelper;
token?: (loginToken: string) => TokenLoginMethod;
};

View File

@ -14,43 +14,53 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ViewModel} from "../ViewModel";
import {LoginFailure} from "../../matrix/Client.js";
import type {PasswordLoginMethod} from "../../matrix/login";
import {Options as BaseOptions, ViewModel} from "../ViewModel";
import type {LoginOptions} from "./LoginViewModel";
type Options = {
loginOptions: LoginOptions | undefined;
attemptLogin: (loginMethod: PasswordLoginMethod) => Promise<null>;
} & BaseOptions
export class PasswordLoginViewModel extends ViewModel {
constructor(options) {
private _loginOptions?: LoginOptions;
private _attemptLogin: (loginMethod: PasswordLoginMethod) => Promise<null>;
private _isBusy = false;
private _errorMessage = "";
constructor(options: Options) {
super(options);
const {loginOptions, attemptLogin} = options;
this._loginOptions = loginOptions;
this._attemptLogin = attemptLogin;
this._isBusy = false;
this._errorMessage = "";
}
get isBusy() { return this._isBusy; }
get errorMessage() { return this._errorMessage; }
get isBusy(): boolean { return this._isBusy; }
get errorMessage(): string { return this._errorMessage; }
setBusy(status) {
setBusy(status: boolean): void {
this._isBusy = status;
this.emitChange("isBusy");
}
_showError(message) {
_showError(message: string): void {
this._errorMessage = message;
this.emitChange("errorMessage");
}
async login(username, password) {
async login(username: string, password: string): Promise<void>{
this._errorMessage = "";
this.emitChange("errorMessage");
const status = await this._attemptLogin(this._loginOptions.password(username, password));
const status = await this._attemptLogin(this._loginOptions!.password!(username, password));
let error = "";
switch (status) {
case LoginFailure.Credentials:
error = this.i18n`Your username and/or password don't seem to be correct.`;
break;
case LoginFailure.Connection:
error = this.i18n`Can't connect to ${this._loginOptions.homeserver}.`;
error = this.i18n`Can't connect to ${this._loginOptions!.homeserver}.`;
break;
case LoginFailure.Unknown:
error = this.i18n`Something went wrong while checking your login and password.`;

View File

@ -14,25 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ViewModel} from "../ViewModel";
import type {SSOLoginHelper} from "../../matrix/login";
import {Options as BaseOptions, ViewModel} from "../ViewModel";
import type {LoginOptions} from "./LoginViewModel";
type Options = {
loginOptions: LoginOptions | undefined;
} & BaseOptions;
export class StartSSOLoginViewModel extends ViewModel{
constructor(options) {
private _sso?: SSOLoginHelper;
private _isBusy = false;
constructor(options: Options) {
super(options);
this._sso = options.loginOptions.sso;
this._sso = options.loginOptions!.sso;
this._isBusy = false;
}
get isBusy() { return this._isBusy; }
setBusy(status) {
get isBusy(): boolean { return this._isBusy; }
setBusy(status: boolean): void {
this._isBusy = status;
this.emitChange("isBusy");
}
async startSSOLogin() {
await this.platform.settingsStorage.setString("sso_ongoing_login_homeserver", this._sso.homeserver);
const link = this._sso.createSSORedirectURL(this.urlCreator.createSSOCallbackURL());
async startSSOLogin(): Promise<void> {
await this.platform.settingsStorage.setString("sso_ongoing_login_homeserver", this._sso!.homeserver);
const link = this._sso!.createSSORedirectURL(this.urlRouter.createSSOCallbackURL());
this.platform.openUrl(link);
}
}

Some files were not shown because too many files have changed in this diff Show More