Compare commits

...

157 commits

Author SHA1 Message Date
f9aa7b52f8
feat: switch to matrix.test.mystiq.app
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-08-19 17:41:05 +05:30
2e54866353
fix: submit path
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-08-18 17:51:23 +05:30
ce075eb32b
feat: set custom homeserver and bugreport endpoint
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-08-18 17:41:24 +05:30
02a50a19cb
feat: add ci badge
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-08-16 17:25:37 +05:30
a33d9981bd
fix: secrets
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-08-16 17:05:59 +05:30
8335a50308
feat: switch to python, debian doesn't have make installed by default
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-08-16 17:02:10 +05:30
ee9e73d8c7
fix: use debian latest img to get git with git branch --show-current
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-08-16 16:59:15 +05:30
63f77feb7b
fix: set project root
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-08-16 16:55:52 +05:30
04de39596f
feat: bump ci node to 16
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-08-16 16:52:22 +05:30
25b634bb78
fix: use same tests as github actions
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-08-16 16:47:23 +05:30
96c9ea8de7
fix: use node 14, same as github actions config
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-08-16 16:44:15 +05:30
d80e970117
feat: conditional deploy pipeline
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-08-16 16:38:53 +05:30
6db5f34ac2
feat: multi-pipeline workflow 2022-08-16 16:36:05 +05:30
df0000783d
feat: deploy to librepages
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2022-08-16 16:14:27 +05:30
Bruno Windels
c898bcb46a release v0.3.1 2022-08-02 12:16:55 +02:00
Bruno Windels
97391663d3 sdk version 0.1.0 2022-08-01 14:32:26 +02:00
R Midhun Suresh
7d3f22c106
Merge pull request #824 from vector-im/fix-dev-server-1
Fix develop server breaking due to import syntax
2022-08-01 17:29:52 +05:30
RMidhunSuresh
832597447a Add explaining doc 2022-08-01 17:01:36 +05:30
RMidhunSuresh
236a4ab49b Ignore error 2022-08-01 17:01:36 +05:30
RMidhunSuresh
ba8cdea6b4 Use default import if other not found 2022-08-01 17:01:36 +05:30
RMidhunSuresh
ef9f90bc36 Fix imports breaking on dev 2022-08-01 17:01:36 +05:30
R Midhun Suresh
67e94bd642
Merge pull request #825 from vector-im/fix-sdk-fail-1
Fix sdk build failing after derived theme implementation
2022-08-01 16:17:09 +05:30
R Midhun Suresh
f7839135a4
Merge pull request #823 from vector-im/fix-tmp-dir
Fix .tmp being created in `/`
2022-08-01 16:16:35 +05:30
RMidhunSuresh
4571ecd851 Specify theme as array 2022-07-29 23:45:58 +05:30
RMidhunSuresh
5091090795 Produce .tmp directory within root 2022-07-29 23:11:17 +05:30
Bruno Windels
db2b4e693c release v0.3.0 2022-07-29 17:10:24 +02:00
Bruno Windels
eee8412621
Merge pull request #822 from vector-im/bwindels/move-runtime-theme-test-out-of-root
move semi-automatic test for runtime themes into dedicated directory
2022-07-29 15:00:34 +00:00
Bruno Windels
5e83eca3b9 move semi-automatic test for runtime themes into dedicated directory 2022-07-29 16:43:28 +02:00
Bruno Windels
041e628520
Merge pull request #769 from vector-im/implement-derived-theme
Support for derived themes
2022-07-29 14:25:05 +00:00
Bruno Windels
4838e19c92
Merge pull request #811 from vector-im/bwindels/sharekeyswithinvitees
Key sharing based on room history visibility
2022-07-29 14:23:26 +00:00
Bruno Windels
b40ce6137e
Merge pull request #676 from vector-im/ts-conversion-domain-navigation
Convert /domain/navigation to typescript
2022-07-29 14:21:17 +00:00
Bruno Windels
fdefea5b88 Merge branch 'master' into ts-conversion-domain-navigation 2022-07-29 16:18:22 +02:00
RMidhunSuresh
39817dc36b Revert back option 2022-07-29 17:33:33 +05:30
RMidhunSuresh
708637e390 No need for this complex resolve 2022-07-29 16:45:25 +05:30
Bruno Windels
b6f795505d fix lint 2022-07-29 12:21:16 +02:00
Bruno Windels
10522cacef
Merge pull request #813 from vector-im/doc-derived-theming
[Documentation] - Add information about derived themes to doc
2022-07-29 10:16:41 +00:00
Bruno Windels
02116103a1
Merge pull request #816 from Kaki-In/restore_last
Opening the last opened room at start
2022-07-29 10:16:23 +00:00
Bruno Windels
06da5a8ae4
clarification 2022-07-29 10:14:58 +00:00
Bruno Windels
02bc7d1d7e
fix typo 2022-07-29 10:14:41 +00:00
Kaki In
09bc77073b
Merge branch 'vector-im:master' into restore_last 2022-07-29 12:06:49 +02:00
Bruno Windels
4a2e14925a
Merge pull request #812 from vector-im/doc-config
[Documentation] - Add type for config options
2022-07-29 10:05:27 +00:00
Bruno Windels
224ab2672a
Merge pull request #809 from Kaki-In/implement-join
Implemented /join
2022-07-29 10:03:18 +00:00
Bruno Windels
170460f5a9 add link to sygnal webpush docs as well 2022-07-29 12:02:09 +02:00
Bruno Windels
2a5e0302dc
Merge pull request #785 from vector-im/hs/log-when-storage-access-fails
Log the error when we can't get storage access
2022-07-29 09:47:58 +00:00
Kaki In
f512bfcfc1 Pretty syntaxed the RoomViewModel 2022-07-29 11:47:47 +02:00
Half-Shot
5b5c852401 Revert "use logging items"
This reverts commit d937b9b14b.
2022-07-29 10:44:37 +01:00
Kaki In
58a2d1f34c Restored the common.js indentation 2022-07-29 11:44:23 +02:00
Half-Shot
d937b9b14b use logging items 2022-07-29 10:39:41 +01:00
Bruno Windels
d3e93196e3
Merge pull request #777 from ibeckermayer/ibeckermayer/ts-conversion-loginviewmodel
TS conversion for `LoginViewModel`
2022-07-29 09:27:10 +00:00
Kaki In
f5dacb4e42 Fixed last check 2022-07-28 10:26:59 +02:00
Kaki In
302131c447 Review last checks 2022-07-28 10:14:21 +02:00
Kaki In
fb79326747 Forgot one change 2022-07-28 09:26:08 +02:00
Kaki In
3c64f7d49b Finals checks about https://github.com/vector-im/hydrogen-web/pull/809#pullrequestreview-1053501341
- joined the processJoinRoom and joinRoom methods;
 - fixed some precisions miss;
 - removed some useless code;
 - change the error message height from absolute (40px) to relative (auto)
2022-07-28 09:23:30 +02:00
Isaiah Becker-Mayer
a82df95b82 marking private methods as such 2022-07-27 22:09:30 -07:00
Isaiah Becker-Mayer
cadca70946 fixes linter errors and removes some unneeded async/await 2022-07-27 22:09:30 -07:00
Isaiah Becker-Mayer
8b91d8fac8 adds newline 2022-07-27 22:09:30 -07:00
Isaiah Becker-Mayer
a5b9cb6b95 removes unnecessary awaits 2022-07-27 22:09:30 -07:00
Isaiah Becker-Mayer
aeed978789 changes signature of emitChange to require changedProps 2022-07-27 22:09:30 -07:00
Isaiah Becker-Mayer
7b7b19476c updates some signatures to be more verbose, fixes wrong type for attemptLogin 2022-07-27 22:09:30 -07:00
Isaiah Becker-Mayer
ad0bd82bda creating default exports 2022-07-27 22:09:30 -07:00
Isaiah Becker-Mayer
d7657dcc4d first draft of fully typescriptified LoginViewModel.ts 2022-07-27 22:09:30 -07:00
Kaki In
176caf340f Placed the join command outside of the processCommand method 2022-07-27 16:42:44 +02:00
Kaki In
a40bb59dc0 Some fixes :
- fixed a pretty syntax miss (a !== b);
 - fixed a type error : replaced "msgtype" by "type" when instantied the "messinfo" variable;
 - some indentation fixes
2022-07-27 16:36:58 +02:00
Kaki In
ab64ce02b2 Separated the _processCommand and the joinRoom command
- renamed executeJoinCommand as joinRoom;
 - separated the joinRoom process and the parse and result process
2022-07-27 15:18:32 +02:00
Kaki In
2d3b6fe973 Canceled indentation modification. 2022-07-27 12:40:19 +02:00
Kaki In
550b9db4dc Separated the join instructions into a executeJoinCommand method 2022-07-27 12:21:00 +02:00
Kaki In
9b0ab0c8f1 Used "null" instead of "undefined"
When creating the this._lastSessionHash attribute of History
2022-07-27 09:19:36 +02:00
Kaki In
f9f49b7640 Fixed an error and improving css
If the /join command success, an error was thrown, because of a copy-pasted command not well integrated
The button of the error on "theme.css" contains now an unicode cross. The :after/:before cross was disformed when opening the room informations.
2022-07-26 14:48:03 +02:00
Kaki In
0718f1e77e Fixed the https://github.com/vector-im/hydrogen-web/pull/816#discussion_r929692693 comment
Added the _lastSessionHash attribute inside the History constructor
2022-07-26 11:11:16 +02:00
Kaki In
09fd1a5113 Use "args.join" instead of "message.substring"
into RoomViewModel._processCommands
2022-07-26 10:37:05 +02:00
Kaki In
832b840a15 Merge remote-tracking branch 'origin' into restore_last 2022-07-26 10:06:31 +02:00
Kaki In
adfecf0778 Fix restoring the last url at start
The last session url is now remembered for being restored at the beginning of the session. Thanks for the help of @bwindels
2022-07-26 10:02:20 +02:00
Kaki In
5fa6793958
Merge branch 'vector-im:master' into implement-join 2022-07-25 16:30:50 +02:00
Kaki In
1e5179f835 - Application des différents commentaires du Pull Request (#809)
- Correction des erreurs d'indentations.
2022-07-25 15:22:06 +02:00
Kaki In
0bf021ea87 The room is now joined after having actualised the rooms list, to avoid the synchronisations waits that can sometimes disable to enter the room (message "You're not into this room" or simply "You're not in this room yet. *Join the room*") 2022-07-25 13:37:03 +02:00
RMidhunSuresh
fdd60a7516 Add documentation for derived themes 2022-07-25 11:38:50 +05:30
RMidhunSuresh
63bdbee39c Make optional fields optional 2022-07-25 11:33:22 +05:30
RMidhunSuresh
8a976861fb Add type 2022-07-25 11:31:14 +05:30
Kaki In
b7fd22c7f9 SyntaxError fixed 2022-07-22 17:10:29 +02:00
Kaki In
66a59e6f4d Error of interpretation of the 403 status at the last update. Fixed 2022-07-22 17:09:43 +02:00
Kaki In
e345d0b33e Added the 403 status when joining an unknown room 2022-07-22 17:06:09 +02:00
Kaki In
be8962cec2 Fixed priority operations when checking request status 2022-07-22 16:59:48 +02:00
Kaki In
8b39346409 The error message can now be closed 2022-07-22 16:34:52 +02:00
Kaki In
fb58d9c9ef Corrected some syntax dismiss 2022-07-22 16:08:53 +02:00
Kaki In
faa8cae532 Added the possibility to join a room using /join (also added the global commands uses, and some others commands like /shrug .) 2022-07-21 13:55:23 +02:00
RMidhunSuresh
8d766ac504 Remove await within loop 2022-07-21 12:05:10 +05:30
RMidhunSuresh
7feaa479c0 Typescript update to support .mjs files 2022-07-20 15:55:11 +05:30
RMidhunSuresh
1456e308a8 Add type and fix formatting 2022-07-20 15:36:02 +05:30
RMidhunSuresh
313e65e00c Write tests 2022-07-20 12:30:41 +05:30
RMidhunSuresh
612b878793 Update theme name 2022-07-19 21:21:35 +05:30
RMidhunSuresh
8aa96e8031 Update log label 2022-07-19 21:19:22 +05:30
RMidhunSuresh
7ac2c7c7fa Get tests to work 2022-07-19 21:06:55 +05:30
RMidhunSuresh
de02456641 Add explaining comment 2022-07-19 19:46:36 +05:30
RMidhunSuresh
994667205f Remove change 2022-07-19 19:38:36 +05:30
RMidhunSuresh
ecb3a66dfc WIP 2022-07-19 17:56:08 +05:30
RMidhunSuresh
e1ee258630 Change path 2022-07-19 17:56:08 +05:30
RMidhunSuresh
83b5d3b68e Change directory name 2022-07-19 17:56:08 +05:30
RMidhunSuresh
7a1591e0ce Move code 2022-07-19 17:56:08 +05:30
RMidhunSuresh
07db5450b7 Aliases can also be derived 2022-07-19 17:56:08 +05:30
RMidhunSuresh
081de5afa8 .js --> .mjs 2022-07-19 17:56:08 +05:30
RMidhunSuresh
dece42dce3 Do not store all the manifests in memory 2022-07-19 17:56:08 +05:30
RMidhunSuresh
b29287c47e await in loop --> Promise.all() 2022-07-19 17:56:08 +05:30
RMidhunSuresh
9bdf9c500b Add return types 2022-07-19 17:56:08 +05:30
RMidhunSuresh
9e2d355573 Add logging 2022-07-19 17:56:08 +05:30
RMidhunSuresh
ce5db47708 Support using derived theme as default theme 2022-07-19 17:56:08 +05:30
RMidhunSuresh
da0a918c18 This code should only run once 2022-07-19 17:56:08 +05:30
RMidhunSuresh
043cc9f12c Use ThemeManifest type 2022-07-19 17:56:08 +05:30
RMidhunSuresh
80fb953688 Don't fail on erros; expect the code to throw! 2022-07-19 17:56:08 +05:30
RMidhunSuresh
f15e23762a Add more missing keys to type 2022-07-19 17:56:08 +05:30
RMidhunSuresh
f440457875 Use ThemeManifest type where possible 2022-07-19 17:56:08 +05:30
RMidhunSuresh
a8cab98666 Add mroe missing types 2022-07-19 17:56:08 +05:30
RMidhunSuresh
ac7be0c7a1 WIP 2022-07-19 17:56:08 +05:30
RMidhunSuresh
d731eab51c Support fetching text 2022-07-19 17:56:08 +05:30
RMidhunSuresh
f7b302d34f Don't optimzie colors 2022-07-19 17:56:08 +05:30
RMidhunSuresh
5ba74b1d75 Use script to copy over runtime theme after build 2022-07-19 17:56:08 +05:30
RMidhunSuresh
c5f4a75d4b Split code so that it can be reused 2022-07-19 17:56:08 +05:30
RMidhunSuresh
2f3db89e0a Let ts know that we can use replaceAll() 2022-07-19 17:56:08 +05:30
RMidhunSuresh
1ef382f3a9 Add gruvbox color scheme 2022-07-19 17:56:08 +05:30
RMidhunSuresh
161e29b36e Use existing code 2022-07-19 17:56:08 +05:30
RMidhunSuresh
2947f9f6ff Remove console.log 2022-07-19 17:56:08 +05:30
RMidhunSuresh
c873804543 produce asset hashed icons 2022-07-19 17:56:08 +05:30
RMidhunSuresh
43e8cc9e52 Add svgo for optimizing svgs as dev dependency 2022-07-19 17:56:08 +05:30
RMidhunSuresh
bf87ed7eae Do not add variables to root for runtime theme 2022-07-19 17:56:08 +05:30
RMidhunSuresh
8c02541b69 WIP - 1 2022-07-19 17:56:08 +05:30
RMidhunSuresh
599e519f22 Convert color code to use es6 module 2022-07-19 17:56:08 +05:30
RMidhunSuresh
d5e24bf6e8 Convert color.js to color.mjs 2022-07-19 17:56:08 +05:30
Isaiah Becker-Mayer
204948db64 changing filename to ts 2022-07-06 21:06:36 -04:00
Will Hunt
a85d2c96d6
Log the error when we can't get storage access
This is quite useful when debugging why a session isn't working properly.
2022-07-06 10:06:00 +01:00
RMidhunSuresh
d31f127982 Add explaining comment 2022-06-07 13:28:56 +05:30
RMidhunSuresh
ba647d012d Fix type in observeNavigation 2022-05-29 20:38:14 +05:30
RMidhunSuresh
fc873757d8 WIP 2022-05-27 22:42:21 +05:30
RMidhunSuresh
ec1cc89cf9 Make URLRouter in options conditional on generic
URLRouter can be passed in option to vm only if the SegmentType used
contains session.
ViewModel.urlCreator returns undefined when used with a SegmentType that
lacks session.
2022-05-27 22:42:21 +05:30
RMidhunSuresh
a336623f3a Generic parameter should extend object 2022-05-27 22:42:21 +05:30
RMidhunSuresh
9300347e9b Give defaultt type 2022-05-27 22:42:21 +05:30
RMidhunSuresh
f49d580d49 WIP 2022-05-27 22:42:21 +05:30
RMidhunSuresh
263948faa3 Remove unwanted export 2022-05-27 22:42:21 +05:30
RMidhunSuresh
52f0690c70 Add return type 2022-05-27 22:42:21 +05:30
RMidhunSuresh
7a24059337 Remove empty line 2022-05-27 22:42:21 +05:30
RMidhunSuresh
4fd1918202 Remove comment 2022-05-27 22:42:21 +05:30
RMidhunSuresh
4ae3a5bf7a Use undefined instead of null 2022-05-27 22:42:21 +05:30
RMidhunSuresh
5be00f051f Use subtype instead of whole SegmentType 2022-05-27 22:42:21 +05:30
RMidhunSuresh
e7f4ce6175 Mark methods as private 2022-05-27 22:42:21 +05:30
RMidhunSuresh
09bc0f1b60 Extract complex type as type alias 2022-05-27 22:42:21 +05:30
RMidhunSuresh
76d04ee277 Make defaultSessionId optional 2022-05-27 22:42:21 +05:30
RMidhunSuresh
f28dfc6964 Type createRouter function 2022-05-27 22:42:21 +05:30
RMidhunSuresh
c14e4f3eed Use segment type 2022-05-27 22:42:21 +05:30
RMidhunSuresh
5d42f372f6 Pass as separate arguments to constructor 2022-05-27 22:42:21 +05:30
RMidhunSuresh
4c3e0a6ff0 Convert URLRouter.js to typescript 2022-05-27 22:42:21 +05:30
RMidhunSuresh
d9bfca10e1 Type function 2022-05-27 22:42:21 +05:30
RMidhunSuresh
bf2fb52691 Fix formatting 2022-05-27 22:42:21 +05:30
RMidhunSuresh
646cbe0fff Make all keys string 2022-05-27 22:42:21 +05:30
RMidhunSuresh
92e8fc8ad3 Remove deprecated method 2022-05-27 22:42:21 +05:30
RMidhunSuresh
92c79c853d Convert index.js to typescript 2022-05-27 22:42:21 +05:30
RMidhunSuresh
55229252d7 Type allowsChild 2022-05-27 22:42:21 +05:30
RMidhunSuresh
3efc426fed Complete converting Navigation.js to ts 2022-05-27 22:42:21 +05:30
RMidhunSuresh
04d5b9bfda WIP - 2 2022-05-27 22:42:21 +05:30
RMidhunSuresh
66f6c4aba1 WIP 2022-05-27 22:42:18 +05:30
50 changed files with 1742 additions and 497 deletions

1
.gitignore vendored
View file

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

View file

@ -19,6 +19,7 @@ module.exports = {
], ],
rules: { rules: {
"@typescript-eslint/no-floating-promises": 2, "@typescript-eslint/no-floating-promises": 2,
"@typescript-eslint/no-misused-promises": 2 "@typescript-eslint/no-misused-promises": 2,
"semi": ["error", "always"]
} }
}; };

