Merge branch 'master' into thirdroom/dev

This commit is contained in:
Robert Long 2023-03-15 15:02:29 -07:00
commit 02c7b79d50
262 changed files with 6705 additions and 1852 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

View File

@ -21,13 +21,16 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@ -35,14 +38,15 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7

1
.gitignore vendored
View File

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

View File

@ -20,6 +20,10 @@ module.exports = {
rules: {
"@typescript-eslint/no-floating-promises": 2,
"@typescript-eslint/no-misused-promises": 2,
"semi": ["error", "always"]
"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,24 +1,26 @@
FROM --platform=${BUILDPLATFORM} docker.io/library/node:16.13-alpine3.15 as builder
FROM --platform=${BUILDPLATFORM} docker.io/node:alpine as builder
RUN apk add --no-cache git python3 build-base
WORKDIR /app
# Install the dependencies first
COPY yarn.lock package.json ./
# 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 the rest and build the app
COPY . .
COPY . /app
RUN yarn build
# Remove the default config, replace it with a symlink to somewhere that will be updated at runtime
RUN rm -f target/config.json \
# Because we will be running as an unprivileged user, we need to make sure that the config file is writable
# So, we will copy the default config to the /tmp folder that will be writable at runtime
RUN mv -f target/config.json /config.json.bundled \
&& ln -sf /tmp/config.json target/config.json
FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:1.21-alpine
FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:alpine
# Copy the config template as well as the templating script
COPY ./docker/config.json.tmpl /config.json.tmpl
COPY ./docker/config-template.sh /docker-entrypoint.d/99-config-template.sh
# Copy the dynamic config script
COPY ./docker/dynamic-config.sh /docker-entrypoint.d/99-dynamic-config.sh
# And the bundled config file
COPY --from=builder /config.json.bundled /config.json.bundled
# 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:

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

@ -41,11 +41,11 @@ export DOCKER_BUILDKIT=1
docker build -t hydrogen .
```
Or, pull the Docker image the GitHub Container Registry:
Or, pull the docker image from GitHub Container Registry:
```
docker pull ghcr.io/vector-im/hydrogen
docker tag ghcr.io/vector-im/hydrogen hydrogen
docker pull ghcr.io/vector-im/hydrogen-web
docker tag ghcr.io/vector-im/hydrogen-web hydrogen
```
### Start container image
@ -55,6 +55,32 @@ Then, start up a container from that image:
```
docker run \
--name hydrogen \
--publish 8080:80 \
--publish 8080:8080 \
hydrogen
```
n.b. the image is now based on the unprivileged nginx base, so the port is now `8080` instead of `80` and you need a writable `/tmp` volume.
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 8080:8080 \
--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
```

15
doc/error-handling.md Normal file
View File

@ -0,0 +1,15 @@
# Error handling
Ideally, every error that is unexpected and can't be automatically recovered from without degrading the experience is shown in the UI. This is the task of the view model, and you can use `ErrorReportViewModel` for this purpose, a dedicated base view model class. It exposes a child view model, `ErrorViewModel`, when `reportError` is called which can be paired with `ErrorView` in the view to present an error message from which debug logs can also be sent.
Methods on classes from the `matrix` layer can often throw errors and those errors should be caught in the view model and reported to the UI. When inheriting from `ErrorReportViewModel`, there is the low-level `reportError` method, but typically you'd use the convenience method `logAndCatch`. The latter makes it easy to get both error handlikng and logging right. You would typically use `logAndCatch` for every public method in the view model (e.g methods called from the view or from the parent view model). It calls a callback within a log item and also a try catch that reports the error.
## Sync errors & ErrorBoundary
There are some errors that are thrown during background processes though, most notably the sync loop. These processes are not triggered by the view model directly, and hence there is not always a method call they can wrap in a try/catch. For this, there is the `ErrorBoundary` utility class. Since almost all aspects of the client can be updated through the sync loop, it is also not too helpful if there is only one try/catch around the whole sync and we stop sync if something goes wrong.
Instead, it's more helpful to split up the error handling into different scopes, where errors are stored and not rethrown when leaving the scope. One example is to have a scope per room. In this way, we can isolate an error occuring during sync to a specific room, and report it in the UI of that room. This is typically where you would use `reportError` from `ErrorReportViewModel` rather than `logAndCatch`. You observe changes from your model in the view model (see docs on updates), and if the `error` property is set (by the `ErrorBoundary`), you call reportError with it. You can do this repeatedly without problems, if the same error is already reported, it's a No-Op.
### `writeSync` and preventing data loss when dealing with errors.
There is an extra complication though. The `writeSync` sync lifecycle step should not swallow any errors, or data loss can occur. This is because the whole `writeSync` lifecycle step writes all changes (for all rooms, the session, ...) for a sync response in one transaction (including the sync token), and aborts the transaction and stops sync if there is an error thrown during this step. So if there is an error in `writeSync` of a given room, it's fair to assume not all changes it was planning to write were passed to the transaction, as it got interrupted by the exception. Therefore, if we would swallow the error, data loss can occur as we'd not get another chance to write these changes to disk as we would have advanced the sync token. Therefore, code in the `writeSync` lifecycle step should be written defensively but always throw.

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

@ -237,7 +237,7 @@ room.sendEvent(eventEntry.eventType, replacement);
## Replies
```js
const reply = eventEntry.reply({});
const reply = eventEntry.createReplyContent({});
room.sendEvent("m.room.message", reply);
```

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

@ -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
```

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

@ -0,0 +1,11 @@
#!/bin/sh
set -eux
if [ -n "${CONFIG_OVERRIDE:-}" ]; then
# Use config override environment variable if set
echo "$CONFIG_OVERRIDE" > /tmp/config.json
else
# Otherwise, use the default config that was bundled in the image
cp /config.json.bundled /tmp/config.json
fi

View File

@ -1,6 +1,6 @@
{
"name": "hydrogen-web",
"version": "0.3.0",
"version": "0.3.8",
"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",

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,7 +13,13 @@ 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) {
@ -238,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": {

View File

@ -1,7 +1,7 @@
{
"name": "@thirdroom/hydrogen-view-sdk",
"description": "Embeddable matrix client library, including view components",
"version": "0.0.29",
"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,72 @@
/*
Copyright 2023 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 { ViewModel } from "./ViewModel";
import type { Options as BaseOptions } from "./ViewModel";
import type { Session } from "../matrix/Session";
import { ErrorViewModel } from "./ErrorViewModel";
import type { LogCallback, LabelOrValues } from "../logging/types";
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<N extends object, O extends Options<N> = Options<N>> extends ViewModel<N, O> {
private _errorViewModel?: ErrorViewModel<N>;
get errorViewModel(): ErrorViewModel<N> | undefined {
return this._errorViewModel;
}
/** Typically you'd want to use `logAndCatch` when implementing a view model method.
* Use `reportError` when showing errors on your model that were set by
* background processes using `ErrorBoundary` or you have some other
* special low-level need to write your try/catch yourself. */
protected reportError(error: Error) {
if (this._errorViewModel?.error === error) {
return;
}
this.disposeTracked(this._errorViewModel);
this._errorViewModel = this.track(new ErrorViewModel(this.childOptions({
error,
onClose: () => {
this._errorViewModel = this.disposeTracked(this._errorViewModel);
this.emitChange("errorViewModel");
}
})));
this.emitChange("errorViewModel");
}
/** Combines logging and error reporting in one method.
* Wrap the implementation of public view model methods
* with this to ensure errors are logged and reported.*/
protected logAndCatch<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, errorValue: T = undefined as unknown as T): T {
try {
let result = this.logger.run(labelOrValues, callback);
if (result instanceof Promise) {
result = result.catch(err => {
this.reportError(err);
return errorValue;
}) as unknown as T;
}
return result;
} catch (err) {
this.reportError(err);
return errorValue;
}
}
}

View File

@ -0,0 +1,49 @@
/*
Copyright 2023 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 { ViewModel, Options as BaseOptions } from "./ViewModel";
import {submitLogsFromSessionToDefaultServer} from "./rageshake";
import type { Session } from "../matrix/Session";
import type {SegmentType} from "./navigation/index";
type Options<N extends object> = {
error: Error
session: Session,
onClose: () => void
} & BaseOptions<N>;
export class ErrorViewModel<N extends object = SegmentType, O extends Options<N> = Options<N>> extends ViewModel<N, O> {
get message(): string {
return this.error.message;
}
get error(): Error {
return this.getOption("error");
}
close() {
this.getOption("onClose")();
}
async submitLogs(): Promise<boolean> {
try {
await submitLogsFromSessionToDefaultServer(this.getOption("session"), this.platform);
return true;
} catch (err) {
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

@ -43,7 +43,7 @@ export class LogoutViewModel extends ViewModel<SegmentType, Options> {
}
get cancelUrl(): string | undefined {
return this.urlCreator.urlForSegment("session", true);
return this.urlRouter.urlForSegment("session", true);
}
async logout(): Promise<void> {

View File

@ -19,6 +19,7 @@ import {SessionViewModel} from "./session/SessionViewModel.js";
import {SessionLoadViewModel} from "./SessionLoadViewModel.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;
}
@ -39,12 +41,14 @@ export class RootViewModel extends ViewModel {
this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("oidc").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;
const oidcCallback = this.navigation.path.get("oidc")?.value;
@ -52,6 +56,10 @@ export class RootViewModel extends ViewModel {
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);
@ -77,7 +85,7 @@ export class RootViewModel extends ViewModel {
}
}
} else if (loginToken) {
this.urlCreator.normalizeUrl();
this.urlRouter.normalizeUrl();
if (this.activeSection !== "login") {
this._showLogin({loginToken});
}
@ -95,7 +103,7 @@ export class RootViewModel extends ViewModel {
}
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");
@ -150,6 +158,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}));
@ -158,7 +172,7 @@ export class RootViewModel extends ViewModel {
}
_showSessionLoader(sessionId) {
const client = new Client(this.platform);
const client = new Client(this.platform, this.features);
client.startWithExistingSession(sessionId);
this._setSection(() => {
this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({
@ -178,6 +192,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) {
@ -194,6 +210,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();
@ -201,6 +218,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");
}
@ -209,6 +227,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,8 +154,7 @@ export class SessionLoadViewModel extends ViewModel {
}
async logout() {
const sessionId = this.navigation.path.get("session")?.value;
await this._client.startLogout(sessionId);
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

@ -29,13 +29,16 @@ import type {ILogger} from "../logging/types";
import type {Navigation} from "./navigation/Navigation";
import type {SegmentType} from "./navigation/index";
import type {IURLRouter} from "./navigation/URLRouter";
import type { ITimeFormatter } from "../platform/types/types";
import type { FeatureSet } from "../features";
export type Options<T extends object = SegmentType> = {
platform: Platform;
logger: ILogger;
urlCreator: IURLRouter<T>;
urlRouter: IURLRouter<T>;
navigation: Navigation<T>;
emitChange?: (params: any) => void;
features: FeatureSet
}
@ -49,7 +52,7 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
this._options = options;
}
childOptions<T extends Object>(explicitOptions: T): T & Options<N> {
childOptions<T extends Object>(explicitOptions: T): T & O {
return Object.assign({}, this._options, explicitOptions);
}
@ -137,12 +140,20 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
return this.platform.logger;
}
get urlCreator(): IURLRouter<N> {
return this._options.urlCreator;
get urlRouter(): IURLRouter<N> {
return this._options.urlRouter;
}
get features(): FeatureSet {
return this._options.features;
}
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

@ -58,3 +58,11 @@ export function getAvatarHttpUrl(avatarUrl: string | undefined, cssSize: number,
}
return undefined;
}
// move to AvatarView.js when converting to typescript
export interface IAvatarContract {
avatarLetter: string;
avatarColorNumber: number;
avatarUrl: (size: number) => string | undefined;
avatarTitle: string;
}

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

@ -64,20 +64,20 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
this._ready = ready;
this._loginToken = loginToken;
this._oidc = oidc;
this._client = new Client(this.platform);
this._client = new Client(this.platform, this.features);
this._homeserver = defaultHomeserver;
this._initViewModels();
}
get passwordLoginViewModel(): PasswordLoginViewModel {
get passwordLoginViewModel(): PasswordLoginViewModel | undefined {
return this._passwordLoginViewModel;
}
get startSSOLoginViewModel(): StartSSOLoginViewModel {
get startSSOLoginViewModel(): StartSSOLoginViewModel | undefined {
return this._startSSOLoginViewModel;
}
get completeSSOLoginViewModel(): CompleteSSOLoginViewModel {
get completeSSOLoginViewModel(): CompleteSSOLoginViewModel | undefined {
return this._completeSSOLoginViewModel;
}
@ -331,7 +331,7 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
type ReadyFn = (client: Client) => void;
// TODO: move to Client.js when its converted to typescript.
type LoginOptions = {
export type LoginOptions = {
homeserver: string;
password?: (username: string, password: string) => PasswordLoginMethod;
sso?: SSOLoginHelper;

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);
}
}

View File

@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ObservableValue} from "../../observable/value/ObservableValue";
import {BaseObservableValue} from "../../observable/value/BaseObservableValue";
import {BaseObservableValue, ObservableValue} from "../../observable/value";
type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean;

View File

@ -147,7 +147,7 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
openRoomActionUrl(roomId: string): string {
// not a segment to navigation knowns about, so append it manually
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${encodeURIComponent(roomId)}`;
return this._history.pathAsUrl(urlPath);
}

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