18
.woodpecker.yml Normal file
View file

@ -0,0 +1,18 @@
pipeline:
buildfrontend:
image: node:16
commands:
- yarn install --prefer-offline --frozen-lockfile
- yarn test
- yarn run lint-ci
- yarn run tsc
- yarn build
deploy:
image: python
when:
event: push
branch: master
commands:
- make ci-deploy
secrets: [ GITEA_WRITE_DEPLOY_KEY, LIBREPAGES_DEPLOY_SECRET ]

14
Makefile Normal file
View file

@ -0,0 +1,14 @@
ci-deploy: ## Deploy from CI/CD. Only call from within CI
@if [ "${CI}" != "woodpecker" ]; \
then echo "Only call from within CI. Will re-write your local Git configuration. To override, set export CI=woodpecker"; \
exit 1; \
fi
git config --global user.email "${CI_COMMIT_AUTHOR_EMAIL}"
git config --global user.name "${CI_COMMIT_AUTHOR}"
./scripts/ci.sh --commit-files librepages target "${CI_COMMIT_AUTHOR} <${CI_COMMIT_AUTHOR_EMAIL}>"
./scripts/ci.sh --init "$$GITEA_WRITE_DEPLOY_KEY"
./scripts/ci.sh --deploy ${LIBREPAGES_DEPLOY_SECRET} librepages
./scripts/ci.sh --clean
help: ## Prints help for targets with comments
@cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

View file

@ -1,3 +1,5 @@
[![status-badge](https://ci.batsense.net/api/badges/mystiq/hydrogen-web/status.svg)](https://ci.batsense.net/mystiq/hydrogen-web)
# Hydrogen # Hydrogen
A minimal [Matrix](https://matrix.org/) chat client, focused on performance, offline functionality, and broad browser support. This is work in progress and not yet ready for primetime. Bug reports are welcome, but please don't file any feature requests or other missing things to be on par with Element Web. A minimal [Matrix](https://matrix.org/) chat client, focused on performance, offline functionality, and broad browser support. This is work in progress and not yet ready for primetime. Bug reports are welcome, but please don't file any feature requests or other missing things to be on par with Element Web.

11
doc/IMPORT-ISSUES.md Normal file
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

@ -167,3 +167,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. 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!** **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.

View file

@ -1,6 +1,6 @@
{ {
"name": "hydrogen-web", "name": "hydrogen-web",
"version": "0.2.33", "version": "0.3.1",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"directories": { "directories": {
"doc": "doc" "doc": "doc"
@ -50,8 +50,9 @@
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-value-parser": "^4.2.0", "postcss-value-parser": "^4.2.0",
"regenerator-runtime": "^0.13.7", "regenerator-runtime": "^0.13.7",
"svgo": "^2.8.0",
"text-encoding": "^0.7.0", "text-encoding": "^0.7.0",
"typescript": "^4.3.5", "typescript": "^4.7.0",
"vite": "^2.9.8", "vite": "^2.9.8",
"xxhashjs": "^0.2.2" "xxhashjs": "^0.2.2"
}, },

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
const path = require('path').posix; const path = require('path').posix;
const {optimize} = require('svgo');
async function readCSSSource(location) { async function readCSSSource(location) {
const fs = require("fs").promises; const fs = require("fs").promises;
const path = require("path");
const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`); const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`);
const data = await fs.readFile(resolvedLocation); const data = await fs.readFile(resolvedLocation);
return data; return data;
@ -43,6 +43,45 @@ function addThemesToConfig(bundle, manifestLocations, defaultThemes) {
} }
} }
/**
* 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. * 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. * To understand what chunk means in this context, see https://rollupjs.org/guide/en/#generatebundle.
@ -278,7 +317,7 @@ module.exports = function buildThemes(options) {
]; ];
}, },
generateBundle(_, bundle) { async generateBundle(_, bundle) {
const assetMap = getMappingFromFileNameToAssetInfo(bundle); const assetMap = getMappingFromFileNameToAssetInfo(bundle);
const chunkMap = getMappingFromLocationToChunkArray(bundle); const chunkMap = getMappingFromLocationToChunkArray(bundle);
const runtimeThemeChunkMap = getMappingFromLocationToRuntimeChunk(bundle); const runtimeThemeChunkMap = getMappingFromLocationToRuntimeChunk(bundle);
@ -299,13 +338,29 @@ module.exports = function buildThemes(options) {
const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot); const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot);
builtAssets[`${name}-${variant}`] = locationRelativeToManifest; 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 runtimeThemeChunk = runtimeThemeChunkMap.get(location);
const runtimeAssetLocation = path.relative(manifestLocation, assetMap.get(runtimeThemeChunk.fileName).fileName); const runtimeAssetLocation = path.relative(manifestLocation, assetMap.get(runtimeThemeChunk.fileName).fileName);
manifest.source = { manifest.source = {
"built-assets": builtAssets, "built-assets": builtAssets,
"runtime-asset": runtimeAssetLocation, "runtime-asset": runtimeAssetLocation,
"derived-variables": derivedVariables, "derived-variables": derivedVariables,
"icon": icon "icon": icon,
}; };
const name = `theme-${themeKey}.json`; const name = `theme-${themeKey}.json`;
manifestLocations.push(`${manifestLocation}/${name}`); manifestLocations.push(`${manifestLocation}/${name}`);

165
scripts/ci.sh Executable file
View file

@ -0,0 +1,165 @@
#!/bin/bash
# ci.sh: Helper script to automate deployment operations on CI/CD
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
set -xEeuo pipefail
#source $(pwd)/scripts/lib.sh
readonly SSH_ID_FILE=/tmp/ci-ssh-id
readonly SSH_REMOTE_NAME=origin-ssh
readonly PROJECT_ROOT=$(pwd)
match_arg() {
if [ $1 == $2 ] || [ $1 == $3 ]
then
return 0
else
return 1
fi
}
help() {
cat << EOF
USAGE: ci.sh [SUBCOMMAND]
Helper script to automate deployment operations on CI/CD
Subcommands
-c --clean cleanup secrets, SSH key and other runtime data
-i --init <SSH_PRIVATE_KEY> initialize environment, write SSH private to file
-d --deploy <PAGES-SECRET> <TARGET BRANCH> push branch to Gitea and call Pages server
-h --help print this help menu
EOF
}
# $1: SSH private key
write_ssh(){
truncate --size 0 $SSH_ID_FILE
echo "$1" > $SSH_ID_FILE
chmod 600 $SSH_ID_FILE
}
set_ssh_remote() {
http_remote_url=$(git remote get-url origin)
remote_hostname=$(echo $http_remote_url | cut -d '/' -f 3)
repository_owner=$(echo $http_remote_url | cut -d '/' -f 4)
repository_name=$(echo $http_remote_url | cut -d '/' -f 5)
ssh_remote="git@$remote_hostname:$repository_owner/$repository_name"
ssh_remote="git@git.batsense.net:mystiq/hydrogen-web.git"
git remote add $SSH_REMOTE_NAME $ssh_remote
}
clean() {
if [ -f $SSH_ID_FILE ]
then
shred $SSH_ID_FILE
rm $SSH_ID_FILE
fi
}
# $1: branch name
# $2: directory containing build assets
# $3: Author in <author-name author@example.com> format
commit_files() {
cd $PROJECT_ROOT
original_branch=$(git branch --show-current)
tmp_dir=$(mktemp -d)
cp -r $2/* $tmp_dir
if [[ -z $(git ls-remote --heads origin ${1}) ]]
then
echo "[*] Creating deployment branch $1"
git checkout --orphan $1
else
echo "[*] Deployment branch $1 exists, pulling changes from remote"
git fetch origin $1
git switch $1
fi
git rm -rf .
/bin/rm -rf *
cp -r $tmp_dir/* .
git add --all
if [ $(git status --porcelain | xargs | sed '/^$/d' | wc -l) -gt 0 ];
then
echo "[*] Repository has changed, committing changes"
git commit \
--author="$3" \
--message="new deploy: $(date --iso-8601=seconds)"
fi
git checkout $original_branch
}
# $1: Pages API secret
# $2: Deployment target branch
deploy() {
if (( "$#" < 2 ))
then
help
else
git -c core.sshCommand="/usr/bin/ssh -oStrictHostKeyChecking=no -i $SSH_ID_FILE"\
push --force $SSH_REMOTE_NAME $2
curl -vv --location --request \
POST "https://deploy.batsense.net/api/v1/update"\
--header 'Content-Type: application/json' \
--data-raw "{ \"secret\": \"$1\", \"branch\": \"$2\" }"
fi
}
if (( "$#" < 1 ))
then
help
exit -1
fi
if match_arg $1 '-i' '--init'
then
if (( "$#" < 2 ))
then
help
exit -1
fi
set_ssh_remote
write_ssh "$2"
elif match_arg $1 '-c' '--clean'
then
clean
elif match_arg $1 '-cf' '--commit-files'
then
if (( "$#" < 4 ))
then
help
exit -1
fi
commit_files $2 $3 $4
elif match_arg $1 '-d' '--deploy'
then
if (( "$#" < 3 ))
then
help
exit -1
fi
deploy $2 $3
elif match_arg $1 '-h' '--help'
then
help
else
help
fi

View file

@ -81,7 +81,8 @@ module.exports = (opts = {}) => {
const urlVariables = new Map(); const urlVariables = new Map();
const counter = createCounter(); const counter = createCounter();
root.walkDecls(decl => findAndReplaceUrl(decl, urlVariables, counter)); 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); addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables);
} }
if (opts.compiledVariables){ if (opts.compiledVariables){

View file

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

View file

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

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

@ -14,18 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Options, ViewModel} from "./ViewModel"; import {Options as BaseOptions, ViewModel} from "./ViewModel";
import {Client} from "../matrix/Client.js"; 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 _sessionId: string;
private _busy: boolean; private _busy: boolean;
private _showConfirm: boolean; private _showConfirm: boolean;
private _error?: Error; private _error?: Error;
constructor(options: LogoutOptions) { constructor(options: Options) {
super(options); super(options);
this._sessionId = options.sessionId; this._sessionId = options.sessionId;
this._busy = false; this._busy = false;
@ -41,7 +42,7 @@ export class LogoutViewModel extends ViewModel<LogoutOptions> {
return this._busy; return this._busy;
} }
get cancelUrl(): string { get cancelUrl(): string | undefined {
return this.urlCreator.urlForSegment("session", true); return this.urlCreator.urlForSegment("session", true);
} }

View file

@ -17,7 +17,7 @@ limitations under the License.
import {Client} from "../matrix/Client.js"; import {Client} from "../matrix/Client.js";
import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionViewModel} from "./session/SessionViewModel.js";
import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js";
import {LoginViewModel} from "./login/LoginViewModel.js"; import {LoginViewModel} from "./login/LoginViewModel";
import {LogoutViewModel} from "./LogoutViewModel"; import {LogoutViewModel} from "./LogoutViewModel";
import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
import {ViewModel} from "./ViewModel"; import {ViewModel} from "./ViewModel";
@ -118,7 +118,7 @@ export class RootViewModel extends ViewModel {
// but we also want the change of screen to go through the navigation // 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 // so we store the session container in a temporary variable that will be
// consumed by _applyNavigation, triggered by the navigation change // consumed by _applyNavigation, triggered by the navigation change
// //
// Also, we should not call _setSection before the navigation is in the correct state, // Also, we should not call _setSection before the navigation is in the correct state,
// as url creation (e.g. in RoomTileViewModel) // as url creation (e.g. in RoomTileViewModel)
// won't be using the correct navigation base path. // won't be using the correct navigation base path.

View file

@ -27,17 +27,19 @@ import type {Platform} from "../platform/web/Platform";
import type {Clock} from "../platform/web/dom/Clock"; import type {Clock} from "../platform/web/dom/Clock";
import type {ILogger} from "../logging/types"; import type {ILogger} from "../logging/types";
import type {Navigation} from "./navigation/Navigation"; import type {Navigation} from "./navigation/Navigation";
import type {URLRouter} from "./navigation/URLRouter"; import type {SegmentType} from "./navigation/index";
import type {IURLRouter} from "./navigation/URLRouter";
export type Options = { export type Options<T extends object = SegmentType> = {
platform: Platform platform: Platform;
logger: ILogger logger: ILogger;
urlCreator: URLRouter urlCreator: IURLRouter<T>;
navigation: Navigation navigation: Navigation<T>;
emitChange?: (params: any) => void 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 disposables?: Disposables;
private _isDisposed = false; private _isDisposed = false;
private _options: Readonly<O>; private _options: Readonly<O>;
@ -47,7 +49,7 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
this._options = options; this._options = options;
} }
childOptions<T extends Object>(explicitOptions: T): T & Options { childOptions<T extends Object>(explicitOptions: T): T & Options<N> {
return Object.assign({}, this._options, explicitOptions); return Object.assign({}, this._options, explicitOptions);
} }
@ -58,11 +60,11 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this._options[name]; 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 segmentObservable = this.navigation.observe(type);
const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => { const unsubscribe = segmentObservable.subscribe((value: N[T]) => {
onChange(value, type); onChange(value, type);
}) });
this.track(unsubscribe); this.track(unsubscribe);
} }
@ -100,10 +102,10 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
// TODO: this will need to support binding // 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 // 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? // 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. // 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 // just concat for now
let result = ""; let result = "";
for (let i = 0; i < parts.length; ++i) { for (let i = 0; i < parts.length; ++i) {
@ -135,11 +137,12 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this.platform.logger; return this.platform.logger;
} }
get urlCreator(): URLRouter { get urlCreator(): IURLRouter<N> {
return this._options.urlCreator; return this._options.urlCreator;
} }
get navigation(): Navigation { get navigation(): Navigation<N> {
return this._options.navigation; // typescript needs a little help here
return this._options.navigation as unknown as Navigation<N>;
} }
} }

View file

@ -15,101 +15,145 @@ limitations under the License.
*/ */
import {Client} from "../../matrix/Client.js"; import {Client} from "../../matrix/Client.js";
import {ViewModel} from "../ViewModel"; import {Options as BaseOptions, ViewModel} from "../ViewModel";
import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js";
import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js";
import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js";
import {LoadStatus} from "../../matrix/Client.js"; import {LoadStatus} from "../../matrix/Client.js";
import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
import {SegmentType} from "../navigation/index";
export class LoginViewModel extends ViewModel { import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login";
constructor(options) {
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); super(options);
const {ready, defaultHomeserver, loginToken} = options; const {ready, defaultHomeserver, loginToken} = options;
this._ready = ready; this._ready = ready;
this._loginToken = loginToken; this._loginToken = loginToken;
this._client = new Client(this.platform); 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._homeserver = defaultHomeserver;
this._queriedHomeserver = null;
this._errorMessage = "";
this._hideHomeserver = false;
this._isBusy = false;
this._abortHomeserverQueryTimeout = null;
this._abortQueryOperation = null;
this._initViewModels(); this._initViewModels();
} }
get passwordLoginViewModel() { return this._passwordLoginViewModel; } get passwordLoginViewModel(): PasswordLoginViewModel {
get startSSOLoginViewModel() { return this._startSSOLoginViewModel; } return this._passwordLoginViewModel;
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; }
goBack() { get startSSOLoginViewModel(): StartSSOLoginViewModel {
return this._startSSOLoginViewModel;
}
get completeSSOLoginViewModel(): CompleteSSOLoginViewModel {
return this._completeSSOLoginViewModel;
}
get homeserver(): string {
return this._homeserver;
}
get resolvedHomeserver(): string | undefined {
return this._loginOptions?.homeserver;
}
get errorMessage(): string {
return this._errorMessage;
}
get showHomeserver(): boolean {
return !this._hideHomeserver;
}
get loadViewModel(): SessionLoadViewModel {
return this._loadViewModel;
}
get isBusy(): boolean {
return this._isBusy;
}
get isFetchingLoginOptions(): boolean {
return !!this._abortQueryOperation;
}
goBack(): void {
this.navigation.push("session"); this.navigation.push("session");
} }
async _initViewModels() { private _initViewModels(): void {
if (this._loginToken) { if (this._loginToken) {
this._hideHomeserver = true; this._hideHomeserver = true;
this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel( this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel(
this.childOptions( this.childOptions(
{ {
client: this._client, client: this._client,
attemptLogin: loginMethod => this.attemptLogin(loginMethod), attemptLogin: (loginMethod: TokenLoginMethod) => this.attemptLogin(loginMethod),
loginToken: this._loginToken loginToken: this._loginToken
}))); })));
this.emitChange("completeSSOLoginViewModel"); this.emitChange("completeSSOLoginViewModel");
} }
else { else {
await this.queryHomeserver(); void this.queryHomeserver();
} }
} }
_showPasswordLogin() { private _showPasswordLogin(): void {
this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( this._passwordLoginViewModel = this.track(new PasswordLoginViewModel(
this.childOptions({ this.childOptions({
loginOptions: this._loginOptions, loginOptions: this._loginOptions,
attemptLogin: loginMethod => this.attemptLogin(loginMethod) attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod)
}))); })));
this.emitChange("passwordLoginViewModel"); this.emitChange("passwordLoginViewModel");
} }
_showSSOLogin() { private _showSSOLogin(): void {
this._startSSOLoginViewModel = this.track( this._startSSOLoginViewModel = this.track(
new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions}))
); );
this.emitChange("startSSOLoginViewModel"); this.emitChange("startSSOLoginViewModel");
} }
_showError(message) { private _showError(message: string): void {
this._errorMessage = message; this._errorMessage = message;
this.emitChange("errorMessage"); this.emitChange("errorMessage");
} }
_setBusy(status) { private _setBusy(status: boolean): void {
this._isBusy = status; this._isBusy = status;
this._passwordLoginViewModel?.setBusy(status); this._passwordLoginViewModel?.setBusy(status);
this._startSSOLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status);
this.emitChange("isBusy"); this.emitChange("isBusy");
} }
async attemptLogin(loginMethod) { async attemptLogin(loginMethod: ILoginMethod): Promise<null> {
this._setBusy(true); this._setBusy(true);
this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); void this._client.startWithLogin(loginMethod, {inspectAccountSetup: true});
const loadStatus = this._client.loadStatus; 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; await handle.promise;
this._setBusy(false); this._setBusy(false);
const status = loadStatus.get(); const status = loadStatus.get();
@ -119,11 +163,11 @@ export class LoginViewModel extends ViewModel {
this._hideHomeserver = true; this._hideHomeserver = true;
this.emitChange("hideHomeserver"); this.emitChange("hideHomeserver");
this._disposeViewModels(); this._disposeViewModels();
this._createLoadViewModel(); void this._createLoadViewModel();
return null; return null;
} }
_createLoadViewModel() { private _createLoadViewModel(): void {
this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription);
this._loadViewModel = this.disposeTracked(this._loadViewModel); this._loadViewModel = this.disposeTracked(this._loadViewModel);
this._loadViewModel = this.track( this._loadViewModel = this.track(
@ -139,7 +183,7 @@ export class LoginViewModel extends ViewModel {
}) })
) )
); );
this._loadViewModel.start(); void this._loadViewModel.start();
this.emitChange("loadViewModel"); this.emitChange("loadViewModel");
this._loadViewModelSubscription = this.track( this._loadViewModelSubscription = this.track(
this._loadViewModel.disposableOn("change", () => { this._loadViewModel.disposableOn("change", () => {
@ -151,22 +195,22 @@ export class LoginViewModel extends ViewModel {
); );
} }
_disposeViewModels() { private _disposeViewModels(): void {
this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel); this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel);
this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel);
this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel);
this.emitChange("disposeViewModels"); this.emitChange("disposeViewModels");
} }
async setHomeserver(newHomeserver) { async setHomeserver(newHomeserver: string): Promise<void> {
this._homeserver = newHomeserver; this._homeserver = newHomeserver;
// clear everything set by queryHomeserver // clear everything set by queryHomeserver
this._loginOptions = null; this._loginOptions = undefined;
this._queriedHomeserver = null; this._queriedHomeserver = undefined;
this._showError(""); this._showError("");
this._disposeViewModels(); this._disposeViewModels();
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); 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 // also clear the timeout if it is still running
this.disposeTracked(this._abortHomeserverQueryTimeout); this.disposeTracked(this._abortHomeserverQueryTimeout);
const timeout = this.clock.createTimeout(1000); const timeout = this.clock.createTimeout(1000);
@ -181,10 +225,10 @@ export class LoginViewModel extends ViewModel {
} }
} }
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); 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 // don't repeat a query we've just done
if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { if (this._homeserver === this._queriedHomeserver || this._homeserver === "") {
return; return;
@ -210,7 +254,7 @@ export class LoginViewModel extends ViewModel {
if (e.name === "AbortError") { if (e.name === "AbortError") {
return; //aborted, bail out return; //aborted, bail out
} else { } else {
this._loginOptions = null; this._loginOptions = undefined;
} }
} finally { } finally {
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
@ -221,19 +265,29 @@ export class LoginViewModel extends ViewModel {
if (this._loginOptions.password) { this._showPasswordLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); }
if (!this._loginOptions.sso && !this._loginOptions.password) { if (!this._loginOptions.sso && !this._loginOptions.password) {
this._showError("This homeserver supports neither SSO nor password based login flows"); this._showError("This homeserver supports neither SSO nor password based login flows");
} }
} }
else { else {
this._showError(`Could not query login methods supported by ${this.homeserver}`); this._showError(`Could not query login methods supported by ${this.homeserver}`);
} }
} }
dispose() { dispose(): void {
super.dispose(); super.dispose();
if (this._client) { if (this._client) {
// if we move away before we're done with initial sync // if we move away before we're done with initial sync
// delete the session // delete the session
this._client.deleteSession(); void this._client.deleteSession();
} }
} }
} }
type ReadyFn = (client: Client) => void;
// TODO: move to Client.js when its converted to typescript.
type LoginOptions = {
homeserver: string;
password?: (username: string, password: string) => PasswordLoginMethod;
sso?: SSOLoginHelper;
token?: (loginToken: string) => TokenLoginMethod;
};

View file

@ -16,27 +16,49 @@ limitations under the License.
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue"; import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
export class Navigation {
constructor(allowsChild) { type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean;
/**
* OptionalValue is basically stating that if SegmentType[type] = true:
* - Allow this type to be optional
* - Give it a default value of undefined
* - Also allow it to be true
* This lets us do:
* const s: Segment<SegmentType> = new Segment("create-room");
* instead of
* const s: Segment<SegmentType> = new Segment("create-room", undefined);
*/
export type OptionalValue<T> = T extends true? [(undefined | true)?]: [T];
export class Navigation<T extends object> {
private readonly _allowsChild: AllowsChild<T>;
private _path: Path<T>;
private readonly _observables: Map<keyof T, SegmentObservable<T>> = new Map();
private readonly _pathObservable: ObservableValue<Path<T>>;
constructor(allowsChild: AllowsChild<T>) {
this._allowsChild = allowsChild; this._allowsChild = allowsChild;
this._path = new Path([], allowsChild); this._path = new Path([], allowsChild);
this._observables = new Map();
this._pathObservable = new ObservableValue(this._path); this._pathObservable = new ObservableValue(this._path);
} }
get pathObservable() { get pathObservable(): ObservableValue<Path<T>> {
return this._pathObservable; return this._pathObservable;
} }
get path() { get path(): Path<T> {
return this._path; return this._path;
} }
push(type, value = undefined) { push<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): void {
return this.applyPath(this.path.with(new Segment(type, value))); const newPath = this.path.with(new Segment(type, ...value));
if (newPath) {
this.applyPath(newPath);
}
} }
applyPath(path) { applyPath(path: Path<T>): void {
// Path is not exported, so you can only create a Path through Navigation, // Path is not exported, so you can only create a Path through Navigation,
// so we assume it respects the allowsChild rules // so we assume it respects the allowsChild rules
const oldPath = this._path; const oldPath = this._path;
@ -60,7 +82,7 @@ export class Navigation {
this._pathObservable.set(this._path); this._pathObservable.set(this._path);
} }
observe(type) { observe(type: keyof T): SegmentObservable<T> {
let observable = this._observables.get(type); let observable = this._observables.get(type);
if (!observable) { if (!observable) {
observable = new SegmentObservable(this, type); observable = new SegmentObservable(this, type);
@ -69,9 +91,9 @@ export class Navigation {
return observable; return observable;
} }
pathFrom(segments) { pathFrom(segments: Segment<any>[]): Path<T> {
let parent; let parent: Segment<any> | undefined;
let i; let i: number;
for (i = 0; i < segments.length; i += 1) { for (i = 0; i < segments.length; i += 1) {
if (!this._allowsChild(parent, segments[i])) { if (!this._allowsChild(parent, segments[i])) {
return new Path(segments.slice(0, i), this._allowsChild); return new Path(segments.slice(0, i), this._allowsChild);
@ -81,12 +103,12 @@ export class Navigation {
return new Path(segments, this._allowsChild); return new Path(segments, this._allowsChild);
} }
segment(type, value) { segment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): Segment<T> {
return new Segment(type, value); return new Segment(type, ...value);
} }
} }
function segmentValueEqual(a, b) { function segmentValueEqual<T>(a?: T[keyof T], b?: T[keyof T]): boolean {
if (a === b) { if (a === b) {
return true; return true;
} }
@ -103,24 +125,29 @@ function segmentValueEqual(a, b) {
return false; return false;
} }
export class Segment {
constructor(type, value) { export class Segment<T, K extends keyof T = any> {
this.type = type; public value: T[K];
this.value = value === undefined ? true : value;
constructor(public type: K, ...value: OptionalValue<T[K]>) {
this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K];
} }
} }
class Path { class Path<T> {
constructor(segments = [], allowsChild) { private readonly _segments: Segment<T, any>[];
private readonly _allowsChild: AllowsChild<T>;
constructor(segments: Segment<T>[] = [], allowsChild: AllowsChild<T>) {
this._segments = segments; this._segments = segments;
this._allowsChild = allowsChild; this._allowsChild = allowsChild;
} }
clone() { clone(): Path<T> {
return new Path(this._segments.slice(), this._allowsChild); return new Path(this._segments.slice(), this._allowsChild);
} }
with(segment) { with(segment: Segment<T>): Path<T> | undefined {
let index = this._segments.length - 1; let index = this._segments.length - 1;
do { do {
if (this._allowsChild(this._segments[index], segment)) { if (this._allowsChild(this._segments[index], segment)) {
@ -132,10 +159,10 @@ class Path {
index -= 1; index -= 1;
} while(index >= -1); } while(index >= -1);
// allow -1 as well so we check if the segment is allowed as root // allow -1 as well so we check if the segment is allowed as root
return null; return undefined;
} }
until(type) { until(type: keyof T): Path<T> {
const index = this._segments.findIndex(s => s.type === type); const index = this._segments.findIndex(s => s.type === type);
if (index !== -1) { if (index !== -1) {
return new Path(this._segments.slice(0, index + 1), this._allowsChild) return new Path(this._segments.slice(0, index + 1), this._allowsChild)
@ -143,11 +170,11 @@ class Path {
return new Path([], this._allowsChild); return new Path([], this._allowsChild);
} }
get(type) { get(type: keyof T): Segment<T> | undefined {
return this._segments.find(s => s.type === type); return this._segments.find(s => s.type === type);
} }
replace(segment) { replace(segment: Segment<T>): Path<T> | undefined {
const index = this._segments.findIndex(s => s.type === segment.type); const index = this._segments.findIndex(s => s.type === segment.type);
if (index !== -1) { if (index !== -1) {
const parent = this._segments[index - 1]; const parent = this._segments[index - 1];
@ -160,10 +187,10 @@ class Path {
} }
} }
} }
return null; return undefined;
} }
get segments() { get segments(): Segment<T>[] {
return this._segments; return this._segments;
} }
} }
@ -172,43 +199,49 @@ class Path {
* custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet. * custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet.
* This ensures that observers of a segment can also read the most recent value of other segments. * This ensures that observers of a segment can also read the most recent value of other segments.
*/ */
class SegmentObservable extends BaseObservableValue { class SegmentObservable<T extends object> extends BaseObservableValue<T[keyof T] | undefined> {
constructor(navigation, type) { private readonly _navigation: Navigation<T>;
private _type: keyof T;
private _lastSetValue?: T[keyof T];
constructor(navigation: Navigation<T>, type: keyof T) {
super(); super();
this._navigation = navigation; this._navigation = navigation;
this._type = type; this._type = type;
this._lastSetValue = navigation.path.get(type)?.value; this._lastSetValue = navigation.path.get(type)?.value;
} }
get() { get(): T[keyof T] | undefined {
const path = this._navigation.path; const path = this._navigation.path;
const segment = path.get(this._type); const segment = path.get(this._type);
const value = segment?.value; const value = segment?.value;
return value; return value;
} }
emitIfChanged() { emitIfChanged(): void {
const newValue = this.get(); const newValue = this.get();
if (!segmentValueEqual(newValue, this._lastSetValue)) { if (!segmentValueEqual<T>(newValue, this._lastSetValue)) {
this._lastSetValue = newValue; this._lastSetValue = newValue;
this.emit(newValue); this.emit(newValue);
} }
} }
} }
export type {Path};
export function tests() { export function tests() {
function createMockNavigation() { function createMockNavigation() {
return new Navigation((parent, {type}) => { return new Navigation((parent, {type}) => {
switch (parent?.type) { switch (parent?.type) {
case undefined: case undefined:
return type === "1" || "2"; return type === "1" || type === "2";
case "1": case "1":
return type === "1.1"; return type === "1.1";
case "1.1": case "1.1":
return type === "1.1.1"; return type === "1.1.1";
case "2": case "2":
return type === "2.1" || "2.2"; return type === "2.1" || type === "2.2";
default: default:
return false; return false;
} }
@ -216,7 +249,7 @@ export function tests() {
} }
function observeTypes(nav, types) { function observeTypes(nav, types) {
const changes = []; const changes: {type:string, value:any}[] = [];
for (const type of types) { for (const type of types) {
nav.observe(type).subscribe(value => { nav.observe(type).subscribe(value => {
changes.push({type, value}); changes.push({type, value});
@ -225,6 +258,12 @@ export function tests() {
return changes; return changes;
} }
type SegmentType = {
"foo": number;
"bar": number;
"baz": number;
}
return { return {
"applying a path emits an event on the observable": assert => { "applying a path emits an event on the observable": assert => {
const nav = createMockNavigation(); const nav = createMockNavigation();
@ -242,18 +281,18 @@ export function tests() {
assert.equal(changes[1].value, 8); assert.equal(changes[1].value, 8);
}, },
"path.get": assert => { "path.get": assert => {
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
assert.equal(path.get("foo").value, 5); assert.equal(path.get("foo")!.value, 5);
assert.equal(path.get("bar").value, 6); assert.equal(path.get("bar")!.value, 6);
}, },
"path.replace success": assert => { "path.replace success": assert => {
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
const newPath = path.replace(new Segment("foo", 1)); const newPath = path.replace(new Segment("foo", 1));
assert.equal(newPath.get("foo").value, 1); assert.equal(newPath!.get("foo")!.value, 1);
assert.equal(newPath.get("bar").value, 6); assert.equal(newPath!.get("bar")!.value, 6);
}, },
"path.replace not found": assert => { "path.replace not found": assert => {
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
const newPath = path.replace(new Segment("baz", 1)); const newPath = path.replace(new Segment("baz", 1));
assert.equal(newPath, null); assert.equal(newPath, null);
} }

View file

@ -14,28 +14,55 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export class URLRouter { import type {History} from "../../platform/web/dom/History.js";
constructor({history, navigation, parseUrlPath, stringifyPath}) { import type {Navigation, Segment, Path, OptionalValue} from "./Navigation";
import type {SubscriptionHandle} from "../../observable/BaseObservable";
type ParseURLPath<T> = (urlPath: string, currentNavPath: Path<T>, defaultSessionId?: string) => Segment<T>[];
type StringifyPath<T> = (path: Path<T>) => string;
export interface IURLRouter<T> {
attach(): void;
dispose(): void;
pushUrl(url: string): void;
tryRestoreLastUrl(): boolean;
urlForSegments(segments: Segment<T>[]): string | undefined;
urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined;
urlUntilSegment(type: keyof T): string;
urlForPath(path: Path<T>): string;
openRoomActionUrl(roomId: string): string;
createSSOCallbackURL(): string;
normalizeUrl(): void;
}
export class URLRouter<T extends {session: string | boolean}> implements IURLRouter<T> {
private readonly _history: History;
private readonly _navigation: Navigation<T>;
private readonly _parseUrlPath: ParseURLPath<T>;
private readonly _stringifyPath: StringifyPath<T>;
private _subscription?: SubscriptionHandle;
private _pathSubscription?: SubscriptionHandle;
private _isApplyingUrl: boolean = false;
private _defaultSessionId?: string;
constructor(history: History, navigation: Navigation<T>, parseUrlPath: ParseURLPath<T>, stringifyPath: StringifyPath<T>) {
this._history = history; this._history = history;
this._navigation = navigation; this._navigation = navigation;
this._parseUrlPath = parseUrlPath; this._parseUrlPath = parseUrlPath;
this._stringifyPath = stringifyPath; this._stringifyPath = stringifyPath;
this._subscription = null;
this._pathSubscription = null;
this._isApplyingUrl = false;
this._defaultSessionId = this._getLastSessionId(); this._defaultSessionId = this._getLastSessionId();
} }
_getLastSessionId() { private _getLastSessionId(): string | undefined {
const navPath = this._urlAsNavPath(this._history.getLastUrl() || ""); const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
const sessionId = navPath.get("session")?.value; const sessionId = navPath.get("session")?.value;
if (typeof sessionId === "string") { if (typeof sessionId === "string") {
return sessionId; return sessionId;
} }
return null; return undefined;
} }
attach() { attach(): void {
this._subscription = this._history.subscribe(url => this._applyUrl(url)); this._subscription = this._history.subscribe(url => this._applyUrl(url));
// subscribe to path before applying initial url // subscribe to path before applying initial url
// so redirects in _applyNavPathToHistory are reflected in url bar // so redirects in _applyNavPathToHistory are reflected in url bar
@ -43,12 +70,12 @@ export class URLRouter {
this._applyUrl(this._history.get()); this._applyUrl(this._history.get());
} }
dispose() { dispose(): void {
this._subscription = this._subscription(); if (this._subscription) { this._subscription = this._subscription(); }
this._pathSubscription = this._pathSubscription(); if (this._pathSubscription) { this._pathSubscription = this._pathSubscription(); }
} }
_applyNavPathToHistory(path) { private _applyNavPathToHistory(path: Path<T>): void {
const url = this.urlForPath(path); const url = this.urlForPath(path);
if (url !== this._history.get()) { if (url !== this._history.get()) {
if (this._isApplyingUrl) { if (this._isApplyingUrl) {
@ -60,7 +87,7 @@ export class URLRouter {
} }
} }
_applyNavPathToNavigation(navPath) { private _applyNavPathToNavigation(navPath: Path<T>): void {
// this will cause _applyNavPathToHistory to be called, // this will cause _applyNavPathToHistory to be called,
// so set a flag whether this request came from ourselves // so set a flag whether this request came from ourselves
// (in which case it is a redirect if the url does not match the current one) // (in which case it is a redirect if the url does not match the current one)
@ -69,22 +96,22 @@ export class URLRouter {
this._isApplyingUrl = false; this._isApplyingUrl = false;
} }
_urlAsNavPath(url) { private _urlAsNavPath(url: string): Path<T> {
const urlPath = this._history.urlAsPath(url); const urlPath = this._history.urlAsPath(url);
return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId)); return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId));
} }
_applyUrl(url) { private _applyUrl(url: string): void {
const navPath = this._urlAsNavPath(url); const navPath = this._urlAsNavPath(url);
this._applyNavPathToNavigation(navPath); this._applyNavPathToNavigation(navPath);
} }
pushUrl(url) { pushUrl(url: string): void {
this._history.pushUrl(url); this._history.pushUrl(url);
} }
tryRestoreLastUrl() { tryRestoreLastUrl(): boolean {
const lastNavPath = this._urlAsNavPath(this._history.getLastUrl() || ""); const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
if (lastNavPath.segments.length !== 0) { if (lastNavPath.segments.length !== 0) {
this._applyNavPathToNavigation(lastNavPath); this._applyNavPathToNavigation(lastNavPath);
return true; return true;
@ -92,8 +119,8 @@ export class URLRouter {
return false; return false;
} }
urlForSegments(segments) { urlForSegments(segments: Segment<T>[]): string | undefined {
let path = this._navigation.path; let path: Path<T> | undefined = this._navigation.path;
for (const segment of segments) { for (const segment of segments) {
path = path.with(segment); path = path.with(segment);
if (!path) { if (!path) {
@ -103,29 +130,29 @@ export class URLRouter {
return this.urlForPath(path); return this.urlForPath(path);
} }
urlForSegment(type, value) { urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined {
return this.urlForSegments([this._navigation.segment(type, value)]); return this.urlForSegments([this._navigation.segment(type, ...value)]);
} }
urlUntilSegment(type) { urlUntilSegment(type: keyof T): string {
return this.urlForPath(this._navigation.path.until(type)); return this.urlForPath(this._navigation.path.until(type));
} }
urlForPath(path) { urlForPath(path: Path<T>): string {
return this._history.pathAsUrl(this._stringifyPath(path)); return this._history.pathAsUrl(this._stringifyPath(path));
} }
openRoomActionUrl(roomId) { openRoomActionUrl(roomId: string): string {
// not a segment to navigation knowns about, so append it manually // 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/${roomId}`;
return this._history.pathAsUrl(urlPath); return this._history.pathAsUrl(urlPath);
} }
createSSOCallbackURL() { createSSOCallbackURL(): string {
return window.location.origin; return window.location.origin;
} }
normalizeUrl() { normalizeUrl(): void {
// Remove any queryParameters from the URL // Remove any queryParameters from the URL
// Gets rid of the loginToken after SSO // Gets rid of the loginToken after SSO
this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`); this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`);

View file

@ -14,18 +14,36 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Navigation, Segment} from "./Navigation.js"; import {Navigation, Segment} from "./Navigation";
import {URLRouter} from "./URLRouter.js"; import {URLRouter} from "./URLRouter";
import type {Path, OptionalValue} from "./Navigation";
export function createNavigation() { export type SegmentType = {
"login": true;
"session": string | boolean;
"sso": string;
"logout": true;
"room": string;
"rooms": string[];
"settings": true;
"create-room": true;
"empty-grid-tile": number;
"lightbox": string;
"right-panel": true;
"details": true;
"members": true;
"member": string;
};
export function createNavigation(): Navigation<SegmentType> {
return new Navigation(allowsChild); return new Navigation(allowsChild);
} }
export function createRouter({history, navigation}) { export function createRouter({history, navigation}: {history: History, navigation: Navigation<SegmentType>}): URLRouter<SegmentType> {
return new URLRouter({history, navigation, stringifyPath, parseUrlPath}); return new URLRouter(history, navigation, parseUrlPath, stringifyPath);
} }
function allowsChild(parent, child) { function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<SegmentType>): boolean {
const {type} = child; const {type} = child;
switch (parent?.type) { switch (parent?.type) {
case undefined: case undefined:
@ -45,8 +63,9 @@ function allowsChild(parent, child) {
} }
} }
export function removeRoomFromPath(path, roomId) { export function removeRoomFromPath(path: Path<SegmentType>, roomId: string): Path<SegmentType> | undefined {
const rooms = path.get("rooms"); let newPath: Path<SegmentType> | undefined = path;
const rooms = newPath.get("rooms");
let roomIdGridIndex = -1; let roomIdGridIndex = -1;
// first delete from rooms segment // first delete from rooms segment
if (rooms) { if (rooms) {
@ -54,22 +73,22 @@ export function removeRoomFromPath(path, roomId) {
if (roomIdGridIndex !== -1) { if (roomIdGridIndex !== -1) {
const idsWithoutRoom = rooms.value.slice(); const idsWithoutRoom = rooms.value.slice();
idsWithoutRoom[roomIdGridIndex] = ""; idsWithoutRoom[roomIdGridIndex] = "";
path = path.replace(new Segment("rooms", idsWithoutRoom)); newPath = newPath.replace(new Segment("rooms", idsWithoutRoom));
} }
} }
const room = path.get("room"); const room = newPath!.get("room");
// then from room (which occurs with or without rooms) // then from room (which occurs with or without rooms)
if (room && room.value === roomId) { if (room && room.value === roomId) {
if (roomIdGridIndex !== -1) { if (roomIdGridIndex !== -1) {
path = path.with(new Segment("empty-grid-tile", roomIdGridIndex)); newPath = newPath!.with(new Segment("empty-grid-tile", roomIdGridIndex));
} else { } else {
path = path.until("session"); newPath = newPath!.until("session");
} }
} }
return path; return newPath;
} }
function roomsSegmentWithRoom(rooms, roomId, path) { function roomsSegmentWithRoom(rooms: Segment<SegmentType, "rooms">, roomId: string, path: Path<SegmentType>): Segment<SegmentType, "rooms"> {
if(!rooms.value.includes(roomId)) { if(!rooms.value.includes(roomId)) {
const emptyGridTile = path.get("empty-grid-tile"); const emptyGridTile = path.get("empty-grid-tile");
const oldRoom = path.get("room"); const oldRoom = path.get("room");
@ -87,28 +106,28 @@ function roomsSegmentWithRoom(rooms, roomId, path) {
} }
} }
function pushRightPanelSegment(array, segment, value = true) { function pushRightPanelSegment<T extends keyof SegmentType>(array: Segment<SegmentType>[], segment: T, ...value: OptionalValue<SegmentType[T]>): void {
array.push(new Segment("right-panel")); array.push(new Segment("right-panel"));
array.push(new Segment(segment, value)); array.push(new Segment(segment, ...value));
} }
export function addPanelIfNeeded(navigation, path) { export function addPanelIfNeeded<T extends SegmentType>(navigation: Navigation<T>, path: Path<T>): Path<T> {
const segments = navigation.path.segments; const segments = navigation.path.segments;
const i = segments.findIndex(segment => segment.type === "right-panel"); const i = segments.findIndex(segment => segment.type === "right-panel");
let _path = path; let _path = path;
if (i !== -1) { if (i !== -1) {
_path = path.until("room"); _path = path.until("room");
_path = _path.with(segments[i]); _path = _path.with(segments[i])!;
_path = _path.with(segments[i + 1]); _path = _path.with(segments[i + 1])!;
} }
return _path; return _path;
} }
export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>, defaultSessionId?: string): Segment<SegmentType>[] {
// substr(1) to take of initial / // substring(1) to take of initial /
const parts = urlPath.substr(1).split("/"); const parts = urlPath.substring(1).split("/");
const iterator = parts[Symbol.iterator](); const iterator = parts[Symbol.iterator]();
const segments = []; const segments: Segment<SegmentType>[] = [];
let next; let next;
while (!(next = iterator.next()).done) { while (!(next = iterator.next()).done) {
const type = next.value; const type = next.value;
@ -170,9 +189,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
return segments; return segments;
} }
export function stringifyPath(path) { export function stringifyPath(path: Path<SegmentType>): string {
let urlPath = ""; let urlPath = "";
let prevSegment; let prevSegment: Segment<SegmentType> | undefined;
for (const segment of path.segments) { for (const segment of path.segments) {
switch (segment.type) { switch (segment.type) {
case "rooms": case "rooms":
@ -205,9 +224,15 @@ export function stringifyPath(path) {
} }
export function tests() { export function tests() {
function createEmptyPath() {
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([]);
return path;
}
return { return {
"stringify grid url with focused empty tile": assert => { "stringify grid url with focused empty tile": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -217,7 +242,7 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/3"); assert.equal(urlPath, "/session/1/rooms/a,b,c/3");
}, },
"stringify grid url with focused room": assert => { "stringify grid url with focused room": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -227,7 +252,7 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/1"); assert.equal(urlPath, "/session/1/rooms/a,b,c/1");
}, },
"stringify url with right-panel and details segment": assert => { "stringify url with right-panel and details segment": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -239,13 +264,15 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details"); assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details");
}, },
"Parse loginToken query parameter into SSO segment": assert => { "Parse loginToken query parameter into SSO segment": assert => {
const segments = parseUrlPath("?loginToken=a1232aSD123"); const path = createEmptyPath();
const segments = parseUrlPath("?loginToken=a1232aSD123", path);
assert.equal(segments.length, 1); assert.equal(segments.length, 1);
assert.equal(segments[0].type, "sso"); assert.equal(segments[0].type, "sso");
assert.equal(segments[0].value, "a1232aSD123"); assert.equal(segments[0].value, "a1232aSD123");
}, },
"parse grid url path with focused empty tile": assert => { "parse grid url path with focused empty tile": assert => {
const segments = parseUrlPath("/session/1/rooms/a,b,c/3"); const path = createEmptyPath();
const segments = parseUrlPath("/session/1/rooms/a,b,c/3", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -255,7 +282,8 @@ export function tests() {
assert.equal(segments[2].value, 3); assert.equal(segments[2].value, 3);
}, },
"parse grid url path with focused room": assert => { "parse grid url path with focused room": assert => {
const segments = parseUrlPath("/session/1/rooms/a,b,c/1"); const path = createEmptyPath();
const segments = parseUrlPath("/session/1/rooms/a,b,c/1", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -265,7 +293,8 @@ export function tests() {
assert.equal(segments[2].value, "b"); assert.equal(segments[2].value, "b");
}, },
"parse empty grid url": assert => { "parse empty grid url": assert => {
const segments = parseUrlPath("/session/1/rooms/"); const path = createEmptyPath();
const segments = parseUrlPath("/session/1/rooms/", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -275,7 +304,8 @@ export function tests() {
assert.equal(segments[2].value, 0); assert.equal(segments[2].value, 0);
}, },
"parse empty grid url with focus": assert => { "parse empty grid url with focus": assert => {
const segments = parseUrlPath("/session/1/rooms//1"); const path = createEmptyPath();
const segments = parseUrlPath("/session/1/rooms//1", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -285,7 +315,7 @@ export function tests() {
assert.equal(segments[2].value, 1); assert.equal(segments[2].value, 1);
}, },
"parse open-room action replacing the current focused room": assert => { "parse open-room action replacing the current focused room": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -301,7 +331,7 @@ export function tests() {
assert.equal(segments[2].value, "d"); assert.equal(segments[2].value, "d");
}, },
"parse open-room action changing focus to an existing room": assert => { "parse open-room action changing focus to an existing room": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -317,7 +347,7 @@ export function tests() {
assert.equal(segments[2].value, "a"); assert.equal(segments[2].value, "a");
}, },
"parse open-room action changing focus to an existing room with details open": assert => { "parse open-room action changing focus to an existing room with details open": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -339,7 +369,7 @@ export function tests() {
assert.equal(segments[4].value, true); assert.equal(segments[4].value, true);
}, },
"open-room action should only copy over previous segments if there are no parts after open-room": assert => { "open-room action should only copy over previous segments if there are no parts after open-room": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -361,7 +391,7 @@ export function tests() {
assert.equal(segments[4].value, "foo"); assert.equal(segments[4].value, "foo");
}, },
"parse open-room action setting a room in an empty tile": assert => { "parse open-room action setting a room in an empty tile": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -377,82 +407,83 @@ export function tests() {
assert.equal(segments[2].value, "d"); assert.equal(segments[2].value, "d");
}, },
"parse session url path without id": assert => { "parse session url path without id": assert => {
const segments = parseUrlPath("/session"); const path = createEmptyPath();
const segments = parseUrlPath("/session", path);
assert.equal(segments.length, 1); assert.equal(segments.length, 1);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.strictEqual(segments[0].value, true); assert.strictEqual(segments[0].value, true);
}, },
"remove active room from grid path turns it into empty tile": assert => { "remove active room from grid path turns it into empty tile": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "b"); const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 3); assert.equal(newPath?.segments.length, 3);
assert.equal(newPath.segments[0].type, "session"); assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1); assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms"); assert.equal(newPath?.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]); assert.deepEqual(newPath?.segments[1].value, ["a", "", "c"]);
assert.equal(newPath.segments[2].type, "empty-grid-tile"); assert.equal(newPath?.segments[2].type, "empty-grid-tile");
assert.equal(newPath.segments[2].value, 1); assert.equal(newPath?.segments[2].value, 1);
}, },
"remove inactive room from grid path": assert => { "remove inactive room from grid path": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "a"); const newPath = removeRoomFromPath(path, "a");
assert.equal(newPath.segments.length, 3); assert.equal(newPath?.segments.length, 3);
assert.equal(newPath.segments[0].type, "session"); assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1); assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms"); assert.equal(newPath?.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]); assert.deepEqual(newPath?.segments[1].value, ["", "b", "c"]);
assert.equal(newPath.segments[2].type, "room"); assert.equal(newPath?.segments[2].type, "room");
assert.equal(newPath.segments[2].value, "b"); assert.equal(newPath?.segments[2].value, "b");
}, },
"remove inactive room from grid path with empty tile": assert => { "remove inactive room from grid path with empty tile": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", ""]), new Segment("rooms", ["a", "b", ""]),
new Segment("empty-grid-tile", 3) new Segment("empty-grid-tile", 3)
]); ]);
const newPath = removeRoomFromPath(path, "b"); const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 3); assert.equal(newPath?.segments.length, 3);
assert.equal(newPath.segments[0].type, "session"); assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1); assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms"); assert.equal(newPath?.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["a", "", ""]); assert.deepEqual(newPath?.segments[1].value, ["a", "", ""]);
assert.equal(newPath.segments[2].type, "empty-grid-tile"); assert.equal(newPath?.segments[2].type, "empty-grid-tile");
assert.equal(newPath.segments[2].value, 3); assert.equal(newPath?.segments[2].value, 3);
}, },
"remove active room": assert => { "remove active room": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "b"); const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 1); assert.equal(newPath?.segments.length, 1);
assert.equal(newPath.segments[0].type, "session"); assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1); assert.equal(newPath?.segments[0].value, 1);
}, },
"remove inactive room doesn't do anything": assert => { "remove inactive room doesn't do anything": assert => {
const nav = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "a"); const newPath = removeRoomFromPath(path, "a");
assert.equal(newPath.segments.length, 2); assert.equal(newPath?.segments.length, 2);
assert.equal(newPath.segments[0].type, "session"); assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1); assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "room"); assert.equal(newPath?.segments[1].type, "room");
assert.equal(newPath.segments[1].value, "b"); assert.equal(newPath?.segments[1].value, "b");
}, },
} }

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {ViewModel} from "../ViewModel"; import {ViewModel} from "../ViewModel";
import {addPanelIfNeeded} from "../navigation/index.js"; import {addPanelIfNeeded} from "../navigation/index";
function dedupeSparse(roomIds) { function dedupeSparse(roomIds) {
return roomIds.map((id, idx) => { return roomIds.map((id, idx) => {
@ -185,7 +185,7 @@ export class RoomGridViewModel extends ViewModel {
} }
} }
import {createNavigation} from "../navigation/index.js"; import {createNavigation} from "../navigation/index";
import {ObservableValue} from "../../observable/ObservableValue"; import {ObservableValue} from "../../observable/ObservableValue";
export function tests() { export function tests() {

View file

@ -21,7 +21,7 @@ import {InviteTileViewModel} from "./InviteTileViewModel.js";
import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js"; import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js";
import {RoomFilter} from "./RoomFilter.js"; import {RoomFilter} from "./RoomFilter.js";
import {ApplyMap} from "../../../observable/map/ApplyMap.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js";
import {addPanelIfNeeded} from "../../navigation/index.js"; import {addPanelIfNeeded} from "../../navigation/index";
export class LeftPanelViewModel extends ViewModel { export class LeftPanelViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -23,6 +23,7 @@ import {imageToInfo} from "../common.js";
// TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry // TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry
// this is a breaking SDK change though to make this option mandatory // this is a breaking SDK change though to make this option mandatory
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index"; import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
import {RoomStatus} from "../../../matrix/room/common";
export class RoomViewModel extends ViewModel { export class RoomViewModel extends ViewModel {
constructor(options) { constructor(options) {
@ -197,18 +198,89 @@ export class RoomViewModel extends ViewModel {
} }
} }
async _processCommandJoin(roomName) {
try {
const roomId = await this._options.client.session.joinRoom(roomName);
const roomStatusObserver = await this._options.client.session.observeRoomStatus(roomId);
await roomStatusObserver.waitFor(status => status === RoomStatus.Joined);
this.navigation.push("room", roomId);
} catch (err) {
let exc;
if ((err.statusCode ?? err.status) === 400) {
exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`);
} else if ((err.statusCode ?? err.status) === 404 || (err.statusCode ?? err.status) === 502 || err.message == "Internal Server Error") {
exc = new Error(`/join : room '${roomName}' not found`);
} else if ((err.statusCode ?? err.status) === 403) {
exc = new Error(`/join : you're not invited to join '${roomName}'`);
} else {
exc = err;
}
this._sendError = exc;
this._timelineError = null;
this.emitChange("error");
}
}
async _processCommand (message) {
let msgtype;
const [commandName, ...args] = message.substring(1).split(" ");
switch (commandName) {
case "me":
message = args.join(" ");
msgtype = "m.emote";
break;
case "join":
if (args.length === 1) {
const roomName = args[0];
await this._processCommandJoin(roomName);
} else {
this._sendError = new Error("join syntax: /join <room-id>");
this._timelineError = null;
this.emitChange("error");
}
break;
case "shrug":
message = "¯\\_(ツ)_/¯ " + args.join(" ");
msgtype = "m.text";
break;
case "tableflip":
message = "(╯°□°)╯︵ ┻━┻ " + args.join(" ");
msgtype = "m.text";
break;
case "unflip":
message = "┬──┬ ( ゜-゜ノ) " + args.join(" ");
msgtype = "m.text";
break;
case "lenny":
message = "( ͡° ͜ʖ ͡°) " + args.join(" ");
msgtype = "m.text";
break;
default:
this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`);
this._timelineError = null;
this.emitChange("error");
message = undefined;
}
return {type: msgtype, message: message};
}
async _sendMessage(message, replyingTo) { async _sendMessage(message, replyingTo) {
if (!this._room.isArchived && message) { if (!this._room.isArchived && message) {
let messinfo = {type : "m.text", message : message};
if (message.startsWith("//")) {
messinfo.message = message.substring(1).trim();
} else if (message.startsWith("/")) {
messinfo = await this._processCommand(message);
}
try { try {
let msgtype = "m.text"; const msgtype = messinfo.type;
if (message.startsWith("/me ")) { const message = messinfo.message;
message = message.substr(4).trim(); if (msgtype && message) {
msgtype = "m.emote"; if (replyingTo) {
} await replyingTo.reply(msgtype, message);
if (replyingTo) { } else {
await replyingTo.reply(msgtype, message); await this._room.sendEvent("m.room.message", {msgtype, body: message});
} else { }
await this._room.sendEvent("m.room.message", {msgtype, body: message});
} }
} catch (err) { } catch (err) {
console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`);
@ -353,6 +425,11 @@ export class RoomViewModel extends ViewModel {
this._composerVM.setReplyingTo(entry); this._composerVM.setReplyingTo(entry);
} }
} }
dismissError() {
this._sendError = null;
this.emitChange("error");
}
} }
function videoToInfo(video) { function videoToInfo(video) {

View file

@ -18,7 +18,7 @@ export {Platform} from "./platform/web/Platform.js";
export {Client, LoadStatus} from "./matrix/Client.js"; export {Client, LoadStatus} from "./matrix/Client.js";
export {RoomStatus} from "./matrix/room/common"; export {RoomStatus} from "./matrix/room/common";
// export main view & view models // export main view & view models
export {createNavigation, createRouter} from "./domain/navigation/index.js"; export {createNavigation, createRouter} from "./domain/navigation/index";
export {RootViewModel} from "./domain/RootViewModel.js"; export {RootViewModel} from "./domain/RootViewModel.js";
export {RootView} from "./platform/web/ui/RootView.js"; export {RootView} from "./platform/web/ui/RootView.js";
export {SessionViewModel} from "./domain/session/SessionViewModel.js"; export {SessionViewModel} from "./domain/session/SessionViewModel.js";

View file

@ -100,6 +100,8 @@ export class Client {
}); });
} }
// TODO: When converted to typescript this should return the same type
// as this._loginOptions is in LoginViewModel.ts (LoginOptions).
_parseLoginOptions(options, homeserver) { _parseLoginOptions(options, homeserver) {
/* /*
Take server response and return new object which has two props password and sso which Take server response and return new object which has two props password and sso which
@ -136,7 +138,7 @@ export class Client {
const request = this._platform.request; const request = this._platform.request;
const hsApi = new HomeServerApi({homeserver, request}); const hsApi = new HomeServerApi({homeserver, request});
const registration = new Registration(hsApi, { const registration = new Registration(hsApi, {
username, username,
password, password,
initialDeviceDisplayName, initialDeviceDisplayName,
}, },
@ -196,7 +198,7 @@ export class Client {
sessionInfo.deviceId = dehydratedDevice.deviceId; sessionInfo.deviceId = dehydratedDevice.deviceId;
} }
} }
await this._platform.sessionInfoStorage.add(sessionInfo); await this._platform.sessionInfoStorage.add(sessionInfo);
// loading the session can only lead to // loading the session can only lead to
// LoadStatus.Error in case of an error, // LoadStatus.Error in case of an error,
// so separate try/catch // so separate try/catch
@ -266,7 +268,7 @@ export class Client {
this._status.set(LoadStatus.SessionSetup); this._status.set(LoadStatus.SessionSetup);
await log.wrap("createIdentity", log => this._session.createIdentity(log)); await log.wrap("createIdentity", log => this._session.createIdentity(log));
} }
this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session, logger: this._platform.logger}); this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session, logger: this._platform.logger});
// notify sync and session when back online // notify sync and session when back online
this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => { this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => {
@ -311,7 +313,7 @@ export class Client {
this._waitForFirstSyncHandle = this._sync.status.waitFor(s => { this._waitForFirstSyncHandle = this._sync.status.waitFor(s => {
if (s === SyncStatus.Stopped) { if (s === SyncStatus.Stopped) {
// keep waiting if there is a ConnectionError // keep waiting if there is a ConnectionError
// as the reconnector above will call // as the reconnector above will call
// sync.start again to retry in this case // sync.start again to retry in this case
return this._sync.error?.name !== "ConnectionError"; return this._sync.error?.name !== "ConnectionError";
} }

View file

@ -0,0 +1,7 @@
import {ILoginMethod} from "./LoginMethod";
import {PasswordLoginMethod} from "./PasswordLoginMethod";
import {SSOLoginHelper} from "./SSOLoginHelper";
import {TokenLoginMethod} from "./TokenLoginMethod";
export {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod};

View file

@ -42,6 +42,7 @@ async function requestPersistedStorage(): Promise<boolean> {
await glob.document.requestStorageAccess(); await glob.document.requestStorageAccess();
return true; return true;
} catch (err) { } catch (err) {
console.warn("requestStorageAccess threw an error:", err);
return false; return false;
} }
} else { } else {

View file

@ -0,0 +1,64 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type Config = {
/**
* The default homeserver used by Hydrogen; auto filled in the login UI.
* eg: https://matrix.org
* REQUIRED
*/
defaultHomeServer: string;
/**
* The submit endpoint for your preferred rageshake server.
* eg: https://element.io/bugreports/submit
* Read more about rageshake at https://github.com/matrix-org/rageshake
* OPTIONAL
*/
bugReportEndpointUrl?: string;
/**
* Paths to theme-manifests
* eg: ["assets/theme-element.json", "assets/theme-awesome.json"]
* REQUIRED
*/
themeManifests: string[];
/**
* This configures the default theme(s) used by Hydrogen.
* These themes appear as "Default" option in the theme chooser UI and are also
* used as a fallback when other themes fail to load.
* Whether the dark or light variant is used depends on the system preference.
* OPTIONAL
*/
defaultTheme?: {
// id of light theme
light: string;
// id of dark theme
dark: string;
};
/**
* Configuration for push notifications.
* See https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3pushersset
* and https://github.com/matrix-org/sygnal/blob/main/docs/applications.md#webpush
* OPTIONAL
*/
push?: {
// See app_id in the request body in above link
appId: string;
// The host used for pushing notification
gatewayUrl: string;
// See pushkey in above link
applicationServerKey: string;
};
};

View file

@ -22,6 +22,13 @@ export type ThemeManifest = Partial<{
version: number; version: number;
// A user-facing string that is the name for this theme-collection. // A user-facing string that is the name for this theme-collection.
name: string; name: string;
// An identifier for this theme
id: string;
/**
* Id of the theme that this theme derives from.
* Only present for derived/runtime themes.
*/
extends: string;
/** /**
* This is added to the manifest during the build process and includes data * This is added to the manifest during the build process and includes data
* that is needed to load themes at runtime. * that is needed to load themes at runtime.
@ -42,6 +49,12 @@ export type ThemeManifest = Partial<{
"runtime-asset": string; "runtime-asset": string;
// Array of derived-variables // Array of derived-variables
"derived-variables": Array<string>; "derived-variables": Array<string>;
/**
* Mapping from icon variable to location of icon in build output with query parameters
* indicating how it should be colored for this particular theme.
* eg: "icon-url-1": "element-logo.86bc8565.svg?primary=accent-color"
*/
icon: Record<string, string>;
}; };
values: { values: {
/** /**
@ -60,6 +73,8 @@ type Variant = Partial<{
default: boolean; default: boolean;
// A user-facing string that is the name for this variant. // A user-facing string that is the name for this variant.
name: string; name: string;
// A boolean indicating whether this is a dark theme or not
dark: boolean;
/** /**
* Mapping from css variable to its value. * Mapping from css variable to its value.
* eg: {"background-color-primary": "#21262b", ...} * eg: {"background-color-primary": "#21262b", ...}

View file

@ -38,7 +38,7 @@ import {downloadInIframe} from "./dom/download.js";
import {Disposables} from "../../utils/Disposables"; import {Disposables} from "../../utils/Disposables";
import {parseHTML} from "./parsehtml.js"; import {parseHTML} from "./parsehtml.js";
import {handleAvatarError} from "./ui/avatar"; import {handleAvatarError} from "./ui/avatar";
import {ThemeLoader} from "./ThemeLoader"; import {ThemeLoader} from "./theming/ThemeLoader";
function addScript(src) { function addScript(src) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {

View file

@ -1,217 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type {ILogItem} from "../../logging/types.js";
import type {Platform} from "./Platform.js";
type NormalVariant = {
id: string;
cssLocation: string;
};
type DefaultVariant = {
dark: {
id: string;
cssLocation: string;
variantName: string;
};
light: {
id: string;
cssLocation: string;
variantName: string;
};
default: {
id: string;
cssLocation: string;
variantName: string;
};
}
type ThemeInformation = NormalVariant | DefaultVariant;
export enum ColorSchemePreference {
Dark,
Light
};
export class ThemeLoader {
private _platform: Platform;
private _themeMapping: Record<string, ThemeInformation>;
constructor(platform: Platform) {
this._platform = platform;
}
async init(manifestLocations: string[], log?: ILogItem): Promise<void> {
await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => {
this._themeMapping = {};
const results = await Promise.all(
manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response())
);
results.forEach(({ body }, i) => this._populateThemeMap(body, manifestLocations[i], log));
});
}
private _populateThemeMap(manifest, manifestLocation: string, log: ILogItem) {
log.wrap("populateThemeMap", (l) => {
/*
After build has finished, the source section of each theme manifest
contains `built-assets` which is a mapping from the theme-id to
cssLocation of theme
*/
const builtAssets: Record<string, string> = manifest.source?.["built-assets"];
const themeName = manifest.name;
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
for (let [themeId, cssLocation] of Object.entries(builtAssets)) {
try {
/**
* This cssLocation is relative to the location of the manifest file.
* So we first need to resolve it relative to the root of this hydrogen instance.
*/
cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href;
}
catch {
continue;
}
const variant = themeId.match(/.+-(.+)/)?.[1];
const { name: variantName, default: isDefault, dark } = manifest.values.variants[variant!];
const themeDisplayName = `${themeName} ${variantName}`;
if (isDefault) {
/**
* This is a default variant!
* We'll add these to the themeMapping (separately) keyed with just the
* theme-name (i.e "Element" instead of "Element Dark").
* We need to be able to distinguish them from other variants!
*
* This allows us to render radio-buttons with "dark" and
* "light" options.
*/
const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant;
defaultVariant.variantName = variantName;
defaultVariant.id = themeId
defaultVariant.cssLocation = cssLocation;
continue;
}
// Non-default variants are keyed in themeMapping with "theme_name variant_name"
// eg: "Element Dark"
this._themeMapping[themeDisplayName] = {
cssLocation,
id: themeId
};
}
if (defaultDarkVariant.id && defaultLightVariant.id) {
/**
* As mentioned above, if there's both a default dark and a default light variant,
* add them to themeMapping separately.
*/
const defaultVariant = this.preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant;
this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant };
}
else {
/**
* If only one default variant is found (i.e only dark default or light default but not both),
* treat it like any other variant.
*/
const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant;
this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation };
}
//Add the default-theme as an additional option to the mapping
const defaultThemeId = this.getDefaultTheme();
if (defaultThemeId) {
const themeDetails = this._findThemeDetailsFromId(defaultThemeId);
if (themeDetails) {
this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.cssLocation };
}
}
l.log({ l: "Default Theme", theme: defaultThemeId});
l.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" });
l.log({ l: "Result", themeMapping: this._themeMapping });
});
}
setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) {
this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => {
let cssLocation: string;
let themeDetails = this._themeMapping[themeName];
if ("id" in themeDetails) {
cssLocation = themeDetails.cssLocation;
}
else {
if (!themeVariant) {
throw new Error("themeVariant is undefined!");
}
cssLocation = themeDetails[themeVariant].cssLocation;
}
this._platform.replaceStylesheet(cssLocation);
this._platform.settingsStorage.setString("theme-name", themeName);
if (themeVariant) {
this._platform.settingsStorage.setString("theme-variant", themeVariant);
}
else {
this._platform.settingsStorage.remove("theme-variant");
}
});
}
/** Maps theme display name to theme information */
get themeMapping(): Record<string, ThemeInformation> {
return this._themeMapping;
}
async getActiveTheme(): Promise<{themeName: string, themeVariant?: string}> {
let themeName = await this._platform.settingsStorage.getString("theme-name");
let themeVariant = await this._platform.settingsStorage.getString("theme-variant");
if (!themeName || !this._themeMapping[themeName]) {
themeName = "Default" in this._themeMapping ? "Default" : Object.keys(this._themeMapping)[0];
if (!this._themeMapping[themeName][themeVariant]) {
themeVariant = "default" in this._themeMapping[themeName] ? "default" : undefined;
}
}
return { themeName, themeVariant };
}
getDefaultTheme(): string | undefined {
switch (this.preferredColorScheme) {
case ColorSchemePreference.Dark:
return this._platform.config["defaultTheme"]?.dark;
case ColorSchemePreference.Light:
return this._platform.config["defaultTheme"]?.light;
}
}
private _findThemeDetailsFromId(themeId: string): {themeName: string, cssLocation: string, variant?: string} | undefined {
for (const [themeName, themeData] of Object.entries(this._themeMapping)) {
if ("id" in themeData && themeData.id === themeId) {
return { themeName, cssLocation: themeData.cssLocation };
}
else if ("light" in themeData && themeData.light?.id === themeId) {
return { themeName, cssLocation: themeData.light.cssLocation, variant: "light" };
}
else if ("dark" in themeData && themeData.dark?.id === themeId) {
return { themeName, cssLocation: themeData.dark.cssLocation, variant: "dark" };
}
}
}
get preferredColorScheme(): ColorSchemePreference | undefined {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return ColorSchemePreference.Dark;
}
else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
return ColorSchemePreference.Light;
}
}
}

View file

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

View file

@ -17,6 +17,12 @@ limitations under the License.
import {BaseObservableValue} from "../../../observable/ObservableValue"; import {BaseObservableValue} from "../../../observable/ObservableValue";
export class History extends BaseObservableValue { export class History extends BaseObservableValue {
constructor() {
super();
this._lastSessionHash = undefined;
}
handleEvent(event) { handleEvent(event) {
if (event.type === "hashchange") { if (event.type === "hashchange") {
this.emit(this.get()); this.emit(this.get());
@ -65,6 +71,7 @@ export class History extends BaseObservableValue {
} }
onSubscribeFirst() { onSubscribeFirst() {
this._lastSessionHash = window.localStorage?.getItem("hydrogen_last_url_hash");
window.addEventListener('hashchange', this); window.addEventListener('hashchange', this);
} }
@ -76,7 +83,7 @@ export class History extends BaseObservableValue {
window.localStorage?.setItem("hydrogen_last_url_hash", hash); window.localStorage?.setItem("hydrogen_last_url_hash", hash);
} }
getLastUrl() { getLastSessionUrl() {
return window.localStorage?.getItem("hydrogen_last_url_hash"); return this._lastSessionHash;
} }
} }

View file

@ -115,6 +115,9 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) {
} else if (format === "buffer") { } else if (format === "buffer") {
body = await response.arrayBuffer(); body = await response.arrayBuffer();
} }
else if (format === "text") {
body = await response.text();
}
} catch (err) { } catch (err) {
// some error pages return html instead of json, ignore error // some error pages return html instead of json, ignore error
if (!(err.name === "SyntaxError" && status >= 400)) { if (!(err.name === "SyntaxError" && status >= 400)) {

View file

@ -17,7 +17,7 @@ limitations under the License.
// import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay"; // import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay";
import {RootViewModel} from "../../domain/RootViewModel.js"; import {RootViewModel} from "../../domain/RootViewModel.js";
import {createNavigation, createRouter} from "../../domain/navigation/index.js"; import {createNavigation, createRouter} from "../../domain/navigation/index";
// Don't use a default export here, as we use multiple entries during legacy build, // Don't use a default export here, as we use multiple entries during legacy build,
// which does not support default exports, // which does not support default exports,
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry // see https://github.com/rollup/plugins/tree/master/packages/multi-entry

View file

@ -0,0 +1,131 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {derive} from "./shared/color.mjs";
export class DerivedVariables {
private _baseVariables: Record<string, string>;
private _variablesToDerive: string[]
private _isDark: boolean
private _aliases: Record<string, string> = {};
private _derivedAliases: string[] = [];
constructor(baseVariables: Record<string, string>, variablesToDerive: string[], isDark: boolean) {
this._baseVariables = baseVariables;
this._variablesToDerive = variablesToDerive;
this._isDark = isDark;
}
toVariables(): Record<string, string> {
const resolvedVariables: any = {};
this._detectAliases();
for (const variable of this._variablesToDerive) {
const resolvedValue = this._derive(variable);
if (resolvedValue) {
resolvedVariables[variable] = resolvedValue;
}
}
for (const [alias, variable] of Object.entries(this._aliases) as any) {
resolvedVariables[alias] = this._baseVariables[variable] ?? resolvedVariables[variable];
}
for (const variable of this._derivedAliases) {
const resolvedValue = this._deriveAlias(variable, resolvedVariables);
if (resolvedValue) {
resolvedVariables[variable] = resolvedValue;
}
}
return resolvedVariables;
}
private _detectAliases(): void {
const newVariablesToDerive: string[] = [];
for (const variable of this._variablesToDerive) {
const [alias, value] = variable.split("=");
if (value) {
this._aliases[alias] = value;
}
else {
newVariablesToDerive.push(variable);
}
}
this._variablesToDerive = newVariablesToDerive;
}
private _derive(variable: string): string | undefined {
const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/;
const matches = variable.match(RE_VARIABLE_VALUE);
if (matches) {
const [, baseVariable, operation, argument] = matches;
const value = this._baseVariables[baseVariable];
if (!value ) {
if (this._aliases[baseVariable]) {
this._derivedAliases.push(variable);
return;
}
else {
throw new Error(`Cannot find value for base variable "${baseVariable}"!`);
}
}
const resolvedValue = derive(value, operation, argument, this._isDark);
return resolvedValue;
}
}
private _deriveAlias(variable: string, resolvedVariables: Record<string, string>): string | undefined {
const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/;
const matches = variable.match(RE_VARIABLE_VALUE);
if (matches) {
const [, baseVariable, operation, argument] = matches;
const value = resolvedVariables[baseVariable];
if (!value ) {
throw new Error(`Cannot find value for alias "${baseVariable}" when trying to derive ${variable}!`);
}
const resolvedValue = derive(value, operation, argument, this._isDark);
return resolvedValue;
}
}
}
import * as pkg from "off-color";
// @ts-ignore
const offColor = pkg.offColor ?? pkg.default.offColor;
export function tests() {
return {
"Simple variable derivation": assert => {
const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["background-color--darker-5"], false);
const result = deriver.toVariables();
const resultColor = offColor("#ff00ff").darken(5/100).hex();
assert.deepEqual(result, {"background-color--darker-5": resultColor});
},
"For dark themes, lighten and darken are inverted": assert => {
const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["background-color--darker-5"], true);
const result = deriver.toVariables();
const resultColor = offColor("#ff00ff").lighten(5/100).hex();
assert.deepEqual(result, {"background-color--darker-5": resultColor});
},
"Aliases can be derived": assert => {
const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["my-awesome-alias=background-color","my-awesome-alias--darker-5"], false);
const result = deriver.toVariables();
const resultColor = offColor("#ff00ff").darken(5/100).hex();
assert.deepEqual(result, {
"my-awesome-alias": "#ff00ff",
"my-awesome-alias--darker-5": resultColor,
});
},
}
}

View file

@ -0,0 +1,79 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type {Platform} from "../Platform.js";
import {getColoredSvgString} from "./shared/svg-colorizer.mjs";
type ParsedStructure = {
[variableName: string]: {
svg: Promise<{ status: number; body: string }>;
primary: string | null;
secondary: string | null;
};
};
export class IconColorizer {
private _iconVariables: Record<string, string>;
private _resolvedVariables: Record<string, string>;
private _manifestLocation: string;
private _platform: Platform;
constructor(platform: Platform, iconVariables: Record<string, string>, resolvedVariables: Record<string, string>, manifestLocation: string) {
this._platform = platform;
this._iconVariables = iconVariables;
this._resolvedVariables = resolvedVariables;
this._manifestLocation = manifestLocation;
}
async toVariables(): Promise<Record<string, string>> {
const { parsedStructure, promises } = await this._fetchAndParseIcons();
await Promise.all(promises);
return this._produceColoredIconVariables(parsedStructure);
}
private async _fetchAndParseIcons(): Promise<{ parsedStructure: ParsedStructure, promises: any[] }> {
const promises: any[] = [];
const parsedStructure: ParsedStructure = {};
for (const [variable, url] of Object.entries(this._iconVariables)) {
const urlObject = new URL(`https://${url}`);
const pathWithoutQueryParams = urlObject.hostname;
const relativePath = new URL(pathWithoutQueryParams, new URL(this._manifestLocation, window.location.origin));
const responsePromise = this._platform.request(relativePath, { method: "GET", format: "text", cache: true, }).response()
promises.push(responsePromise);
const searchParams = urlObject.searchParams;
parsedStructure[variable] = {
svg: responsePromise,
primary: searchParams.get("primary"),
secondary: searchParams.get("secondary")
};
}
return { parsedStructure, promises };
}
private async _produceColoredIconVariables(parsedStructure: ParsedStructure): Promise<Record<string, string>> {
let coloredVariables: Record<string, string> = {};
for (const [variable, { svg, primary, secondary }] of Object.entries(parsedStructure)) {
const { body: svgCode } = await svg;
if (!primary) {
throw new Error(`Primary color variable ${primary} not in list of variables!`);
}
const primaryColor = this._resolvedVariables[primary], secondaryColor = this._resolvedVariables[secondary!];
const coloredSvgCode = getColoredSvgString(svgCode, primaryColor, secondaryColor);
const dataURI = `url('data:image/svg+xml;utf8,${encodeURIComponent(coloredSvgCode)}')`;
coloredVariables[variable] = dataURI;
}
return coloredVariables;
}
}

View file

@ -0,0 +1,188 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type {ILogItem} from "../../../logging/types";
import type {Platform} from "../Platform.js";
import {RuntimeThemeParser} from "./parsers/RuntimeThemeParser";
import type {Variant, ThemeInformation} from "./parsers/types";
import {ColorSchemePreference} from "./parsers/types";
import {BuiltThemeParser} from "./parsers/BuiltThemeParser";
export class ThemeLoader {
private _platform: Platform;
private _themeMapping: Record<string, ThemeInformation>;
private _injectedVariables?: Record<string, string>;
constructor(platform: Platform) {
this._platform = platform;
}
async init(manifestLocations: string[], log?: ILogItem): Promise<void> {
await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => {
const results = await Promise.all(
manifestLocations.map(location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response())
);
const runtimeThemeParser = new RuntimeThemeParser(this._platform, this.preferredColorScheme);
const builtThemeParser = new BuiltThemeParser(this.preferredColorScheme);
const runtimeThemePromises: Promise<void>[] = [];
for (let i = 0; i < results.length; ++i) {
const { body } = results[i];
try {
if (body.extends) {
const indexOfBaseManifest = results.findIndex(manifest => manifest.body.id === body.extends);
if (indexOfBaseManifest === -1) {
throw new Error(`Base manifest for derived theme at ${manifestLocations[i]} not found!`);
}
const {body: baseManifest} = results[indexOfBaseManifest];
const baseManifestLocation = manifestLocations[indexOfBaseManifest];
const promise = runtimeThemeParser.parse(body, baseManifest, baseManifestLocation, log);
runtimeThemePromises.push(promise);
}
else {
builtThemeParser.parse(body, manifestLocations[i], log);
}
}
catch(e) {
console.error(e);
}
}
await Promise.all(runtimeThemePromises);
this._themeMapping = { ...builtThemeParser.themeMapping, ...runtimeThemeParser.themeMapping };
Object.assign(this._themeMapping, builtThemeParser.themeMapping, runtimeThemeParser.themeMapping);
this._addDefaultThemeToMapping(log);
log.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" });
log.log({ l: "Result", themeMapping: this._themeMapping });
});
}
setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) {
this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => {
let cssLocation: string, variables: Record<string, string>;
let themeDetails = this._themeMapping[themeName];
if ("id" in themeDetails) {
cssLocation = themeDetails.cssLocation;
variables = themeDetails.variables;
}
else {
if (!themeVariant) {
throw new Error("themeVariant is undefined!");
}
cssLocation = themeDetails[themeVariant].cssLocation;
variables = themeDetails[themeVariant].variables;
}
this._platform.replaceStylesheet(cssLocation);
if (variables) {
log?.log({l: "Derived Theme", variables});
this._injectCSSVariables(variables);
}
else {
this._removePreviousCSSVariables();
}
this._platform.settingsStorage.setString("theme-name", themeName);
if (themeVariant) {
this._platform.settingsStorage.setString("theme-variant", themeVariant);
}
else {
this._platform.settingsStorage.remove("theme-variant");
}
});
}
private _injectCSSVariables(variables: Record<string, string>): void {
const root = document.documentElement;
for (const [variable, value] of Object.entries(variables)) {
root.style.setProperty(`--${variable}`, value);
}
this._injectedVariables = variables;
}
private _removePreviousCSSVariables(): void {
if (!this._injectedVariables) {
return;
}
const root = document.documentElement;
for (const variable of Object.keys(this._injectedVariables)) {
root.style.removeProperty(`--${variable}`);
}
this._injectedVariables = undefined;
}
/** Maps theme display name to theme information */
get themeMapping(): Record<string, ThemeInformation> {
return this._themeMapping;
}
async getActiveTheme(): Promise<{themeName: string, themeVariant?: string}> {
let themeName = await this._platform.settingsStorage.getString("theme-name");
let themeVariant = await this._platform.settingsStorage.getString("theme-variant");
if (!themeName || !this._themeMapping[themeName]) {
themeName = "Default" in this._themeMapping ? "Default" : Object.keys(this._themeMapping)[0];
if (!this._themeMapping[themeName][themeVariant]) {
themeVariant = "default" in this._themeMapping[themeName] ? "default" : undefined;
}
}
return { themeName, themeVariant };
}
getDefaultTheme(): string | undefined {
switch (this.preferredColorScheme) {
case ColorSchemePreference.Dark:
return this._platform.config["defaultTheme"]?.dark;
case ColorSchemePreference.Light:
return this._platform.config["defaultTheme"]?.light;
}
}
private _findThemeDetailsFromId(themeId: string): {themeName: string, themeData: Partial<Variant>} | undefined {
for (const [themeName, themeData] of Object.entries(this._themeMapping)) {
if ("id" in themeData && themeData.id === themeId) {
return { themeName, themeData };
}
else if ("light" in themeData && themeData.light?.id === themeId) {
return { themeName, themeData: themeData.light };
}
else if ("dark" in themeData && themeData.dark?.id === themeId) {
return { themeName, themeData: themeData.dark };
}
}
}
private _addDefaultThemeToMapping(log: ILogItem) {
log.wrap("addDefaultThemeToMapping", l => {
const defaultThemeId = this.getDefaultTheme();
if (defaultThemeId) {
const themeDetails = this._findThemeDetailsFromId(defaultThemeId);
if (themeDetails) {
this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.themeData.cssLocation! };
const variables = themeDetails.themeData.variables;
if (variables) {
this._themeMapping["Default"].variables = variables;
}
}
}
l.log({ l: "Default Theme", theme: defaultThemeId});
});
}
get preferredColorScheme(): ColorSchemePreference | undefined {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return ColorSchemePreference.Dark;
}
else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
return ColorSchemePreference.Light;
}
}
}

View file

@ -0,0 +1,106 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type {ThemeInformation} from "./types";
import type {ThemeManifest} from "../../../types/theme";
import type {ILogItem} from "../../../../logging/types";
import {ColorSchemePreference} from "./types";
export class BuiltThemeParser {
private _themeMapping: Record<string, ThemeInformation> = {};
private _preferredColorScheme?: ColorSchemePreference;
constructor(preferredColorScheme?: ColorSchemePreference) {
this._preferredColorScheme = preferredColorScheme;
}
parse(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) {
log.wrap("BuiltThemeParser.parse", () => {
/*
After build has finished, the source section of each theme manifest
contains `built-assets` which is a mapping from the theme-id to
cssLocation of theme
*/
const builtAssets: Record<string, string> = manifest.source?.["built-assets"];
const themeName = manifest.name;
if (!themeName) {
throw new Error(`Theme name not found in manifest at ${manifestLocation}`);
}
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
for (let [themeId, cssLocation] of Object.entries(builtAssets)) {
try {
/**
* This cssLocation is relative to the location of the manifest file.
* So we first need to resolve it relative to the root of this hydrogen instance.
*/
cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href;
}
catch {
continue;
}
const variant = themeId.match(/.+-(.+)/)?.[1];
const variantDetails = manifest.values?.variants[variant!];
if (!variantDetails) {
throw new Error(`Variant ${variant} is missing in manifest at ${manifestLocation}`);
}
const { name: variantName, default: isDefault, dark } = variantDetails;
const themeDisplayName = `${themeName} ${variantName}`;
if (isDefault) {
/**
* This is a default variant!
* We'll add these to the themeMapping (separately) keyed with just the
* theme-name (i.e "Element" instead of "Element Dark").
* We need to be able to distinguish them from other variants!
*
* This allows us to render radio-buttons with "dark" and
* "light" options.
*/
const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant;
defaultVariant.variantName = variantName;
defaultVariant.id = themeId
defaultVariant.cssLocation = cssLocation;
continue;
}
// Non-default variants are keyed in themeMapping with "theme_name variant_name"
// eg: "Element Dark"
this._themeMapping[themeDisplayName] = {
cssLocation,
id: themeId
};
}
if (defaultDarkVariant.id && defaultLightVariant.id) {
/**
* As mentioned above, if there's both a default dark and a default light variant,
* add them to themeMapping separately.
*/
const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant;
this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant };
}
else {
/**
* If only one default variant is found (i.e only dark default or light default but not both),
* treat it like any other variant.
*/
const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant;
this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation };
}
});
}
get themeMapping(): Record<string, ThemeInformation> {
return this._themeMapping;
}
}

View file

@ -0,0 +1,98 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type {ThemeInformation} from "./types";
import type {Platform} from "../../Platform.js";
import type {ThemeManifest} from "../../../types/theme";
import {ColorSchemePreference} from "./types";
import {IconColorizer} from "../IconColorizer";
import {DerivedVariables} from "../DerivedVariables";
import {ILogItem} from "../../../../logging/types";
export class RuntimeThemeParser {
private _themeMapping: Record<string, ThemeInformation> = {};
private _preferredColorScheme?: ColorSchemePreference;
private _platform: Platform;
constructor(platform: Platform, preferredColorScheme?: ColorSchemePreference) {
this._preferredColorScheme = preferredColorScheme;
this._platform = platform;
}
async parse(manifest: ThemeManifest, baseManifest: ThemeManifest, baseManifestLocation: string, log: ILogItem): Promise<void> {
await log.wrap("RuntimeThemeParser.parse", async () => {
const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, baseManifestLocation, log);
const themeName = manifest.name;
if (!themeName) {
throw new Error(`Theme name not found in manifest!`);
}
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
for (const [variant, variantDetails] of Object.entries(manifest.values?.variants!) as [string, any][]) {
try {
const themeId = `${manifest.id}-${variant}`;
const { name: variantName, default: isDefault, dark, variables } = variantDetails;
const resolvedVariables = new DerivedVariables(variables, derivedVariables, dark).toVariables();
Object.assign(variables, resolvedVariables);
const iconVariables = await new IconColorizer(this._platform, icons, variables, baseManifestLocation).toVariables();
Object.assign(variables, resolvedVariables, iconVariables);
const themeDisplayName = `${themeName} ${variantName}`;
if (isDefault) {
const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant;
Object.assign(defaultVariant, { variantName, id: themeId, cssLocation, variables });
continue;
}
this._themeMapping[themeDisplayName] = { cssLocation, id: themeId, variables: variables, };
}
catch (e) {
console.error(e);
continue;
}
}
if (defaultDarkVariant.id && defaultLightVariant.id) {
const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant;
this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant };
}
else {
const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant;
this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation };
}
});
}
private _getSourceData(manifest: ThemeManifest, location: string, log: ILogItem)
: { cssLocation: string, derivedVariables: string[], icons: Record<string, string>} {
return log.wrap("getSourceData", () => {
const runtimeCSSLocation = manifest.source?.["runtime-asset"];
if (!runtimeCSSLocation) {
throw new Error(`Run-time asset not found in source section for theme at ${location}`);
}
const cssLocation = new URL(runtimeCSSLocation, new URL(location, window.location.origin)).href;
const derivedVariables = manifest.source?.["derived-variables"];
if (!derivedVariables) {
throw new Error(`Derived variables not found in source section for theme at ${location}`);
}
const icons = manifest.source?.["icon"];
if (!icons) {
throw new Error(`Icon mapping not found in source section for theme at ${location}`);
}
return { cssLocation, derivedVariables, icons };
});
}
get themeMapping(): Record<string, ThemeInformation> {
return this._themeMapping;
}
}

View file

@ -0,0 +1,38 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type NormalVariant = {
id: string;
cssLocation: string;
variables?: any;
};
export type Variant = NormalVariant & {
variantName: string;
};
export type DefaultVariant = {
dark: Variant;
light: Variant;
default: Variant;
}
export type ThemeInformation = NormalVariant | DefaultVariant;
export enum ColorSchemePreference {
Dark,
Light
};

View file

@ -13,10 +13,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import * as pkg from 'off-color';
const offColor = pkg.offColor ?? pkg.default.offColor;
const offColor = require("off-color").offColor; export function derive(value, operation, argument, isDark) {
module.exports.derive = function (value, operation, argument, isDark) {
const argumentAsNumber = parseInt(argument); const argumentAsNumber = parseInt(argument);
if (isDark) { if (isDark) {
// For dark themes, invert the operation // For dark themes, invert the operation

View file

@ -0,0 +1,24 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function getColoredSvgString(svgString, primaryColor, secondaryColor) {
let coloredSVGCode = svgString.replaceAll("#ff00ff", primaryColor);
coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor);
if (svgString === 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).");
}
return coloredSVGCode;
}

View file

@ -521,6 +521,62 @@ a {
.RoomView_error { .RoomView_error {
color: var(--error-color); color: var(--error-color);
background : #efefef;
height : 0px;
font-weight : bold;
transition : 0.25s all ease-out;
padding-right : 20px;
padding-left : 20px;
}
.RoomView_error div{
overflow : hidden;
height: 100%;
width: 100%;
position : relative;
display : flex;
align-items : center;
}
.RoomView_error:not(:empty) {
height : auto;
padding-top : 20px;
padding-bottom : 20px;
}
.RoomView_error p {
position : relative;
display : block;
width : 100%;
height : auto;
margin : 0;
}
.RoomView_error button {
width : 40px;
padding-top : 20px;
padding-bottom : 20px;
background : none;
border : none;
position : relative;
border-radius : 5px;
transition: 0.1s all ease-out;
cursor: pointer;
}
.RoomView_error button:hover {
background : #cfcfcf;
}
.RoomView_error button:before {
content:"\274c";
position : absolute;
top : 15px;
left: 9px;
width : 20px;
height : 10px;
font-size : 10px;
align-self : middle;
} }
.MessageComposer_replyPreview .Timeline_message { .MessageComposer_replyPreview .Timeline_message {

View file

@ -46,7 +46,13 @@ export class RoomView extends TemplateView {
}) })
]), ]),
t.div({className: "RoomView_body"}, [ t.div({className: "RoomView_body"}, [
t.div({className: "RoomView_error"}, vm => vm.error), t.div({className: "RoomView_error"}, [
t.if(vm => vm.error, t => t.div(
[
t.p({}, vm => vm.error),
t.button({ className: "RoomView_error_closerButton", onClick: evt => vm.dismissError(evt) })
])
)]),
t.mapView(vm => vm.timelineViewModel, timelineViewModel => { t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
return timelineViewModel ? return timelineViewModel ?
new TimelineView(timelineViewModel, this._viewClassForTile) : new TimelineView(timelineViewModel, this._viewClassForTile) :
@ -64,7 +70,7 @@ export class RoomView extends TemplateView {
]) ])
]); ]);
} }
_toggleOptionsMenu(evt) { _toggleOptionsMenu(evt) {
if (this._optionsPopup && this._optionsPopup.isOpen) { if (this._optionsPopup && this._optionsPopup.isOpen) {
this._optionsPopup.close(); this._optionsPopup.close();

View file

@ -8,8 +8,8 @@ const path = require("path");
const manifest = require("./package.json"); const manifest = require("./package.json");
const version = manifest.version; const version = manifest.version;
const compiledVariables = new Map(); const compiledVariables = new Map();
const derive = require("./scripts/postcss/color").derive; import {buildColorizedSVG as replacer} from "./scripts/postcss/svg-builder.mjs";
const replacer = require("./scripts/postcss/svg-colorizer").buildColorizedSVG; import {derive} from "./src/platform/web/theming/shared/color.mjs";
const commonOptions = { const commonOptions = {
logLevel: "warn", logLevel: "warn",

View file

@ -36,7 +36,7 @@ export default mergeOptions(commonOptions, {
plugins: [ plugins: [
themeBuilder({ themeBuilder({
themeConfig: { themeConfig: {
themes: { element: "./src/platform/web/ui/css/themes/element" }, themes: ["./src/platform/web/ui/css/themes/element"],
default: "element", default: "element",
}, },
compiledVariables, compiledVariables,

View file

@ -77,6 +77,11 @@
"@nodelib/fs.scandir" "2.1.5" "@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0" fastq "^1.6.0"
"@trysound/sax@0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
"@types/json-schema@^7.0.7": "@types/json-schema@^7.0.7":
version "7.0.9" version "7.0.9"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
@ -347,6 +352,11 @@ commander@^6.1.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
commander@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
concat-map@0.0.1: concat-map@0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@ -382,11 +392,26 @@ css-select@^4.1.3:
domutils "^2.6.0" domutils "^2.6.0"
nth-check "^2.0.0" nth-check "^2.0.0"
css-tree@^1.1.2, css-tree@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
dependencies:
mdn-data "2.0.14"
source-map "^0.6.1"
css-what@^5.0.0: css-what@^5.0.0:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad" resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad"
integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg== integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg==
csso@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529"
integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==
dependencies:
css-tree "^1.1.2"
cuint@^0.2.2: cuint@^0.2.2:
version "0.2.2" version "0.2.2"
resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"
@ -1197,6 +1222,11 @@ lru-cache@^6.0.0:
dependencies: dependencies:
yallist "^4.0.0" yallist "^4.0.0"
mdn-data@2.0.14:
version "2.0.14"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
mdn-polyfills@^5.20.0: mdn-polyfills@^5.20.0:
version "5.20.0" version "5.20.0"
resolved "https://registry.yarnpkg.com/mdn-polyfills/-/mdn-polyfills-5.20.0.tgz#ca8247edf20a4f60dec6804372229812b348260b" resolved "https://registry.yarnpkg.com/mdn-polyfills/-/mdn-polyfills-5.20.0.tgz#ca8247edf20a4f60dec6804372229812b348260b"
@ -1500,7 +1530,7 @@ source-map-js@^1.0.2:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
source-map@~0.6.1: source-map@^0.6.1, source-map@~0.6.1:
version "0.6.1" version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
@ -1510,6 +1540,11 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
stable@^0.1.8:
version "0.1.8"
resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
string-width@^4.2.0: string-width@^4.2.0:
version "4.2.2" version "4.2.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
@ -1550,6 +1585,19 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
svgo@^2.8.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24"
integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==
dependencies:
"@trysound/sax" "0.2.0"
commander "^7.2.0"
css-select "^4.1.3"
css-tree "^1.1.3"
csso "^4.2.0"
picocolors "^1.0.0"
stable "^0.1.8"
table@^6.0.9: table@^6.0.9:
version "6.7.1" version "6.7.1"
resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2" resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2"
@ -1617,10 +1665,10 @@ type-fest@^0.20.2:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
typescript@^4.3.5: typescript@^4.7.0:
version "4.3.5" version "4.7.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
typeson-registry@^1.0.0-alpha.20: typeson-registry@^1.0.0-alpha.20:
version "1.0.0-alpha.39" version "1.0.0-alpha.39"