Compare commits

..

373 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
cb0ac846c7 remove obsolete comment 2022-07-29 16:22:01 +02: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
Bruno Windels
62b3a67e33 write unit tests for correctly reading history visibility when needed 2022-07-28 17:09:41 +02:00
Bruno Windels
319ec37864 fix typos preventing to load the history visibility 2022-07-28 11:44:50 +02: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
Bruno Windels
0df66b5aea track room before listing user ids when sharing key 2022-07-27 12:06:55 +02:00
Bruno Windels
f18520a2fe let loadMembers use own txn in case members haven't been fetched yet
if they haven't, it will need a network request, meaning that the txn
will get closed, so we can't reuse it afterwards
2022-07-27 11:39:50 +02:00
Bruno Windels
50b6ee91d7 don't need history visibility here 2022-07-27 11:39:36 +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
Bruno Windels
402cf17d22 Merge branch 'master' into bwindels/sharekeyswithinvitees 2022-07-27 09:17:31 +02:00
Bruno Windels
bfaba63f47 fix ts error 2022-07-26 17:55:21 +02:00
Bruno Windels
544afef902 test adding and removing when tracking multiple rooms 2022-07-26 17:41:26 +02:00
Bruno Windels
dd878bb8d6 also take rejecting invites into account to remove user identity 2022-07-26 16:58:07 +02:00
Bruno Windels
dea3852425 add some tests for sharing keys with invitees 2022-07-26 16:57:28 +02:00
Bruno Windels
4c17612b05 allow passing txn to loadMembers so we can do it as part of sync txn
to rewrite useridentities upon receiving new history visibility
2022-07-26 16:53:02 +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
Bruno Windels
bc385e2cdc
Merge pull request #778 from vector-im/bwindels/uidocs
more detailed docs for IView, TemplateView and ListView
2022-07-25 13:02:22 +00: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
Bruno Windels
a23df8a545 pass history visibility to device tracker
and delegate adding and removing members to share keys with to it
2022-07-22 17:49:59 +02:00
Bruno Windels
17f42f523a add write method for when history visibility changes
also returning added and removed user ids
2022-07-22 17:49:26 +02:00
Bruno Windels
f6011f3f34 take history visibility into account in device tracker
and return added and removed userids to their userIdentity for the given
room, so room encryption can share and discard the keys for them
2022-07-22 17:48:26 +02:00
Bruno Windels
86c0e9e669 logic for whether a key should be shared by membership and h. visibility 2022-07-22 17:46:53 +02:00
Bruno Windels
f337940202 this migration shouldn't be needed anymore
and undoes the export of addRoomToIdentity, which is somewhat internal
2022-07-22 17:46:29 +02:00
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
Bruno Windels
22831e710c support async callback in iterateResponseStateEvents 2022-07-22 14:15:26 +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
Bruno Windels
c8a8eb10b5 get user ids for sharing a new key when the message is sent
rather than when the key happens to get sent
2022-07-20 15:21:33 +02:00
Bruno Windels
d79e5f7806 create key share operations for invitees when history visibility=invited 2022-07-20 15:20:23 +02:00
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
Bruno Windels
bb5711db7e
Merge pull request #802 from vector-im/fix-dev-server
Fix bug that stops hydrogen from running in dev server
2022-07-19 10:22:36 +00:00
RMidhunSuresh
88808b0b06 Fix bug preventing yarn start 2022-07-19 15:50:01 +05:30
R Midhun Suresh
c9bca52e82
Merge pull request #760 from vector-im/refactor-rollup-plugin
Refactor theme builder plugin
2022-07-11 16:54:18 +05:30
RMidhunSuresh
6718198d9c Continue with other items if this throws 2022-07-11 12:40:24 +05:30
Bruno Windels
7b9e681d55 sdk v0.0.15 2022-07-07 15:25:17 +02:00
R Midhun Suresh
8291aea2f7
Merge pull request #790 from vector-im/fix-hide-composer
Pass childOptions to LowPowerLevelViewModel
2022-07-07 18:19:08 +05:30
RMidhunSuresh
f073f40e31 Fix error 2022-07-07 18:16:33 +05:30
R Midhun Suresh
963324c767
Merge pull request #789 from vector-im/support-pl-room-creation
Support power_level_content_override option on room creation
2022-07-07 17:42:19 +05:30
R Midhun Suresh
eac75644e7
Merge pull request #788 from vector-im/pl-composer
Disable composer when user lacks powerlevel needed to send messages
2022-07-07 17:35:29 +05:30
RMidhunSuresh
0bdbb96036 Use same kind 2022-07-07 17:26:43 +05:30
RMidhunSuresh
d292e1f5ad Extract into function 2022-07-07 17:23:23 +05:30
RMidhunSuresh
cd9e00b847 Support power_level_content_override 2022-07-07 17:17:05 +05:30
RMidhunSuresh
3941b7e3f0 Rename method 2022-07-07 16:45:18 +05:30
RMidhunSuresh
efd9f70e92 WIP 2022-07-07 16:39:45 +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
R Midhun Suresh
28b686dae7
Merge pull request #784 from vector-im/fix-build-race
Fix build error caused due to race in postcss plugin
2022-07-05 20:13:57 +05:30
RMidhunSuresh
dd82469ab4 Don't assume object is available 2022-07-05 20:07:48 +05:30
Bruno Windels
3bf6a46a39 release sdk 0.0.14 2022-07-05 16:02:47 +02:00
Bruno Windels
e42e76a21c
Merge pull request #782 from vector-im/image-view-fix
Do not render images as links if lightboxUrl is empty
2022-07-05 14:00:53 +00:00
RMidhunSuresh
8ec0bd7295 Check if lightbox url is available 2022-07-05 17:55:51 +05:30
Bruno Windels
ff2129f36a
Merge pull request #773 from vector-im/madlittlemods/consistent-test-selector
Add a couple consistent selectors to reference in tests
2022-07-04 14:19:09 +00:00
Bruno Windels
1aa2ff5c10
Merge pull request #781 from vector-im/bwindels/fixlint-2022-7-4
fix lint
2022-07-04 14:18:05 +00:00
Bruno Windels
34ce8a8e3c fix lint 2022-07-04 16:15:59 +02:00
Bruno Windels
652e2c6d3b
Merge pull request #780 from vector-im/bwindels/update-olm-3.2.8
update olm to 3.2.8
2022-07-04 14:15:04 +00:00
Bruno Windels
c0445f2182 update lock file 2022-07-04 15:40:17 +02:00
Bruno Windels
b76fd1d792 update olm to 3.2.8 2022-07-04 15:39:11 +02:00
R Midhun Suresh
751dfa66a8
Merge pull request #758 from vector-im/document-theming
Document theming in Hydrogen
2022-07-04 17:20:53 +05:30
RMidhunSuresh
a3c6d744f5 Add link to ts file 2022-07-04 17:18:50 +05:30
R Midhun Suresh
b9f316e7c3 Better sentence structure
Co-authored-by: Bruno Windels <274386+bwindels@users.noreply.github.com>
2022-07-04 17:16:43 +05:30
R Midhun Suresh
d448ee1722 Fix typo
Co-authored-by: Bruno Windels <274386+bwindels@users.noreply.github.com>
2022-07-04 17:16:43 +05:30
RMidhunSuresh
da87470996 Store images in source tree 2022-07-04 17:16:43 +05:30
RMidhunSuresh
b319c0acb0 Remvoe stray newlines 2022-07-04 17:16:43 +05:30
RMidhunSuresh
e90e573bf9 Add doc 2022-07-04 17:16:43 +05:30
R Midhun Suresh
a68f0bba39
Merge pull request #752 from vector-im/theme-document-manifest
Create a type for theme-manifest
2022-07-04 17:11:08 +05:30
Bruno Windels
ca94c65dac clarify LazyListView constraints 2022-07-04 10:19:56 +02:00
Bruno Windels
fba3275f5b
Merge pull request #746 from vector-im/madlittlemods/assets-path-for-assets
Import SDK assets from the `assets/` directory
2022-07-04 06:53:37 +00:00
Bruno Windels
fc93acfd8d some rewording 2022-07-01 14:09:06 +02:00
Bruno Windels
d398e490eb some rewording 2022-07-01 13:59:57 +02:00
Bruno Windels
0ab611b013 more detailed docs for IView, TemplateView and ListView 2022-07-01 13:08:50 +02:00
Bruno Windels
bb923b8eb9 bump sdk version 2022-06-30 10:54:11 +02:00
Bruno Windels
73cd96fe3a abort release script on error 2022-06-30 10:54:00 +02:00
Bruno Windels
4929839fe9 release v0.2.33 2022-06-30 10:51:11 +02:00
Eric Eastwood
c59f65e43b Add a couple consistent selectors to reference in tests
Using `data-testid` because it seems generic out of the list from:

 - https://docs.cypress.io/guides/core-concepts/cypress-app#Uniqueness
 - https://docs.cypress.io/guides/references/best-practices#How-It-Works
2022-06-29 12:56:20 +02:00
Eric Eastwood
fd3a0f0126 Merge branch 'master' into madlittlemods/assets-path-for-assets 2022-06-28 16:35:54 +02:00
Eric Eastwood
ccfd63dfeb Restore backwards compatible theme paths
See https://github.com/vector-im/hydrogen-web/pull/746#discussion_r901347536
2022-06-28 16:35:30 +02:00
Eric Eastwood
5b54280ac2
Ignore macOS metadata .DS_Store (#770) 2022-06-28 05:08:24 -05:00
Bruno Windels
bd5bf7d456
Merge pull request #761 from vector-im/hs/node-15-replaceal
Require node 15+
2022-06-25 18:22:35 +00:00
Bruno Windels
ad8ad22cc1
Merge pull request #767 from vector-im/bwindels/download-media
Menu option to download attached image or video of event
2022-06-25 18:21:17 +00:00
Bruno Windels
3369bda2f0 offer menu options to download media
also always show status (before sendStatus), not just when isPending
as we are recycling it to show download status as well
2022-06-25 20:15:33 +02:00
Bruno Windels
7430aa7aab allow download media in media view model 2022-06-25 20:14:32 +02:00
Bruno Windels
3bc453d5ca
Merge pull request #766 from vector-im/bwindels/fix-765
Also allow undefined, which means at the end of the paginated direction
2022-06-25 17:40:26 +00:00
Bruno Windels
84bac0afe9 Also allow undefined, which means at the end of the paginated direction
we already detect the end by chunk.length===0, so we just need to not throw
2022-06-25 19:37:36 +02:00
Will Hunt
9cb7d89097 Require node 15.
We use replaceAll in scripts/postcss/svg-colorizer.js which is a ES2021 feature. https://node.green/#ES2021-features--String-prototype-replaceAll
2022-06-24 13:27:09 +01:00
RMidhunSuresh
d688fa4737 Get the theme-collection id from manifest 2022-06-23 15:06:22 +05:30
RMidhunSuresh
0dfd24af22 Update info on path
path is now relative to the manifest!
2022-06-21 12:52:10 +05:30
RMidhunSuresh
34eac94da3 Make everything optional
Now typescript will force us to validate everything.
2022-06-20 21:27:02 +05:30
RMidhunSuresh
fbdd512e06 Split functions into smaller functions 2022-06-20 21:10:11 +05:30
RMidhunSuresh
5eec724712 Locations must be relative to manifest 2022-06-20 20:35:06 +05:30
RMidhunSuresh
93165cb947 runtime theme chunks should also be stored in map
There will be more than one runtime theme file when multiple theme
collections exist.
2022-06-20 13:46:14 +05:30
RMidhunSuresh
e3372f0f2b Don't use theme-name in manifest file names 2022-06-20 12:54:18 +05:30
R Midhun Suresh
5a3cf03f0b
Merge pull request #759 from vector-im/move-scope-down
Refactor out global variables in postcss plugins
2022-06-20 12:14:06 +05:30
R Midhun Suresh
c050ade03c
Merge pull request #756 from vector-im/themeing-improvement-1
Improve code quality in css-url-variables plugin
2022-06-20 11:19:47 +05:30
RMidhunSuresh
cc29dc045d Move scope down in css-url-processor 2022-06-17 16:38:13 +05:30
RMidhunSuresh
09b2437e72 Move scope of variables down in compile-variables 2022-06-17 16:35:18 +05:30
RMidhunSuresh
cfd347335b Move scope of variables down
This was causing icons to be repeated in the css-file
2022-06-16 21:29:33 +05:30
RMidhunSuresh
d322f380ad Fix typo here
This was causing the icons section to be omitted from the source section
of the manifest.
2022-06-16 21:26:16 +05:30
RMidhunSuresh
f658dc2e4b Make comment clearer 2022-06-15 15:06:16 +05:30
RMidhunSuresh
7a3eabf39c Formatting fix 2022-06-15 15:04:33 +05:30
RMidhunSuresh
48da6c782c Remove base key 2022-06-15 15:04:12 +05:30
RMidhunSuresh
b00bbc7daf Fix formatting 2022-06-15 15:03:41 +05:30
RMidhunSuresh
9fbe8a4e32 Change description of version key 2022-06-15 15:02:15 +05:30
Bruno Windels
623939c671 release v0.2.32 2022-06-15 11:29:29 +02:00
Bruno Windels
fccc41f4b9
Merge pull request #753 from vector-im/bwindels/rageshake-submit
Allow sending logs to rageshake server
2022-06-15 11:28:54 +02:00
Bruno Windels
3b66ed8c17 fix type 2022-06-15 11:24:16 +02:00
Bruno Windels
8fe8981ffa add options to send logs to server in settings ui 2022-06-15 11:14:06 +02:00
Bruno Windels
375d8b066c complete settings view model for logs ui 2022-06-15 11:13:46 +02:00
Bruno Windels
69ada73dd4 cleanup rageshake code 2022-06-15 11:13:05 +02:00
Bruno Windels
2129a97588 remove unused param 2022-06-15 11:12:49 +02:00
Bruno Windels
4caabae895 extract map -> formdata conversion and also suppor this for xhr 2022-06-15 10:15:15 +02:00
RMidhunSuresh
d0375141f8 WIP - write type for manifest 2022-06-15 12:11:15 +05:30
Bruno Windels
a644621889 basic support for sending rageshake in view model 2022-06-14 18:46:02 +02:00
Bruno Windels
4ed7e01dfd release v0.2.31 2022-06-14 16:00:35 +02:00
Bruno Windels
e643ffb334
Merge pull request #751 from vector-im/fix-theming-watch
Fix: don't crash on platforms that don't have a preferred color scheme
2022-06-14 16:00:13 +02:00
RMidhunSuresh
d00ea39dc4 No need to throw here 2022-06-14 19:27:18 +05:30
RMidhunSuresh
69d8e6031e This isn't used anywhere 2022-06-14 19:26:59 +05:30
Bruno Windels
abee9baf60 release v0.2.30 2022-06-14 10:15:00 +02:00
Bruno Windels
d4aaa8117b
Merge pull request #742 from vector-im/theme-chooser-improvements
Theme chooser improvements
2022-06-14 10:14:29 +02:00
RMidhunSuresh
be66969c9a Remove font section from manifest 2022-06-14 11:52:45 +05:30
R Midhun Suresh
7bce0d848f
Merge pull request #750 from vector-im/madlittlemods/fix-broken-hydrogen-dev
Fix Vite not being able to analyze dynamic CSS styles import in dev on Windows
2022-06-13 20:04:25 +05:30
RMidhunSuresh
53a8915ffc Parellelize code 2022-06-12 17:05:31 +05:30
RMidhunSuresh
b5fd3656a7 Fix code breaking on dev server 2022-06-12 16:53:25 +05:30
R Midhun Suresh
acffd15002
Add comment
Co-authored-by: Bruno Windels <274386+bwindels@users.noreply.github.com>
2022-06-12 16:52:21 +05:30
R Midhun Suresh
989ecd785a
Lowercase string
Co-authored-by: Bruno Windels <274386+bwindels@users.noreply.github.com>
2022-06-12 16:51:58 +05:30
RMidhunSuresh
9a5a002293 Remove test-variant 2022-06-08 13:35:58 +05:30
Eric Eastwood
2cfd08e500 Remove debug logging 2022-06-07 23:47:38 -05:00
Eric Eastwood
2b4a7f05a6 Fix Vite not being able analyze dynamic CSS styles import in dev
Fix:
```
$ yarn start
[vite] warning:
@theme/default
1  |  import "C:\Users\MLM\Documents\GitHub\element\hydrogen-web\src\platform\web\ui\css\themes\element\theme.css";import "@theme/element/light/variables.css"
   |          ^
The above dynamic import cannot be analyzed by vite.
See https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations for supported dynamic import formats. If this is intended to be left as-is, you can use the /* @vite-ignore */ comment inside the import() call to suppress this warning.

  Plugin: vite:import-analysis
  File: @theme/default
```

And in the browser, it results in none of the styles loading because of the following error:
```
Uncaught SyntaxError: Invalid Unicode escape sequence (at default:formatted:1:163)
```

---

Before:
```
import { injectQuery as __vite__injectQuery } from "/@vite/client";import "__vite__injectQuery(C:\Users\MLM\Documents\GitHub\element\hydrogen-web\src\platform\web\ui\css\themes\element\theme.css, 'import')";import "/@id/__x00__@theme/element/light/variables.css"
```

After:
```
import "/ui/css/themes/element/theme.css";import "/@id/__x00__@theme/element/light/variables.css"
```
2022-06-07 23:41:45 -05:00
RMidhunSuresh
d31f127982 Add explaining comment 2022-06-07 13:28:56 +05:30
RMidhunSuresh
d08cfe3a29 Add more logging 2022-06-07 11:57:57 +05:30
RMidhunSuresh
51a837d459 Remove unuseed import 2022-06-06 17:26:39 +05:30
RMidhunSuresh
2f0f7143b5 Simplify code 2022-06-06 17:20:36 +05:30
RMidhunSuresh
0dac00f327 themeVariant is optional 2022-06-06 17:20:16 +05:30
RMidhunSuresh
a639fc5467 Rever to sensisble defaults 2022-06-06 12:20:06 +05:30
RMidhunSuresh
258a604cc6 Don't make defaultTheme compulsory 2022-06-06 12:19:48 +05:30
RMidhunSuresh
a2cbac9e0c Move code into method 2022-06-06 11:53:13 +05:30
RMidhunSuresh
71c3fb39a2 store theme-name and variant in settings 2022-06-05 20:52:47 +05:30
RMidhunSuresh
43244fa026 Add explaining comment 2022-06-05 20:52:47 +05:30
RMidhunSuresh
9e88bc3098 Fix bugs 2022-06-05 20:52:47 +05:30
RMidhunSuresh
b74f4b612b Change UI 2022-06-05 20:52:47 +05:30
RMidhunSuresh
8de91291dd Add more methods to ThemeLoader 2022-06-05 20:52:47 +05:30
RMidhunSuresh
dc2d1ce700 Remove id 2022-06-05 20:52:47 +05:30
RMidhunSuresh
12a8e94243 Move code into ThemeLoader 2022-06-05 20:52:47 +05:30
RMidhunSuresh
9e79b632a8 Extract variable 2022-06-05 20:52:47 +05:30
RMidhunSuresh
efb1a67470 Make method name a verb 2022-06-05 20:52:47 +05:30
RMidhunSuresh
e3235ea3eb Rename themeName --> themeId 2022-06-05 20:52:47 +05:30
RMidhunSuresh
46d2792dac Modify comment 2022-06-05 20:52:47 +05:30
RMidhunSuresh
8ad0b8a726 rename themeName --> variantName 2022-06-05 20:52:47 +05:30
RMidhunSuresh
e8e4c33bae Rephrase comment 2022-06-05 20:52:47 +05:30
RMidhunSuresh
cb03e97e78 Use default theme intially 2022-06-05 20:52:47 +05:30
RMidhunSuresh
f6cec938a7 Add default theme to mapping 2022-06-05 20:52:47 +05:30
RMidhunSuresh
bbec2effe5 Add typing 2022-06-05 20:52:47 +05:30
RMidhunSuresh
d4084da299 Extract code into function 2022-06-05 20:52:47 +05:30
RMidhunSuresh
1f00c8f635 Add a temporary theme to test this PR 2022-06-05 20:52:47 +05:30
RMidhunSuresh
0b98473e85 Render a radio button for default variants 2022-06-05 20:52:47 +05:30
RMidhunSuresh
3afbe1148e Use the new built-asset format in ThemeLoader 2022-06-05 20:52:47 +05:30
RMidhunSuresh
809c522571 Change the format of built-asset 2022-06-05 20:52:47 +05:30
RMidhunSuresh
4474458f4b getActiveTheme should never return undefined
Instead it should throw an error.

This is useful for when we do setTheme(await getActiveTheme()) because
setTheme expects a string.
2022-06-05 20:52:47 +05:30
Eric Eastwood
9d8a578dce Better comment 2022-05-31 15:35:48 -05:00
Eric Eastwood
38c3774869 Import assets from the assets/ directory
> Will be easier towards the future when adding more assets. Probably best to keep style.css for now for backwards compat though.
>
> *-- https://github.com/vector-im/hydrogen-web/pull/693#discussion_r853844282*
2022-05-31 15:30:56 -05:00
Bruno Windels
8b2299852e
Merge pull request #744 from vector-im/bwindels/fix-tracker-changed-key-check
Fix: device with changed key not being properly ignored
2022-05-31 13:51:17 +02:00
Bruno Windels
c62c8da10b fix changed key not being ignored 2022-05-31 13:39:35 +02:00
Bruno Windels
bc51644868 reassignment is not used later on, remove 2022-05-31 13:39:23 +02:00
Bruno Windels
3d3d590334 add failing test for device with changed key being returned 2022-05-31 13:39:05 +02:00
Bruno Windels
11d7535c23 add some basic tests (with mock utils) for DeviceTracker 2022-05-31 13:38:34 +02:00
Bruno Windels
a49d7eae5d
Merge pull request #693 from vector-im/madlittlemods/686-682-local-friendly-development-and-commonjs
Make the SDK friendly to locally link and develop on
2022-05-30 14:45:16 +02:00
Bruno Windels
1b2a6b5d0e
Merge branch 'master' into madlittlemods/686-682-local-friendly-development-and-commonjs 2022-05-30 14:15:19 +02:00
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
Bruno Windels
ed8c98558d release v0.2.29 2022-05-18 21:45:45 +02:00
Bruno Windels
514d5c0a50
add notes about client side caching 2022-05-18 19:44:39 +00:00
Bruno Windels
13428bd03c allow updating cache of unhashed assets (like config) in service worker 2022-05-18 21:41:47 +02:00
Bruno Windels
1555b0f4bc put a message in container node when config file is not found 2022-05-18 21:41:31 +02:00
Bruno Windels
0e46aed0df rename config file to config.sample.json when packaging 2022-05-18 20:52:18 +02:00
Bruno Windels
7b0591be46 explain that push section of config usually doesn't need to be touched 2022-05-18 20:51:50 +02:00
Bruno Windels
f21e103270 add newlines to config file when rewriting with theme stuff 2022-05-18 20:46:38 +02:00
Bruno Windels
7a197c0a1a add deployment instruction now that we have a config file 2022-05-18 20:44:04 +02:00
Bruno Windels
8a5f1ed9cd Merge remote-tracking branch 'origin/move-config-root' 2022-05-18 20:40:12 +02:00
Bruno Windels
36ddd61318
Merge pull request #724 from vector-im/theme-chooser
Implement theme chooser in settings
2022-05-18 20:22:38 +02:00
Bruno Windels
03ab1ee2c7 log theme being loaded 2022-05-18 17:48:03 +02:00
RMidhunSuresh
a550788788 Remove some logging + use wrapOrRun 2022-05-18 18:56:28 +05:30
RMidhunSuresh
683ffa9ed3 injectServiceWorker plugin should accept callback 2022-05-18 17:31:17 +05:30
RMidhunSuresh
7952a34d64 Add logging 2022-05-18 16:09:09 +05:30
RMidhunSuresh
7426d17e33 Precache config and theme manifest 2022-05-18 16:07:26 +05:30
RMidhunSuresh
660a08db3e Give a better name 2022-05-18 14:41:52 +05:30
RMidhunSuresh
1b22a48b54 Treat theme-manifests the same way as config 2022-05-18 14:23:41 +05:30
Eric Eastwood
b725269c7a Clean up index.html in the right spot 2022-05-18 00:21:56 -05:00
Eric Eastwood
639358b146 Merge branch 'master' into madlittlemods/686-682-local-friendly-development-and-commonjs
Conflicts:
	scripts/sdk/base-manifest.json
2022-05-12 12:05:45 -05:00
RMidhunSuresh
34e8b60917 Create config.json in root 2022-05-12 16:05:33 +05:30
RMidhunSuresh
9ba1534390 Remove unused import 2022-05-12 16:03:06 +05:30
RMidhunSuresh
4ddfd3b508 built-asset --> built-assets 2022-05-12 14:56:58 +05:30
RMidhunSuresh
e63440527a Move condition to binding 2022-05-12 13:43:19 +05:30
RMidhunSuresh
0984aeb570 Move code to ThemeLoader 2022-05-12 13:39:57 +05:30
RMidhunSuresh
654e83a5f9 Remove method 2022-05-12 13:28:11 +05:30
RMidhunSuresh
b306344739 Add explaining comment 2022-05-12 12:55:08 +05:30
R Midhun Suresh
4231037345
Update src/platform/web/Platform.js
Co-authored-by: Bruno Windels <274386+bwindels@users.noreply.github.com>
2022-05-12 12:48:41 +05:30
R Midhun Suresh
d5bc9f5d7d
Update src/platform/web/Platform.js
Co-authored-by: Bruno Windels <274386+bwindels@users.noreply.github.com>
2022-05-12 12:48:34 +05:30
RMidhunSuresh
cc88245933 Create themeLoader only if not dev 2022-05-11 15:46:12 +05:30
RMidhunSuresh
174adc0755 Move platform dependent code to Platform 2022-05-11 15:38:37 +05:30
RMidhunSuresh
c26dc04b52 Fix type 2022-05-11 15:03:32 +05:30
RMidhunSuresh
2761789f45 Move theme code to separate file 2022-05-11 14:58:14 +05:30
RMidhunSuresh
213f87378b Use t.if instead of t.map 2022-05-11 12:46:12 +05:30
RMidhunSuresh
855298bdaf Read from manifest 2022-05-11 12:40:32 +05:30
RMidhunSuresh
e8a4ab5ecc built-asset must be a mapping
A mapping from theme-name to location of css file
2022-05-10 16:58:06 +05:30
RMidhunSuresh
5204fe5c99 This emitFile is no longer needed 2022-05-10 14:22:37 +05:30
RMidhunSuresh
c39f0d2efb Don't show theme chooser on dev 2022-05-10 14:12:36 +05:30
RMidhunSuresh
bb3368959f Use sh instead of bash 2022-05-10 14:12:36 +05:30
RMidhunSuresh
af9cbd727f Remove existing stylesheets when changing themes 2022-05-10 14:12:36 +05:30
RMidhunSuresh
12a70469eb Fix formatting 2022-05-10 14:12:36 +05:30
RMidhunSuresh
c611d3f85c Select current theme in dropdown 2022-05-10 14:12:36 +05:30
RMidhunSuresh
ecb83bb277 Store and load theme from setting 2022-05-10 14:12:36 +05:30
RMidhunSuresh
daae7442bb Create theme chooser 2022-05-10 14:12:36 +05:30
RMidhunSuresh
cc2c74fdff Generate theme summary on build 2022-05-10 14:12:36 +05:30
RMidhunSuresh
541cd96eeb Add script to cleanup after build 2022-05-10 14:12:36 +05:30
RMidhunSuresh
f16a2e5d22 Don't add asset hash to manifest json on build 2022-05-10 14:12:36 +05:30
Eric Eastwood
e54482e4c0 Add some comments 2022-05-05 17:57:25 -05:00
Eric Eastwood
75098b4712 Merge branch 'master' into madlittlemods/686-682-local-friendly-development-and-commonjs 2022-05-05 17:50:33 -05:00
Eric Eastwood
d053d4388f Update Vite to avoid flakey errors in our PostCSS plugins
Fix https://github.com/vector-im/hydrogen-web/issues/722

Updating Vite to includes fixes from
https://github.com/vitejs/vite/issues/7822 -> https://github.com/vitejs/vite/pull/7827
2022-05-05 14:58:43 -05:00
Eric Eastwood
ce289baba6 Remove extra space 2022-04-20 17:32:12 -05:00
Eric Eastwood
f1e07b6842 Explain what is being deleted by the strange syntax
See https://github.com/vector-im/hydrogen-web/pull/693#discussion_r815284713
2022-04-20 11:59:49 -05:00
Eric Eastwood
e9cee2e6a4 Merge branch 'master' into madlittlemods/686-682-local-friendly-development-and-commonjs
Conflicts:
	scripts/sdk/build.sh
2022-04-20 11:58:39 -05:00
Eric Eastwood
f56dc582a5 Fix tests after theme updates 2022-04-20 00:39:32 -05:00
Eric Eastwood
f61bf6090e Enable extended globs for removing all but some filename !(filename)
See https://github.com/vector-im/hydrogen-web/pull/693#discussion_r853534719
2022-04-19 17:28:09 -05:00
Eric Eastwood
12d6447b06 Merge branch 'master' into madlittlemods/686-682-local-friendly-development-and-commonjs
Conflicts:
	package.json
	scripts/sdk/base-manifest.json
	scripts/sdk/build.sh
2022-04-19 17:19:13 -05:00
Eric Eastwood
2401b7f453 Add way to test whether SDK works in ESM and CommonJS 2022-04-05 19:24:27 -05:00
Eric Eastwood
dd06d78a72 Avoid ERR_REQUIRE_ESM errors when requiring SDK 2022-04-05 18:17:14 -05:00
Eric Eastwood
95d17303c3 Update Vite which includes fixes to importing *.js?url with exports
Update to Vite which includes https://github.com/vitejs/vite/pull/7098
2022-04-05 17:16:55 -05:00
Eric Eastwood
d247bc4e28 Merge branch 'master' into madlittlemods/686-682-local-friendly-development-and-commonjs
Conflicts:
	package.json
	scripts/sdk/base-manifest.json
2022-04-05 17:15:30 -05:00
Eric Eastwood
0023ab34ba Add a placeholder for upgrading vite to comment on 2022-02-26 05:19:59 -06:00
Eric Eastwood
8fb2b2755a Fix typos pointing to wrong files 2022-02-26 03:08:16 -06:00
Eric Eastwood
cd007b40e1 Make the SDK friendly to locally link and develop on
Fix https://github.com/vector-im/hydrogen-web/issues/686
Fix https://github.com/vector-im/hydrogen-web/issues/682

Instead of deleting the whole `target/` directory, leave it alone so the symlink
driving the `npm link`/`yarn link` stays in tact.

Leave Vite builds in their build directories (`/lib-build`/`/asset-build`)
so you can `vite build --watch` to build on local changes and still have a
consisent place to reference in the `package.json` `exports`. Previously,
everything relied on `build.sh` which does a bunch of moving and renaming
and made it hard to rebuild on changes.

Add back support for CommonJS (adding the `package.json` `exports`).

The last piece is making sure the `?url` imports (`import workerPath from 'hydrogen-view-sdk/main.js?url';`)
work still. It looks like this may have just been solved via
https://github.com/vitejs/vite/issues/6725 -> https://github.com/vitejs/vite/pull/7073
(literally 2 days ago) and we just need to wait for the next Vite release 🎉
2022-02-26 01:12:00 -06:00
173 changed files with 5074 additions and 5954 deletions

View file

@ -17,7 +17,6 @@ module.exports = {
"globals": {
"DEFINE_VERSION": "readonly",
"DEFINE_GLOBAL_HASH": "readonly",
"DEFINE_PROJECT_DIR": "readonly",
// only available in sw.js
"DEFINE_UNHASHED_PRECACHED_ASSETS": "readonly",
"DEFINE_HASHED_PRECACHED_ASSETS": "readonly",

2
.gitignore vendored
View file

@ -1,5 +1,6 @@
*.sublime-project
*.sublime-workspace
.DS_Store
node_modules
fetchlogs
sessionexports
@ -9,3 +10,4 @@ lib
*.tar.gz
.eslintcache
.tmp
tmp/

View file

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

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
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.
@ -10,13 +12,34 @@ Hydrogen's goals are:
- It is a standalone webapp, but can also be easily embedded into an existing website/webapp to add chat capabilities.
- Loading (unused) parts of the application after initial page load should be supported
For embedded usage, see the [SDK instructions](doc/SDK.md).
If you find this interesting, come and discuss on [`#hydrogen:matrix.org`](https://matrix.to/#/#hydrogen:matrix.org).
# How to use
Hydrogen is deployed to [hydrogen.element.io](https://hydrogen.element.io). You can run it locally `yarn install` (only the first time) and `yarn start` in the terminal, and point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md).
Hydrogen is deployed to [hydrogen.element.io](https://hydrogen.element.io). You can also deploy Hydrogen on your own web server:
Hydrogen uses symbolic links in the codebase, so if you are on Windows, have a look at [making git & symlinks work](https://github.com/git-for-windows/git/wiki/Symbolic-Links) there.
1. Download the [latest release package](https://github.com/vector-im/hydrogen-web/releases).
1. Extract the package to the public directory of your web server.
1. If this is your first deploy:
1. copy `config.sample.json` to `config.json` and if needed, make any modifications (unless you've set up your own [sygnal](https://github.com/matrix-org/sygnal) instance, you don't need to change anything in the `push` section).
1. Disable caching entirely on the server for:
- `index.html`
- `sw.js`
- `config.json`
- All theme manifests referenced in the `themeManifests` of `config.json`, these files are typically called `theme-{name}.json`.
These resources will still be cached client-side by the service worker. Because of this; you'll still need to refresh the app twice before config.json changes are applied.
## Set up a dev environment
You can run Hydrogen locally by the following commands in the terminal:
- `yarn install` (only the first time)
- `yarn start` in the terminal
Now point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md).
# FAQ

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

@ -48,8 +48,8 @@ const assetPaths = {
wasmBundle: olmJsPath
}
};
import "hydrogen-view-sdk/theme-element-light.css";
// OR import "hydrogen-view-sdk/theme-element-dark.css";
import "hydrogen-view-sdk/assets/theme-element-light.css";
// OR import "hydrogen-view-sdk/assets/theme-element-dark.css";
async function main() {
const app = document.querySelector<HTMLDivElement>('#app')!

204
doc/THEMING.md Normal file
View file

@ -0,0 +1,204 @@
# Theming Documentation
## Basic Architecture
A **theme collection** in Hydrogen is represented by a `manifest.json` file and a `theme.css` file.
The manifest specifies variants (eg: dark,light ...) each of which is a **theme** and maps to a single css file in the build output.
Each such theme is produced by changing the values of variables in the base `theme.css` file with those specified in the variant section of the manifest:
![](images/theming-architecture.png)
More in depth explanations can be found in later sections.
## Structure of `manifest.json`
[See theme.ts](../src/platform/types/theme.ts)
## Variables
CSS variables specific to a particular variant are specified in the `variants` section of the manifest:
```json=
"variants": {
"light": {
...
"variables": {
"background-color-primary": "#fff",
"text-color": "#2E2F32",
}
},
"dark": {
...
"variables": {
"background-color-primary": "#21262b",
"text-color": "#fff",
}
}
}
```
These variables will appear in the css file (theme.css):
```css=
body {
background-color: var(--background-color-primary);
color: var(--text-color);
}
```
During the build process, this would result in the creation of two css files (one for each variant) where the variables are substitued with the corresponding values specified in the manifest:
*element-light.css*:
```css=
body {
background-color: #fff;
color: #2E2F32;
}
```
*element-dark.css*:
```css=
body {
background-color: #21262b;
color: #fff;
}
```
## Derived Variables
In addition to simple substitution of variables in the stylesheet, it is also possible to instruct the build system to first produce a new value from the base variable value before the substitution.
Such derived variables have the form `base_css_variable--operation-arg` and can be read as:
apply `operation` to `base_css_variable` with argument `arg`.
Continuing with the previous example, it possible to specify:
```css=
.left-panel {
/* background color should be 20% more darker
than background-color-primary */
background-color: var(--background-color-primary--darker-20);
}
```
Currently supported operations are:
| Operation | Argument | Operates On |
| -------- | -------- | -------- |
| darker | percentage | color |
| lighter | percentage | color |
## Aliases
It is possible give aliases to variables in the `theme.css` file:
```css=
:root {
font-size: 10px;
/* Theme aliases */
--icon-color: var(--background-color-secondary--darker-40);
}
```
It is possible to further derive from these aliased variables:
```css=
div {
background: var(--icon-color--darker-20);
--my-alias: var(--icon-color--darker-20);
/* Derive from aliased variable */
color: var(--my-alias--lighter-15);
}
```
## Colorizing svgs
Along with a change in color-scheme, it may be necessary to change the colors in the svg icons and images.
This can be done by supplying the preferred colors with query parameters:
`my-awesome-logo.svg?primary=base-variable-1&secondary=base-variable-2`
This instructs the build system to colorize the svg with the given primary and secondary colors.
`base-variable-1` and `base-variable-2` are the css-variables specified in the `variables` section of the manifest.
For colorizing svgs, the source svg must use `#ff00ff` as the primary color and `#00ffff` as the secondary color:
| ![](images/svg-icon-example.png) | ![](images/coloring-process.png) |
| :--: |:--: |
| **original source image** | **transformation process** |
## Creating your own theme variant in Hydrogen
If you're looking to change the color-scheme of the existing Element theme, you only need to add your own variant to the existing `manifest.json`.
The steps are fairly simple:
1. Copy over an existing variant to the variants section of the manifest.
2. Change `dark`, `default` and `name` fields.
3. Give new values to each variable in the `variables` section.
4. Build hydrogen.
## Creating your own theme collection in Hydrogen
If a theme variant does not solve your needs, you can create a new theme collection with a different base `theme.css` file.
1. Create a directory for your new theme-collection under `src/platform/web/ui/css/themes/`.
2. Create `manifest.json` and `theme.css` files within the newly created directory.
3. Populate `manifest.json` with the base css variables you wish to use.
4. Write styles in your `theme.css` file using the base variables, derived variables and colorized svg icons.
5. Tell the build system where to find this theme-collection by providing the location of this directory to the `themeBuilder` plugin in `vite.config.js`:
```json=
...
themeBuilder({
themeConfig: {
themes: {
element: "./src/platform/web/ui/css/themes/element",
awesome: "path/to/theme-directory"
},
default: "element",
},
compiledVariables,
}),
...
```
6. Build Hydrogen.
## Changing the default theme
To change the default theme used in Hydrogen, modify the `defaultTheme` field in `config.json` file (which can be found in the build output):
```json=
"defaultTheme": {
"light": theme-id,
"dark": theme-id
}
```
Here *theme-id* is of the form `theme-variant` where `theme` is the key used when specifying the manifest location of the theme collection in `vite.config.js` and `variant` is the key used in variants section of the manifest.
Some examples of theme-ids are `element-dark` and `element-light`.
To find the theme-id of some theme, you can look at the built-asset section of the manifest in the build output.
This default theme will render as "Default" option in the theme-chooser dropdown. If the device preference is for dark theme, the dark default is selected and vice versa.
**You'll need to reload twice so that Hydrogen picks up the config changes!**
# Derived Theme(Collection)
This allows users to theme Hydrogen without the need for rebuilding. Derived theme collections can be thought of as extensions (derivations) of some existing build time theme.
## Creating a derived theme:
Here's how you create a new derived theme:
1. You create a new theme manifest file (eg: theme-awesome.json) and mention which build time theme you're basing your new theme on using the `extends` field. The base css file of the mentioned theme is used for your new theme.
2. You configure the theme manifest as usual by populating the `variants` field with your desired colors.
3. You add your new theme manifest to the list of themes in `config.json`.
Refresh Hydrogen twice (once to refresh cache, and once to load) and the new theme should show up in the theme chooser.
## How does it work?
For every theme collection in hydrogen, the build process emits a runtime css file which like the built theme css file contains variables in the css code. But unlike the theme css file, the runtime css file lacks the definition for these variables:
CSS for the built theme:
```css
:root {
--background-color-primary: #f2f20f;
}
body {
background-color: var(--background-color-primary);
}
```
and the corresponding runtime theme:
```css
/* Notice the lack of definiton for --background-color-primary here! */
body {
background-color: var(--background-color-primary);
}
```
When hydrogen loads a derived theme, it takes the runtime css file of the extended theme and dynamically adds the variable definition based on the values specified in the manifest. Icons are also colored dynamically and injected as variables using Data URIs.

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

@ -0,0 +1,206 @@
## IView components
The [interface](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/types.ts) adopted by view components is agnostic of how they are rendered to the DOM. This has several benefits:
- it allows Hydrogen to not ship a [heavy view framework](https://bundlephobia.com/package/react-dom@18.2.0) that may or may not be used by its SDK users, and also keep bundle size of the app down.
- Given the interface is quite simple, is should be easy to integrate this interface into the render lifecycle of other frameworks.
- The main implementations used in Hydrogen are [`ListView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/ListView.ts) (rendering [`ObservableList`](https://github.com/vector-im/hydrogen-web/blob/master/src/observable/list/BaseObservableList.ts)s) and [`TemplateView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/TemplateView.ts) (templating and one-way databinding), each only a few 100 lines of code and tailored towards their specific use-case. They work straight with the DOM API and have no other dependencies.
- a common inteface allows us to mix and match between these different implementations (and gradually shift if need be in the future) with the code.
## Templates
### Template language
Templates use a mini-DSL language in pure javascript to express declarative templates. This is basically a very thin wrapper around `document.createElement`, `document.createTextNode`, `node.setAttribute` and `node.appendChild` to quickly create DOM trees. The general syntax is as follows:
```js
t.tag_name({attribute1: value, attribute2: value, ...}, [child_elements]);
t.tag_name(child_element);
t.tag_name([child_elements]);
```
**tag_name** can be [most HTML or SVG tags](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/html.ts#L102-L110).
eg:
Here is an example HTML segment followed with the code to create it in Hydrogen.
```html
<section class="main-section">
<h1>Demo</h1>
<button class="btn_cool">Click me</button>
</section>
```
```js
t.section({className: "main-section"},[
t.h1("Demo"),
t.button({className:"btn_cool"}, "Click me")
]);
```
All these functions return DOM element nodes, e.g. the result of `document.createElement`.
### TemplateView
`TemplateView` builds on top of templating by adopting the IView component model and adding event handling attributes, sub views and one-way databinding.
In views based on `TemplateView`, you will see a render method with a `t` argument.
`t` is `TemplateBuilder` object passed to the render function in `TemplateView`. It also takes a data object to render and bind to, often called `vm`, short for view model from the MVVM pattern Hydrogen uses.
You either subclass `TemplateView` and override the `render` method:
```js
class MyView extends TemplateView {
render(t, vm) {
return t.div(...);
}
}
```
Or you pass a render function to `InlineTemplateView`:
```js
new InlineTemplateView(vm, (t, vm) => {
return t.div(...);
});
```
**Note:** the render function is only called once to build the initial DOM tree and setup bindings, etc ... Any subsequent updates to the DOM of a component happens through bindings.
#### Event handlers
Any attribute starting with `on` and having a function as a value will be attached as an event listener on the given node. The event handler will be removed during unmounting.
```js
t.button({onClick: evt => {
vm.doSomething(evt.target.value);
}}, "Click me");
```
#### Subviews
`t.view(instance)` will mount the sub view (can be any IView) and return its root node so it can be attached in the DOM tree.
All subviews will be unmounted when the parent view gets unmounted.
```js
t.div({className: "Container"}, t.view(new ChildView(vm.childViewModel)));
```
#### One-way data-binding
A binding couples a part of the DOM to a value on the view model. The view model emits an update when any of its properties change, to which the view can subscribe. When an update is received by the view, it will reevaluate all the bindings, and update the DOM accordingly.
A binding can appear in many places where a static value can usually be used in the template tree.
To create a binding, you pass a function that maps the view value to a static value.
##### Text binding
```js
t.p(["I've got ", vm => vm.counter, " beans"])
```
##### Attribute binding
```js
t.button({disabled: vm => vm.isBusy}, "Submit");
```
##### Class-name binding
```js
t.div({className: {
button: true,
active: vm => vm.isActive
}})
```
##### Subview binding
So far, all the bindings can only change node values within our tree, but don't change the structure of the DOM. A sub view binding allows you to conditionally add a subview based on the result of a binding function.
All sub view bindings return a DOM (element or comment) node and can be directly added to the DOM tree by including them in your template.
###### map
`t.mapView` allows you to choose a view based on the result of the binding function:
```js
t.mapView(vm => vm.count, count => {
return count > 5 ? new LargeView(count) : new SmallView(count);
});
```
Every time the first or binding function returns a different value, the second function is run to create a new view to replace the previous view.
You can also return `null` or `undefined` from the second function to indicate a view should not be rendered. In this case a comment node will be used as a placeholder.
There is also a `t.map` which will create a new template view (with the same value) and you directly provide a render function for it:
```js
t.map(vm => vm.shape, (shape, t, vm) => {
switch (shape) {
case "rect": return t.rect();
case "circle": return t.circle();
}
})
```
###### if
`t.ifView` will render the subview if the binding returns a truthy value:
```js
t.ifView(vm => vm.isActive, vm => new View(vm.someValue));
```
You equally have `t.if`, which creates a `TemplateView` and passes you the `TemplateBuilder`:
```js
t.if(vm => vm.isActive, (t, vm) => t.div("active!"));
```
##### Side-effects
Sometimes you want to imperatively modify your DOM tree based on the value of a binding.
`mapSideEffect` makes this easy to do:
```js
let node = t.div();
t.mapSideEffect(vm => vm.color, (color, oldColor) => node.style.background = color);
return node;
```
**Note:** you shouldn't add any bindings, subviews or event handlers from the side-effect callback,
the safest is to not use the `t` argument at all.
If you do, they will be added every time the callback is run and only cleaned up when the view is unmounted.
#### `tag` vs `t`
If you don't need a view component with data-binding, sub views and event handler attributes, the template language also is available in `ui/general/html.js` without any of these bells and whistles, exported as `tag`. As opposed to static templates with `tag`, you always use
`TemplateView` as an instance of a class, as there is some extra state to keep track (bindings, event handlers and subviews).
Although syntactically similar, `TemplateBuilder` and `tag` are not functionally equivalent.
Primarily `t` **supports** bindings and event handlers while `tag` **does not**. This is because to remove event listeners, we need to keep track of them, and thus we need to keep this state somewhere which
we can't do with a simple function call but we can insite the TemplateView class.
```js
// The onClick here wont work!!
tag.button({className:"awesome-btn", onClick: () => this.foo()});
class MyView extends TemplateView {
render(t, vm){
// The onClick works here.
t.button({className:"awesome-btn", onClick: () => this.foo()});
}
}
```
## ListView
A view component that renders and updates a list of sub views for every item in a `ObservableList`.
```js
const list = new ListView({
list: someObservableList
}, listValue => return new ChildView(listValue))
```
As items are added, removed, moved (change position) and updated, the DOM will be kept in sync.
There is also a `LazyListView` that only renders items in and around the current viewport, with the restriction that all items in the list must be rendered with the same height.
### Sub view updates
Unless the `parentProvidesUpdates` option in the constructor is set to `false`, the ListView will call the `update` method on the child `IView` component when it receives an update event for one of the items in the `ObservableList`.
This way, not every sub view has to have an individual listener on it's view model (a value from the observable list), and all updates go from the observable list to the list view, who then notifies the correct sub view.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,19 +1,24 @@
{
"name": "hydrogen-web",
"version": "0.2.28",
"version": "0.3.1",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"directories": {
"doc": "doc"
},
"enginesStrict": {
"node": ">=15"
},
"scripts": {
"lint": "eslint --cache src/",
"lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts",
"lint-ci": "eslint src/",
"test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/",
"test:postcss": "impunity --entry-point scripts/postcss/tests/css-compile-variables.test.js scripts/postcss/tests/css-url-to-variables.test.js",
"test:sdk": "yarn build:sdk && cd ./scripts/sdk/test/ && yarn --no-lockfile && node test-sdk-in-esm-vite-build-env.js && node test-sdk-in-commonjs-env.js",
"start": "vite --port 3000",
"build": "vite build",
"build:sdk": "./scripts/sdk/build.sh"
"build": "vite build && ./scripts/cleanup.sh",
"build:sdk": "./scripts/sdk/build.sh",
"watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch"
},
"repository": {
"type": "git",
@ -26,7 +31,6 @@
},
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
"devDependencies": {
"@matrixdotorg/structured-logviewer": "^0.0.1",
"@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2",
"acorn": "^8.6.0",
@ -46,13 +50,14 @@
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-value-parser": "^4.2.0",
"regenerator-runtime": "^0.13.7",
"svgo": "^2.8.0",
"text-encoding": "^0.7.0",
"typescript": "^4.4",
"vite": "^2.6.14",
"typescript": "^4.7.0",
"vite": "^2.9.8",
"xxhashjs": "^0.2.2"
},
"dependencies": {
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"another-json": "^0.2.0",
"base64-arraybuffer": "^0.2.0",
"dompurify": "^2.3.0",

View file

@ -13,11 +13,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const path = require('path');
const path = require('path').posix;
const {optimize} = require('svgo');
async function readCSSSource(location) {
const fs = require("fs").promises;
const path = require("path");
const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`);
const data = await fs.readFile(resolvedLocation);
return data;
@ -31,29 +31,66 @@ function appendVariablesToCSS(variables, cssSource) {
return cssSource + getRootSectionWithVariables(variables);
}
function parseBundle(bundle) {
const chunkMap = new Map();
const assetMap = new Map();
let runtimeThemeChunk;
function addThemesToConfig(bundle, manifestLocations, defaultThemes) {
for (const [fileName, info] of Object.entries(bundle)) {
if (!fileName.endsWith(".css")) {
continue;
if (fileName === "config.json") {
const source = new TextDecoder().decode(info.source);
const config = JSON.parse(source);
config["themeManifests"] = manifestLocations;
config["defaultTheme"] = defaultThemes;
info.source = new TextEncoder().encode(JSON.stringify(config, undefined, 2));
}
if (info.type === "asset") {
/**
* So this is the css assetInfo that contains the asset hashed file name.
* We'll store it in a separate map indexed via fileName (unhashed) to avoid
* searching through the bundle array later.
*/
assetMap.set(info.name, info);
continue;
}
if (info.facadeModuleId?.includes("type=runtime")) {
/**
* We have a separate field in manifest.source just for the runtime theme,
* so store this separately.
*/
runtimeThemeChunk = info;
}
}
/**
* Returns an object where keys are the svg file names and the values
* are the svg code (optimized)
* @param {*} icons Object where keys are css variable names and values are locations of the svg
* @param {*} manifestLocation Location of manifest used for resolving path
*/
async function generateIconSourceMap(icons, manifestLocation) {
const sources = {};
const fileNames = [];
const promises = [];
const fs = require("fs").promises;
for (const icon of Object.values(icons)) {
const [location] = icon.split("?");
// resolve location against manifestLocation
const resolvedLocation = path.resolve(manifestLocation, location);
const iconData = fs.readFile(resolvedLocation);
promises.push(iconData);
const fileName = path.basename(resolvedLocation);
fileNames.push(fileName);
}
const results = await Promise.all(promises);
for (let i = 0; i < results.length; ++i) {
const svgString = results[i].toString();
const result = optimize(svgString, {
plugins: [
{
name: "preset-default",
params: {
overrides: { convertColors: false, },
},
},
],
});
const optimizedSvgString = result.data;
sources[fileNames[i]] = optimizedSvgString;
}
return sources;
}
/**
* Returns a mapping from location (of manifest file) to an array containing all the chunks (of css files) generated from that location.
* To understand what chunk means in this context, see https://rollupjs.org/guide/en/#generatebundle.
* @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo
*/
function getMappingFromLocationToChunkArray(bundle) {
const chunkMap = new Map();
for (const [fileName, info] of Object.entries(bundle)) {
if (!fileName.endsWith(".css") || info.type === "asset" || info.facadeModuleId?.includes("type=runtime")) {
continue;
}
const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1];
@ -68,14 +105,64 @@ function parseBundle(bundle) {
array.push(info);
}
}
return { chunkMap, assetMap, runtimeThemeChunk };
return chunkMap;
}
/**
* Returns a mapping from unhashed file name (of css files) to AssetInfo.
* To understand what AssetInfo means in this context, see https://rollupjs.org/guide/en/#generatebundle.
* @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo
*/
function getMappingFromFileNameToAssetInfo(bundle) {
const assetMap = new Map();
for (const [fileName, info] of Object.entries(bundle)) {
if (!fileName.endsWith(".css")) {
continue;
}
if (info.type === "asset") {
/**
* So this is the css assetInfo that contains the asset hashed file name.
* We'll store it in a separate map indexed via fileName (unhashed) to avoid
* searching through the bundle array later.
*/
assetMap.set(info.name, info);
}
}
return assetMap;
}
/**
* Returns a mapping from location (of manifest file) to ChunkInfo of the runtime css asset
* To understand what ChunkInfo means in this context, see https://rollupjs.org/guide/en/#generatebundle.
* @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo
*/
function getMappingFromLocationToRuntimeChunk(bundle) {
let runtimeThemeChunkMap = new Map();
for (const [fileName, info] of Object.entries(bundle)) {
if (!fileName.endsWith(".css") || info.type === "asset") {
continue;
}
const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1];
if (!location) {
throw new Error("Cannot find location of css chunk!");
}
if (info.facadeModuleId?.includes("type=runtime")) {
/**
* We have a separate field in manifest.source just for the runtime theme,
* so store this separately.
*/
runtimeThemeChunkMap.set(location, info);
}
}
return runtimeThemeChunkMap;
}
module.exports = function buildThemes(options) {
let manifest, variants, defaultDark, defaultLight;
let manifest, variants, defaultDark, defaultLight, defaultThemes = {};
let isDevelopment = false;
const virtualModuleId = '@theme/'
const resolvedVirtualModuleId = '\0' + virtualModuleId;
const themeToManifestLocation = new Map();
return {
name: "build-themes",
@ -88,35 +175,34 @@ module.exports = function buildThemes(options) {
},
async buildStart() {
if (isDevelopment) { return; }
const { themeConfig } = options;
for (const [name, location] of Object.entries(themeConfig.themes)) {
for (const location of themeConfig.themes) {
manifest = require(`${location}/manifest.json`);
const themeCollectionId = manifest.id;
themeToManifestLocation.set(themeCollectionId, location);
variants = manifest.values.variants;
for (const [variant, details] of Object.entries(variants)) {
const fileName = `theme-${name}-${variant}.css`;
if (name === themeConfig.default && details.default) {
const fileName = `theme-${themeCollectionId}-${variant}.css`;
if (themeCollectionId === themeConfig.default && details.default) {
// This is the default theme, stash the file name for later
if (details.dark) {
defaultDark = fileName;
defaultThemes["dark"] = `${themeCollectionId}-${variant}`;
}
else {
defaultLight = fileName;
defaultThemes["light"] = `${themeCollectionId}-${variant}`;
}
}
// emit the css as built theme bundle
this.emitFile({
type: "chunk",
id: `${location}/theme.css?variant=${variant}${details.dark? "&dark=true": ""}`,
fileName,
});
if (!isDevelopment) {
this.emitFile({ type: "chunk", id: `${location}/theme.css?variant=${variant}${details.dark ? "&dark=true" : ""}`, fileName, });
}
}
// emit the css as runtime theme bundle
this.emitFile({
type: "chunk",
id: `${location}/theme.css?type=runtime`,
fileName: `theme-${name}-runtime.css`,
});
if (!isDevelopment) {
this.emitFile({ type: "chunk", id: `${location}/theme.css?type=runtime`, fileName: `theme-${themeCollectionId}-runtime.css`, });
}
}
},
@ -138,7 +224,7 @@ module.exports = function buildThemes(options) {
if (theme === "default") {
theme = options.themeConfig.default;
}
const location = options.themeConfig.themes[theme];
const location = themeToManifestLocation.get(theme);
const manifest = require(`${location}/manifest.json`);
const variants = manifest.values.variants;
if (!variant || variant === "default") {
@ -215,6 +301,7 @@ module.exports = function buildThemes(options) {
type: "text/css",
media: "(prefers-color-scheme: dark)",
href: `./${darkThemeLocation}`,
class: "theme",
}
},
{
@ -224,31 +311,66 @@ module.exports = function buildThemes(options) {
type: "text/css",
media: "(prefers-color-scheme: light)",
href: `./${lightThemeLocation}`,
class: "theme",
}
},
];
},
generateBundle(_, bundle) {
const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle);
async generateBundle(_, bundle) {
const assetMap = getMappingFromFileNameToAssetInfo(bundle);
const chunkMap = getMappingFromLocationToChunkArray(bundle);
const runtimeThemeChunkMap = getMappingFromLocationToRuntimeChunk(bundle);
const manifestLocations = [];
// Location of the directory containing manifest relative to the root of the build output
const manifestLocation = "assets";
for (const [location, chunkArray] of chunkMap) {
const manifest = require(`${location}/manifest.json`);
const compiledVariables = options.compiledVariables.get(location);
const derivedVariables = compiledVariables["derived-variables"];
const icon = compiledVariables["icon"];
const builtAssets = {};
let themeKey;
for (const chunk of chunkArray) {
const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/);
themeKey = name;
const locationRelativeToBuildRoot = assetMap.get(chunk.fileName).fileName;
const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot);
builtAssets[`${name}-${variant}`] = locationRelativeToManifest;
}
// Emit the base svg icons as asset
const nameToAssetHashedLocation = [];
const nameToSource = await generateIconSourceMap(icon, location);
for (const [name, source] of Object.entries(nameToSource)) {
const ref = this.emitFile({ type: "asset", name, source });
const assetHashedName = this.getFileName(ref);
nameToAssetHashedLocation[name] = assetHashedName;
}
// Update icon section in output manifest with paths to the icon in build output
for (const [variable, location] of Object.entries(icon)) {
const [locationWithoutQueryParameters, queryParameters] = location.split("?");
const name = path.basename(locationWithoutQueryParameters);
const locationRelativeToBuildRoot = nameToAssetHashedLocation[name];
const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot);
icon[variable] = `${locationRelativeToManifest}?${queryParameters}`;
}
const runtimeThemeChunk = runtimeThemeChunkMap.get(location);
const runtimeAssetLocation = path.relative(manifestLocation, assetMap.get(runtimeThemeChunk.fileName).fileName);
manifest.source = {
"built-asset": chunkArray.map(chunk => assetMap.get(chunk.fileName).fileName),
"runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName,
"built-assets": builtAssets,
"runtime-asset": runtimeAssetLocation,
"derived-variables": derivedVariables,
"icon": icon
"icon": icon,
};
const name = `theme-${manifest.name}.json`;
const name = `theme-${themeKey}.json`;
manifestLocations.push(`${manifestLocation}/${name}`);
this.emitFile({
type: "asset",
name,
source: JSON.stringify(manifest),
});
}
addThemesToConfig(bundle, manifestLocations, defaultThemes);
},
}
}

View file

@ -8,7 +8,7 @@ function contentHash(str) {
return hasher.digest();
}
function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
function injectServiceWorker(swFile, findUnhashedFileNamesFromBundle, placeholdersPerChunk) {
const swName = path.basename(swFile);
let root;
let version;
@ -31,6 +31,7 @@ function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
logger = config.logger;
},
generateBundle: async function(options, bundle) {
const otherUnhashedFiles = findUnhashedFileNamesFromBundle(bundle);
const unhashedFilenames = [swName].concat(otherUnhashedFiles);
const unhashedFileContentMap = unhashedFilenames.reduce((map, fileName) => {
const chunkOrAsset = bundle[fileName];

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

3
scripts/cleanup.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
# Remove icons created in .tmp
rm -rf .tmp

51
scripts/logviewer/file.js Normal file
View file

@ -0,0 +1,51 @@
/*
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 function openFile(mimeType = null) {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.className = "hidden";
if (mimeType) {
input.setAttribute("accept", mimeType);
}
const promise = new Promise((resolve, reject) => {
const checkFile = () => {
input.removeEventListener("change", checkFile, true);
const file = input.files[0];
document.body.removeChild(input);
if (file) {
resolve(file);
} else {
reject(new Error("no file picked"));
}
}
input.addEventListener("change", checkFile, true);
});
// IE11 needs the input to be attached to the document
document.body.appendChild(input);
input.click();
return promise;
}
export function readFileAsText(file) {
const reader = new FileReader();
const promise = new Promise((resolve, reject) => {
reader.addEventListener("load", evt => resolve(evt.target.result));
reader.addEventListener("error", evt => reject(evt.target.error));
});
reader.readAsText(file);
return promise;
}

110
scripts/logviewer/html.js Normal file
View file

@ -0,0 +1,110 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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.
*/
// DOM helper functions
export function isChildren(children) {
// children should be an not-object (that's the attributes), or a domnode, or an array
return typeof children !== "object" || !!children.nodeType || Array.isArray(children);
}
export function classNames(obj, value) {
return Object.entries(obj).reduce((cn, [name, enabled]) => {
if (typeof enabled === "function") {
enabled = enabled(value);
}
if (enabled) {
return cn + (cn.length ? " " : "") + name;
} else {
return cn;
}
}, "");
}
export function setAttribute(el, name, value) {
if (name === "className") {
name = "class";
}
if (value === false) {
el.removeAttribute(name);
} else {
if (value === true) {
value = name;
}
el.setAttribute(name, value);
}
}
export function el(elementName, attributes, children) {
return elNS(HTML_NS, elementName, attributes, children);
}
export function elNS(ns, elementName, attributes, children) {
if (attributes && isChildren(attributes)) {
children = attributes;
attributes = null;
}
const e = document.createElementNS(ns, elementName);
if (attributes) {
for (let [name, value] of Object.entries(attributes)) {
if (name === "className" && typeof value === "object" && value !== null) {
value = classNames(value);
}
setAttribute(e, name, value);
}
}
if (children) {
if (!Array.isArray(children)) {
children = [children];
}
for (let c of children) {
if (!c.nodeType) {
c = text(c);
}
e.appendChild(c);
}
}
return e;
}
export function text(str) {
return document.createTextNode(str);
}
export const HTML_NS = "http://www.w3.org/1999/xhtml";
export const SVG_NS = "http://www.w3.org/2000/svg";
export const TAG_NAMES = {
[HTML_NS]: [
"br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
"p", "strong", "em", "span", "img", "section", "main", "article", "aside",
"pre", "button", "time", "input", "textarea", "label", "form", "progress", "output"],
[SVG_NS]: ["svg", "circle"]
};
export const tag = {};
for (const [ns, tags] of Object.entries(TAG_NAMES)) {
for (const tagName of tags) {
tag[tagName] = function(attributes, children) {
return elNS(ns, tagName, attributes, children);
}
}
}

View file

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
html, body {
height: 100%;
}
body {
font-family: sans-serif;
font-size: 1rem;
margin: 0;
display: grid;
grid-template-areas: "nav nav" "items details";
grid-template-columns: 1fr 400px;
grid-template-rows: auto 1fr;
min-height: 0;
}
main {
grid-area: items;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 8px;
}
main section h2 {
margin: 2px 14px;
font-size: 1rem;
}
aside {
grid-area: details;
padding: 8px;
}
aside h3 {
word-wrap: anywhere;
}
aside p {
margin: 2px 0;
}
aside .values li span {
word-wrap: ;
word-wrap: anywhere;
padding: 4px;
}
aside .values {
list-style: none;
padding: 0;
border: 1px solid lightgray;
}
aside .values span.key {
width: 30%;
display: block;
}
aside .values span.value {
width: 70%;
display: block;
white-space: pre-wrap;
}
aside .values li {
display: flex;
}
aside .values li:not(:first-child) {
border-top: 1px solid lightgray;
}
nav {
grid-area: nav;
}
.timeline li:not(.expanded) > ol {
display: none;
}
.timeline li > div {
display: flex;
}
.timeline .toggleExpanded {
border: none;
background: none;
width: 24px;
height: 24px;
margin-right: 4px;
cursor: pointer;
}
.timeline .toggleExpanded:before {
content: "▶";
}
.timeline li.expanded > div > .toggleExpanded:before {
content: "▼";
}
.timeline ol {
list-style: none;
padding: 0 0 0 20px;
margin: 0;
}
.timeline .item {
--hue: 100deg;
--brightness: 80%;
background-color: hsl(var(--hue), 60%, var(--brightness));
border: 1px solid hsl(var(--hue), 60%, calc(var(--brightness) - 40%));
border-radius: 4px;
padding: 2px;
display: flex;
margin: 1px;
flex: 1;
min-width: 0;
color: inherit;
text-decoration: none;
}
.timeline .item:not(.has-children) {
margin-left: calc(24px + 4px + 1px);
}
.timeline .item .caption {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
}
.timeline .item.level-3 {
--brightness: 90%;
}
.timeline .item.level-2 {
--brightness: 95%;
}
.timeline .item.level-5 {
--brightness: 80%;
}
.timeline .item.level-6, .timeline .item.level-7 {
--hue: 0deg !important;
}
.timeline .item.level-7 {
--brightness: 50%;
color: white;
}
.timeline .item.type-network {
--hue: 30deg;
}
.timeline .item.type-navigation {
--hue: 200deg;
}
.timeline .item.selected {
background-color: Highlight;
border-color: Highlight;
color: HighlightText;
}
.timeline .item.highlighted {
background-color: fuchsia;
color: white;
}
.hidden {
display: none;
}
#highlight {
width: 300px;
}
nav form {
display: inline;
}
</style>
</head>
<body>
<nav>
<button id="openFile">Open log file</button>
<button id="collapseAll">Collapse all</button>
<button id="hideCollapsed">Hide collapsed root items</button>
<button id="hideHighlightedSiblings" title="Hide collapsed siblings of highlighted">Hide non-highlighted</button>
<button id="showAll">Show all</button>
<form id="highlightForm">
<input type="text" id="highlight" name="highlight" placeholder="Highlight a search term" autocomplete="on">
<output id="highlightMatches"></output>
</form>
</nav>
<main></main>
<aside></aside>
<script type="module" src="main.js"></script>
</body>
</html>

398
scripts/logviewer/main.js Normal file
View file

@ -0,0 +1,398 @@
/*
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 {tag as t} from "./html.js";
import {openFile, readFileAsText} from "./file.js";
const main = document.querySelector("main");
let selectedItemNode;
let rootItem;
let itemByRef;
const logLevels = [undefined, "All", "Debug", "Detail", "Info", "Warn", "Error", "Fatal", "Off"];
main.addEventListener("click", event => {
if (event.target.classList.contains("toggleExpanded")) {
const li = event.target.parentElement.parentElement;
li.classList.toggle("expanded");
} else {
// allow clicking any links other than .item in the timeline, like refs
if (event.target.tagName === "A" && !event.target.classList.contains("item")) {
return;
}
const itemNode = event.target.closest(".item");
if (itemNode) {
// we don't want scroll to jump when clicking
// so prevent default behaviour, and select and push to history manually
event.preventDefault();
selectNode(itemNode);
history.pushState(null, null, `#${itemNode.id}`);
}
}
});
window.addEventListener("hashchange", () => {
const id = window.location.hash.substr(1);
const itemNode = document.getElementById(id);
if (itemNode && itemNode.closest("main")) {
selectNode(itemNode);
itemNode.scrollIntoView({behavior: "smooth", block: "nearest"});
}
});
function selectNode(itemNode) {
if (selectedItemNode) {
selectedItemNode.classList.remove("selected");
}
selectedItemNode = itemNode;
selectedItemNode.classList.add("selected");
let item = rootItem;
let parent;
const indices = selectedItemNode.id.split("/").map(i => parseInt(i, 10));
for(const i of indices) {
parent = item;
item = itemChildren(item)[i];
}
showItemDetails(item, parent, selectedItemNode);
}
function stringifyItemValue(value) {
if (typeof value === "object" && value !== null) {
return JSON.stringify(value, undefined, 2);
} else {
return value + "";
}
}
function showItemDetails(item, parent, itemNode) {
const parentOffset = itemStart(parent) ? `${itemStart(item) - itemStart(parent)}ms` : "none";
const expandButton = t.button("Expand recursively");
expandButton.addEventListener("click", () => expandResursively(itemNode.parentElement.parentElement));
const start = itemStart(item);
const aside = t.aside([
t.h3(itemCaption(item)),
t.p([t.strong("Log level: "), logLevels[itemLevel(item)]]),
t.p([t.strong("Error: "), itemError(item) ? `${itemError(item).name} ${itemError(item).stack}` : "none"]),
t.p([t.strong("Parent offset: "), parentOffset]),
t.p([t.strong("Start: "), new Date(start).toString(), ` (${start})`]),
t.p([t.strong("Duration: "), `${itemDuration(item)}ms`]),
t.p([t.strong("Child count: "), itemChildren(item) ? `${itemChildren(item).length}` : "none"]),
t.p([t.strong("Forced finish: "), (itemForcedFinish(item) || false) + ""]),
t.p(t.strong("Values:")),
t.ul({class: "values"}, Object.entries(itemValues(item)).map(([key, value]) => {
let valueNode;
if (key === "ref") {
const refItem = itemByRef.get(value);
if (refItem) {
valueNode = t.a({href: `#${refItem.id}`}, itemCaption(refItem));
} else {
valueNode = `unknown ref ${value}`;
}
} else {
valueNode = stringifyItemValue(value);
}
return t.li([
t.span({className: "key"}, normalizeValueKey(key)),
t.span({className: "value"}, valueNode)
]);
})),
t.p(expandButton)
]);
document.querySelector("aside").replaceWith(aside);
}
function expandResursively(li) {
li.classList.add("expanded");
const ol = li.querySelector("ol");
if (ol) {
const len = ol.children.length;
for (let i = 0; i < len; i += 1) {
expandResursively(ol.children[i]);
}
}
}
document.getElementById("openFile").addEventListener("click", loadFile);
function getRootItemHeader(prevItem, item) {
if (prevItem) {
const diff = itemStart(item) - itemEnd(prevItem);
if (diff >= 0) {
return `+ ${formatTime(diff)}`;
} else {
const overlap = -diff;
if (overlap >= itemDuration(item)) {
return `ran entirely in parallel with`;
} else {
return `ran ${formatTime(-diff)} in parallel with`;
}
}
} else {
return new Date(itemStart(item)).toString();
}
}
async function loadFile() {
const file = await openFile();
const json = await readFileAsText(file);
const logs = JSON.parse(json);
logs.items.sort((a, b) => itemStart(a) - itemStart(b));
rootItem = {c: logs.items};
itemByRef = new Map();
preprocessRecursively(rootItem, null, itemByRef, []);
const fragment = logs.items.reduce((fragment, item, i, items) => {
const prevItem = i === 0 ? null : items[i - 1];
fragment.appendChild(t.section([
t.h2(getRootItemHeader(prevItem, item)),
t.div({className: "timeline"}, t.ol(itemToNode(item, [i])))
]));
return fragment;
}, document.createDocumentFragment());
main.replaceChildren(fragment);
}
// TODO: make this use processRecursively
function preprocessRecursively(item, parentElement, refsMap, path) {
item.s = (parentElement?.s || 0) + item.s;
if (itemRefSource(item)) {
refsMap.set(itemRefSource(item), item);
}
if (itemChildren(item)) {
for (let i = 0; i < itemChildren(item).length; i += 1) {
// do it in advance for a child as we don't want to do it for the rootItem
const child = itemChildren(item)[i];
const childPath = path.concat(i);
child.id = childPath.join("/");
preprocessRecursively(child, item, refsMap, childPath);
}
}
}
const MS_IN_SEC = 1000;
const MS_IN_MIN = MS_IN_SEC * 60;
const MS_IN_HOUR = MS_IN_MIN * 60;
const MS_IN_DAY = MS_IN_HOUR * 24;
function formatTime(ms) {
let str = "";
if (ms > MS_IN_DAY) {
const days = Math.floor(ms / MS_IN_DAY);
ms -= days * MS_IN_DAY;
str += `${days}d`;
}
if (ms > MS_IN_HOUR) {
const hours = Math.floor(ms / MS_IN_HOUR);
ms -= hours * MS_IN_HOUR;
str += `${hours}h`;
}
if (ms > MS_IN_MIN) {
const mins = Math.floor(ms / MS_IN_MIN);
ms -= mins * MS_IN_MIN;
str += `${mins}m`;
}
if (ms > MS_IN_SEC) {
const secs = ms / MS_IN_SEC;
str += `${secs.toFixed(2)}s`;
} else if (ms > 0 || !str.length) {
str += `${ms}ms`;
}
return str;
}
function itemChildren(item) { return item.c; }
function itemStart(item) { return item.s; }
function itemEnd(item) { return item.s + item.d; }
function itemDuration(item) { return item.d; }
function itemValues(item) { return item.v; }
function itemLevel(item) { return item.l; }
function itemLabel(item) { return item.v?.l; }
function itemType(item) { return item.v?.t; }
function itemError(item) { return item.e; }
function itemForcedFinish(item) { return item.f; }
function itemRef(item) { return item.v?.ref; }
function itemRefSource(item) { return item.v?.refId; }
function itemShortErrorMessage(item) {
if (itemError(item)) {
const e = itemError(item);
return e.name || e.stack.substr(0, e.stack.indexOf("\n"));
}
}
function itemCaption(item) {
if (itemType(item) === "network") {
return `${itemValues(item)?.method} ${itemValues(item)?.url}`;
} else if (itemLabel(item) && itemValues(item)?.id) {
return `${itemLabel(item)} ${itemValues(item).id}`;
} else if (itemLabel(item) && itemValues(item)?.status) {
return `${itemLabel(item)} (${itemValues(item).status})`;
} else if (itemLabel(item) && itemError(item)) {
return `${itemLabel(item)} (${itemShortErrorMessage(item)})`;
} else if (itemRef(item)) {
const refItem = itemByRef.get(itemRef(item));
if (refItem) {
return `ref "${itemCaption(refItem)}"`
} else {
return `unknown ref ${itemRef(item)}`
}
} else {
return itemLabel(item) || itemType(item);
}
}
function normalizeValueKey(key) {
switch (key) {
case "t": return "type";
case "l": return "label";
default: return key;
}
}
// returns the node and the total range (recursively) occupied by the node
function itemToNode(item) {
const hasChildren = !!itemChildren(item)?.length;
const className = {
item: true,
"has-children": hasChildren,
error: itemError(item),
[`type-${itemType(item)}`]: !!itemType(item),
[`level-${itemLevel(item)}`]: true,
};
const id = item.id;
let captionNode;
if (itemRef(item)) {
const refItem = itemByRef.get(itemRef(item));
if (refItem) {
captionNode = ["ref ", t.a({href: `#${refItem.id}`}, itemCaption(refItem))];
}
}
if (!captionNode) {
captionNode = itemCaption(item);
}
const li = t.li([
t.div([
hasChildren ? t.button({className: "toggleExpanded"}) : "",
t.a({className, id, href: `#${id}`}, [
t.span({class: "caption"}, captionNode),
t.span({class: "duration"}, `(${formatTime(itemDuration(item))})`),
])
])
]);
if (itemChildren(item) && itemChildren(item).length) {
li.appendChild(t.ol(itemChildren(item).map(item => {
return itemToNode(item);
})));
}
return li;
}
const highlightForm = document.getElementById("highlightForm");
highlightForm.addEventListener("submit", evt => {
evt.preventDefault();
const matchesOutput = document.getElementById("highlightMatches");
const query = document.getElementById("highlight").value;
if (query) {
matchesOutput.innerText = "Searching…";
let matches = 0;
processRecursively(rootItem, item => {
let domNode = document.getElementById(item.id);
if (itemMatchesFilter(item, query)) {
matches += 1;
domNode.classList.add("highlighted");
domNode = domNode.parentElement;
while (domNode.nodeName !== "SECTION") {
if (domNode.nodeName === "LI") {
domNode.classList.add("expanded");
}
domNode = domNode.parentElement;
}
} else {
domNode.classList.remove("highlighted");
}
});
matchesOutput.innerText = `${matches} matches`;
} else {
for (const node of document.querySelectorAll(".highlighted")) {
node.classList.remove("highlighted");
}
matchesOutput.innerText = "";
}
});
function itemMatchesFilter(item, query) {
if (itemError(item)) {
if (valueMatchesQuery(itemError(item), query)) {
return true;
}
}
return valueMatchesQuery(itemValues(item), query);
}
function valueMatchesQuery(value, query) {
if (typeof value === "string") {
return value.includes(query);
} else if (typeof value === "object" && value !== null) {
for (const key in value) {
if (value.hasOwnProperty(key) && valueMatchesQuery(value[key], query)) {
return true;
}
}
} else if (typeof value === "number") {
return value.toString().includes(query);
}
return false;
}
function processRecursively(item, callback, parentItem) {
if (item.id) {
callback(item, parentItem);
}
if (itemChildren(item)) {
for (let i = 0; i < itemChildren(item).length; i += 1) {
// do it in advance for a child as we don't want to do it for the rootItem
const child = itemChildren(item)[i];
processRecursively(child, callback, item);
}
}
}
document.getElementById("collapseAll").addEventListener("click", () => {
for (const node of document.querySelectorAll(".expanded")) {
node.classList.remove("expanded");
}
});
document.getElementById("hideCollapsed").addEventListener("click", () => {
for (const node of document.querySelectorAll("section > div.timeline > ol > li:not(.expanded)")) {
node.closest("section").classList.add("hidden");
}
});
document.getElementById("hideHighlightedSiblings").addEventListener("click", () => {
for (const node of document.querySelectorAll(".highlighted")) {
const list = node.closest("ol");
const siblings = Array.from(list.querySelectorAll("li > div > a:not(.highlighted)")).map(n => n.closest("li"));
for (const sibling of siblings) {
if (!sibling.classList.contains("expanded")) {
sibling.classList.add("hidden");
}
}
}
});
document.getElementById("showAll").addEventListener("click", () => {
for (const node of document.querySelectorAll(".hidden")) {
node.classList.remove("hidden");
}
});

View file

@ -2,6 +2,9 @@ VERSION=$(jq -r ".version" package.json)
PACKAGE=hydrogen-web-$VERSION.tar.gz
yarn build
pushd target
# move config file so we don't override it
# when deploying a new version
mv config.json config.sample.json
tar -czvf ../$PACKAGE ./
popd
echo $PACKAGE

View file

@ -30,12 +30,7 @@ const valueParser = require("postcss-value-parser");
* The actual derivation is done outside the plugin in a callback.
*/
let aliasMap;
let resolvedMap;
let baseVariables;
let isDark;
function getValueFromAlias(alias) {
function getValueFromAlias(alias, {aliasMap, baseVariables, resolvedMap}) {
const derivedVariable = aliasMap.get(alias);
return baseVariables.get(derivedVariable) ?? resolvedMap.get(derivedVariable);
}
@ -68,14 +63,15 @@ function parseDeclarationValue(value) {
return variables;
}
function resolveDerivedVariable(decl, derive) {
function resolveDerivedVariable(decl, derive, maps, isDark) {
const { baseVariables, resolvedMap } = maps;
const RE_VARIABLE_VALUE = /(?:--)?((.+)--(.+)-(.+))/;
const variableCollection = parseDeclarationValue(decl.value);
for (const variable of variableCollection) {
const matches = variable.match(RE_VARIABLE_VALUE);
if (matches) {
const [, wholeVariable, baseVariable, operation, argument] = matches;
const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable);
const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable, maps);
if (!value) {
throw new Error(`Cannot derive from ${baseVariable} because it is neither defined in config nor is it an alias!`);
}
@ -85,7 +81,7 @@ function resolveDerivedVariable(decl, derive) {
}
}
function extract(decl) {
function extract(decl, {aliasMap, baseVariables}) {
if (decl.variable) {
// see if right side is of form "var(--foo)"
const wholeVariable = decl.value.match(/var\(--(.+)\)/)?.[1];
@ -100,7 +96,7 @@ function extract(decl) {
}
}
function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) {
function addResolvedVariablesToRootSelector(root, {Rule, Declaration}, {resolvedMap}) {
const newRule = new Rule({ selector: ":root", source: root.source });
// Add derived css variables to :root
resolvedMap.forEach((value, key) => {
@ -110,13 +106,20 @@ function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) {
root.append(newRule);
}
function populateMapWithDerivedVariables(map, cssFileLocation) {
function populateMapWithDerivedVariables(map, cssFileLocation, {resolvedMap, aliasMap}) {
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
const derivedVariables = [
...([...resolvedMap.keys()].filter(v => !aliasMap.has(v))),
...([...aliasMap.entries()].map(([alias, variable]) => `${alias}=${variable}`))
];
map.set(location, { "derived-variables": derivedVariables });
const sharedObject = map.get(location);
const output = { "derived-variables": derivedVariables };
if (sharedObject) {
Object.assign(sharedObject, output);
}
else {
map.set(location, output);
}
}
/**
@ -133,10 +136,10 @@ function populateMapWithDerivedVariables(map, cssFileLocation) {
* @param {Map} opts.compiledVariables - A map that stores derived variables so that manifest source sections can be produced
*/
module.exports = (opts = {}) => {
aliasMap = new Map();
resolvedMap = new Map();
baseVariables = new Map();
isDark = false;
const aliasMap = new Map();
const resolvedMap = new Map();
const baseVariables = new Map();
const maps = { aliasMap, resolvedMap, baseVariables };
return {
postcssPlugin: "postcss-compile-variables",
@ -147,16 +150,16 @@ module.exports = (opts = {}) => {
// If this is a runtime theme, don't derive variables.
return;
}
isDark = cssFileLocation.includes("dark=true");
const isDark = cssFileLocation.includes("dark=true");
/*
Go through the CSS file once to extract all aliases and base variables.
We use these when resolving derived variables later.
*/
root.walkDecls(decl => extract(decl));
root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive));
addResolvedVariablesToRootSelector(root, {Rule, Declaration});
root.walkDecls(decl => extract(decl, maps));
root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive, maps, isDark));
addResolvedVariablesToRootSelector(root, {Rule, Declaration}, maps);
if (opts.compiledVariables){
populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation);
populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation, maps);
}
// Also produce a mapping from alias to completely resolved color
const resolvedAliasMap = new Map();

View file

@ -16,7 +16,6 @@ limitations under the License.
const valueParser = require("postcss-value-parser");
const resolve = require("path").resolve;
let cssPath;
function colorsFromURL(url, colorMap) {
const params = new URL(`file://${url}`).searchParams;
@ -36,7 +35,7 @@ function colorsFromURL(url, colorMap) {
return [primaryColor, secondaryColor];
}
function processURL(decl, replacer, colorMap) {
function processURL(decl, replacer, colorMap, cssPath) {
const value = decl.value;
const parsed = valueParser(value);
parsed.walk(node => {
@ -84,8 +83,8 @@ module.exports = (opts = {}) => {
Go through each declaration and if it contains an URL, replace the url with the result
of running replacer(url)
*/
cssPath = root.source?.input.file.replace(/[^/]*$/, "");
root.walkDecls(decl => processURL(decl, opts.replacer, colorMap));
const cssPath = root.source?.input.file.replace(/[^/]*$/, "");
root.walkDecls(decl => processURL(decl, opts.replacer, colorMap, cssPath));
},
};
};

View file

@ -20,11 +20,9 @@ const valueParser = require("postcss-value-parser");
* This plugin extracts content inside url() into css variables and adds the variables to the root section.
* This plugin is used in conjunction with css-url-processor plugin to colorize svg icons.
*/
let counter;
let urlVariables;
const idToPrepend = "icon-url";
function findAndReplaceUrl(decl) {
function findAndReplaceUrl(decl, urlVariables, counter) {
const value = decl.value;
const parsed = valueParser(value);
parsed.walk(node => {
@ -35,7 +33,8 @@ function findAndReplaceUrl(decl) {
if (!url.match(/\.svg\?primary=.+/)) {
return;
}
const variableName = `${idToPrepend}-${counter++}`;
const count = counter.next().value;
const variableName = `${idToPrepend}-${count}`;
urlVariables.set(variableName, url);
node.value = "var";
node.nodes = [{ type: "word", value: `--${variableName}` }];
@ -43,7 +42,7 @@ function findAndReplaceUrl(decl) {
decl.assign({prop: decl.prop, value: parsed.toString()})
}
function addResolvedVariablesToRootSelector(root, { Rule, Declaration }) {
function addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables) {
const newRule = new Rule({ selector: ":root", source: root.source });
// Add derived css variables to :root
urlVariables.forEach((value, key) => {
@ -53,29 +52,42 @@ function addResolvedVariablesToRootSelector(root, { Rule, Declaration }) {
root.append(newRule);
}
function populateMapWithIcons(map, cssFileLocation) {
function populateMapWithIcons(map, cssFileLocation, urlVariables) {
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
const sharedObject = map.get(location);
sharedObject["icon"] = Object.fromEntries(urlVariables);
const output = {"icon": Object.fromEntries(urlVariables)};
if (sharedObject) {
Object.assign(sharedObject, output);
}
else {
map.set(location, output);
}
}
function *createCounter() {
for (let i = 0; ; ++i) {
yield i;
}
}
/* *
* @type {import('postcss').PluginCreator}
*/
module.exports = (opts = {}) => {
urlVariables = new Map();
counter = 0;
return {
postcssPlugin: "postcss-url-to-variable",
Once(root, { Rule, Declaration }) {
root.walkDecls(decl => findAndReplaceUrl(decl));
if (urlVariables.size) {
addResolvedVariablesToRootSelector(root, { Rule, Declaration });
const urlVariables = new Map();
const counter = createCounter();
root.walkDecls(decl => findAndReplaceUrl(decl, urlVariables, counter));
const cssFileLocation = root.source.input.from;
if (urlVariables.size && !cssFileLocation.includes("type=runtime")) {
addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables);
}
if (opts.compiledVariables){
const cssFileLocation = root.source.input.from;
populateMapWithIcons(opts.compiledVariables, cssFileLocation);
populateMapWithIcons(opts.compiledVariables, cssFileLocation, urlVariables);
}
},
};

View file

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

View file

@ -1,3 +1,4 @@
set -e
if [ -z "$1" ]; then
echo "provide a new version, current version is $(jq '.version' package.json)"
exit 1

View file

@ -1,7 +1,19 @@
{
"name": "@thirdroom/hydrogen-view-sdk",
"name": "hydrogen-view-sdk",
"description": "Embeddable matrix client library, including view components",
"version": "0.0.8",
"main": "./hydrogen.es.js",
"type": "module"
"version": "0.1.0",
"main": "./lib-build/hydrogen.cjs.js",
"exports": {
".": {
"import": "./lib-build/hydrogen.es.js",
"require": "./lib-build/hydrogen.cjs.js"
},
"./paths/vite": "./paths/vite.js",
"./style.css": "./asset-build/assets/theme-element-light.css",
"./theme-element-light.css": "./asset-build/assets/theme-element-light.css",
"./theme-element-dark.css": "./asset-build/assets/theme-element-dark.css",
"./main.js": "./asset-build/assets/main.js",
"./download-sandbox.html": "./asset-build/assets/download-sandbox.html",
"./assets/*": "./asset-build/assets/*"
}
}

View file

@ -2,8 +2,12 @@
# Exit whenever one of the commands fail with a non-zero exit code
set -e
set -o pipefail
# Enable extended globs so we can use the `!(filename)` glob syntax
shopt -s extglob
rm -rf target
# Only remove the directory contents instead of the whole directory to maintain
# the `npm link`/`yarn link` symlink
rm -rf target/*
yarn run vite build -c vite.sdk-assets-config.js
yarn run vite build -c vite.sdk-lib-config.js
yarn tsc -p tsconfig-declaration.json
@ -12,19 +16,10 @@ mkdir target/paths
# this doesn't work, the ?url imports need to be in the consuming project, so disable for now
# ./scripts/sdk/transform-paths.js ./src/platform/web/sdk/paths/vite.js ./target/paths/vite.js
cp doc/SDK.md target/README.md
pushd target
pushd asset-build/assets
mv main.*.js ../../main.js
# Create a copy of light theme for backwards compatibility
cp theme-element-light.*.css ../../style.css
# Remove asset hash from css files
mv theme-element-light.*.css ../../theme-element-light.css
mv theme-element-dark.*.css ../../theme-element-dark.css
mv download-sandbox.*.html ../../download-sandbox.html
rm *.js *.wasm
mv ./* ../../
pushd target/asset-build
rm index.html
popd
rm -rf asset-build
mv lib-build/* .
rm -rf lib-build
pushd target/asset-build/assets
# Remove all `*.wasm` and `*.js` files except for `main.js`
rm !(main).js *.wasm
popd

View file

@ -3,21 +3,7 @@ const fs = require("fs");
const appManifest = require("../../package.json");
const baseSDKManifest = require("./base-manifest.json");
/*
need to leave exports out of base-manifest.json because of #vite-bug,
with the downside that we can't support environments that support
both esm and commonjs modules, so we pick just esm.
```
"exports": {
".": {
"import": "./hydrogen.es.js",
"require": "./hydrogen.cjs.js"
},
"./paths/vite": "./paths/vite.js",
"./style.css": "./style.css"
},
```
Also need to leave typescript type definitions out until the
Need to leave typescript type definitions out until the
typescript conversion is complete and all imports in the d.ts files
exists.
```

3
scripts/sdk/test/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
yarn.lock

2
scripts/sdk/test/deps.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
// Keep TypeScripts from complaining about hydrogen-view-sdk not having types yet
declare module "hydrogen-view-sdk";

View file

@ -0,0 +1,21 @@
import * as hydrogenViewSdk from "hydrogen-view-sdk";
import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url';
import workerPath from 'hydrogen-view-sdk/main.js?url';
import olmWasmPath from '@matrix-org/olm/olm.wasm?url';
import olmJsPath from '@matrix-org/olm/olm.js?url';
import olmLegacyJsPath from '@matrix-org/olm/olm_legacy.js?url';
const assetPaths = {
downloadSandbox: downloadSandboxPath,
worker: workerPath,
olm: {
wasm: olmWasmPath,
legacyBundle: olmLegacyJsPath,
wasmBundle: olmJsPath
}
};
import "hydrogen-view-sdk/assets/theme-element-light.css";
console.log('hydrogenViewSdk', hydrogenViewSdk);
console.log('assetPaths', assetPaths);
console.log('Entry ESM works ✅');

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app" class="hydrogen"></div>
<script type="module" src="./esm-entry.ts"></script>
</body>
</html>

View file

@ -0,0 +1,8 @@
{
"name": "test-sdk",
"version": "0.0.0",
"description": "",
"dependencies": {
"hydrogen-view-sdk": "link:../../../target"
}
}

View file

@ -0,0 +1,13 @@
// Make sure the SDK can be used in a CommonJS environment.
// Usage: node scripts/sdk/test/test-sdk-in-commonjs-env.js
const hydrogenViewSdk = require('hydrogen-view-sdk');
// Test that the "exports" are available:
// Worker
require.resolve('hydrogen-view-sdk/main.js');
// Styles
require.resolve('hydrogen-view-sdk/assets/theme-element-light.css');
// Can access files in the assets/* directory
require.resolve('hydrogen-view-sdk/assets/main.js');
console.log('SDK works in CommonJS ✅');

View file

@ -0,0 +1,19 @@
const { resolve } = require('path');
const { build } = require('vite');
async function main() {
await build({
outDir: './dist',
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html')
}
}
}
});
console.log('SDK works in Vite build ✅');
}
main();

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.
*/
import {Options, ViewModel} from "./ViewModel";
import {Options as BaseOptions, ViewModel} from "./ViewModel";
import {Client} from "../matrix/Client.js";
import {SegmentType} from "./navigation/index";
type LogoutOptions = { sessionId: string; } & Options;
type Options = { sessionId: string; } & BaseOptions;
export class LogoutViewModel extends ViewModel<LogoutOptions> {
export class LogoutViewModel extends ViewModel<SegmentType, Options> {
private _sessionId: string;
private _busy: boolean;
private _showConfirm: boolean;
private _error?: Error;
constructor(options: LogoutOptions) {
constructor(options: Options) {
super(options);
this._sessionId = options.sessionId;
this._busy = false;
@ -41,7 +42,7 @@ export class LogoutViewModel extends ViewModel<LogoutOptions> {
return this._busy;
}
get cancelUrl(): string {
get cancelUrl(): string | undefined {
return this.urlCreator.urlForSegment("session", true);
}

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SortedArray} from "../observable/index";
import {SortedArray} from "../observable/index.js";
import {ViewModel} from "./ViewModel";
import {avatarInitials, getIdentifierColorNumber} from "./avatar";

View file

@ -27,17 +27,19 @@ import type {Platform} from "../platform/web/Platform";
import type {Clock} from "../platform/web/dom/Clock";
import type {ILogger} from "../logging/types";
import type {Navigation} from "./navigation/Navigation";
import type {URLRouter} from "./navigation/URLRouter";
import type {SegmentType} from "./navigation/index";
import type {IURLRouter} from "./navigation/URLRouter";
export type Options = {
platform: Platform
logger: ILogger
urlCreator: URLRouter
navigation: Navigation
emitChange?: (params: any) => void
export type Options<T extends object = SegmentType> = {
platform: Platform;
logger: ILogger;
urlCreator: IURLRouter<T>;
navigation: Navigation<T>;
emitChange?: (params: any) => void;
}
export class ViewModel<O extends Options = Options> extends EventEmitter<{change: never}> {
export class ViewModel<N extends object = SegmentType, O extends Options<N> = Options<N>> extends EventEmitter<{change: never}> {
private disposables?: Disposables;
private _isDisposed = false;
private _options: Readonly<O>;
@ -47,7 +49,7 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
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);
}
@ -58,11 +60,11 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this._options[name];
}
observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) {
observeNavigation<T extends keyof N>(type: T, onChange: (value: N[T], type: T) => void): void {
const segmentObservable = this.navigation.observe(type);
const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => {
const unsubscribe = segmentObservable.subscribe((value: N[T]) => {
onChange(value, type);
})
});
this.track(unsubscribe);
}
@ -100,10 +102,10 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
// TODO: this will need to support binding
// if any of the expr is a function, assume the function is a binding, and return a binding function ourselves
//
//
// translated string should probably always be bindings, unless we're fine with a refresh when changing the language?
// we probably are, if we're using routing with a url, we could just refresh.
i18n(parts: TemplateStringsArray, ...expr: any[]) {
i18n(parts: TemplateStringsArray, ...expr: any[]): string {
// just concat for now
let result = "";
for (let i = 0; i < parts.length; ++i) {
@ -135,11 +137,12 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this.platform.logger;
}
get urlCreator(): URLRouter {
get urlCreator(): IURLRouter<N> {
return this._options.urlCreator;
}
get navigation(): Navigation {
return this._options.navigation;
get navigation(): Navigation<N> {
// typescript needs a little help here
return this._options.navigation as unknown as Navigation<N>;
}
}

View file

@ -51,10 +51,10 @@ export function getIdentifierColorNumber(id: string): number {
return (hashCode(id) % 8) + 1;
}
export function getAvatarHttpUrl(avatarUrl: string | undefined, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | undefined {
export function getAvatarHttpUrl(avatarUrl: string, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | null {
if (avatarUrl) {
const imageSize = cssSize * platform.devicePixelRatio;
return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop");
}
return undefined;
return null;
}

View file

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

View file

@ -14,30 +14,51 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ObservableValue} from "../../observable/value/ObservableValue";
import {BaseObservableValue} from "../../observable/value/BaseObservableValue";
import {BaseObservableValue, ObservableValue} from "../../observable/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._path = new Path([], allowsChild);
this._observables = new Map();
this._pathObservable = new ObservableValue(this._path);
}
get pathObservable() {
get pathObservable(): ObservableValue<Path<T>> {
return this._pathObservable;
}
get path() {
get path(): Path<T> {
return this._path;
}
push(type, value = undefined) {
return this.applyPath(this.path.with(new Segment(type, value)));
push<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): void {
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,
// so we assume it respects the allowsChild rules
const oldPath = this._path;
@ -61,7 +82,7 @@ export class Navigation {
this._pathObservable.set(this._path);
}
observe(type) {
observe(type: keyof T): SegmentObservable<T> {
let observable = this._observables.get(type);
if (!observable) {
observable = new SegmentObservable(this, type);
@ -70,9 +91,9 @@ export class Navigation {
return observable;
}
pathFrom(segments) {
let parent;
let i;
pathFrom(segments: Segment<any>[]): Path<T> {
let parent: Segment<any> | undefined;
let i: number;
for (i = 0; i < segments.length; i += 1) {
if (!this._allowsChild(parent, segments[i])) {
return new Path(segments.slice(0, i), this._allowsChild);
@ -82,12 +103,12 @@ export class Navigation {
return new Path(segments, this._allowsChild);
}
segment(type, value) {
return new Segment(type, value);
segment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): Segment<T> {
return new Segment(type, ...value);
}
}
function segmentValueEqual(a, b) {
function segmentValueEqual<T>(a?: T[keyof T], b?: T[keyof T]): boolean {
if (a === b) {
return true;
}
@ -104,24 +125,29 @@ function segmentValueEqual(a, b) {
return false;
}
export class Segment {
constructor(type, value) {
this.type = type;
this.value = value === undefined ? true : value;
export class Segment<T, K extends keyof T = any> {
public value: T[K];
constructor(public type: K, ...value: OptionalValue<T[K]>) {
this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K];
}
}
class Path {
constructor(segments = [], allowsChild) {
class Path<T> {
private readonly _segments: Segment<T, any>[];
private readonly _allowsChild: AllowsChild<T>;
constructor(segments: Segment<T>[] = [], allowsChild: AllowsChild<T>) {
this._segments = segments;
this._allowsChild = allowsChild;
}
clone() {
clone(): Path<T> {
return new Path(this._segments.slice(), this._allowsChild);
}
with(segment) {
with(segment: Segment<T>): Path<T> | undefined {
let index = this._segments.length - 1;
do {
if (this._allowsChild(this._segments[index], segment)) {
@ -133,10 +159,10 @@ class Path {
index -= 1;
} while(index >= -1);
// 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);
if (index !== -1) {
return new Path(this._segments.slice(0, index + 1), this._allowsChild)
@ -144,11 +170,11 @@ class Path {
return new Path([], this._allowsChild);
}
get(type) {
get(type: keyof T): Segment<T> | undefined {
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);
if (index !== -1) {
const parent = this._segments[index - 1];
@ -161,10 +187,10 @@ class Path {
}
}
}
return null;
return undefined;
}
get segments() {
get segments(): Segment<T>[] {
return this._segments;
}
}
@ -173,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.
* This ensures that observers of a segment can also read the most recent value of other segments.
*/
class SegmentObservable extends BaseObservableValue {
constructor(navigation, type) {
class SegmentObservable<T extends object> extends BaseObservableValue<T[keyof T] | undefined> {
private readonly _navigation: Navigation<T>;
private _type: keyof T;
private _lastSetValue?: T[keyof T];
constructor(navigation: Navigation<T>, type: keyof T) {
super();
this._navigation = navigation;
this._type = type;
this._lastSetValue = navigation.path.get(type)?.value;
}
get() {
get(): T[keyof T] | undefined {
const path = this._navigation.path;
const segment = path.get(this._type);
const value = segment?.value;
return value;
}
emitIfChanged() {
emitIfChanged(): void {
const newValue = this.get();
if (!segmentValueEqual(newValue, this._lastSetValue)) {
if (!segmentValueEqual<T>(newValue, this._lastSetValue)) {
this._lastSetValue = newValue;
this.emit(newValue);
}
}
}
export type {Path};
export function tests() {
function createMockNavigation() {
return new Navigation((parent, {type}) => {
switch (parent?.type) {
case undefined:
return type === "1" || "2";
return type === "1" || type === "2";
case "1":
return type === "1.1";
case "1.1":
return type === "1.1.1";
case "2":
return type === "2.1" || "2.2";
return type === "2.1" || type === "2.2";
default:
return false;
}
@ -217,7 +249,7 @@ export function tests() {
}
function observeTypes(nav, types) {
const changes = [];
const changes: {type:string, value:any}[] = [];
for (const type of types) {
nav.observe(type).subscribe(value => {
changes.push({type, value});
@ -226,6 +258,12 @@ export function tests() {
return changes;
}
type SegmentType = {
"foo": number;
"bar": number;
"baz": number;
}
return {
"applying a path emits an event on the observable": assert => {
const nav = createMockNavigation();
@ -243,18 +281,18 @@ export function tests() {
assert.equal(changes[1].value, 8);
},
"path.get": assert => {
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
assert.equal(path.get("foo").value, 5);
assert.equal(path.get("bar").value, 6);
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("bar")!.value, 6);
},
"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));
assert.equal(newPath.get("foo").value, 1);
assert.equal(newPath.get("bar").value, 6);
assert.equal(newPath!.get("foo")!.value, 1);
assert.equal(newPath!.get("bar")!.value, 6);
},
"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));
assert.equal(newPath, null);
}

View file

@ -14,28 +14,55 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export class URLRouter {
constructor({history, navigation, parseUrlPath, stringifyPath}) {
import type {History} from "../../platform/web/dom/History.js";
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._navigation = navigation;
this._parseUrlPath = parseUrlPath;
this._stringifyPath = stringifyPath;
this._subscription = null;
this._pathSubscription = null;
this._isApplyingUrl = false;
this._defaultSessionId = this._getLastSessionId();
}
_getLastSessionId() {
const navPath = this._urlAsNavPath(this._history.getLastUrl() || "");
private _getLastSessionId(): string | undefined {
const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
const sessionId = navPath.get("session")?.value;
if (typeof sessionId === "string") {
return sessionId;
}
return null;
return undefined;
}
attach() {
attach(): void {
this._subscription = this._history.subscribe(url => this._applyUrl(url));
// subscribe to path before applying initial url
// so redirects in _applyNavPathToHistory are reflected in url bar
@ -43,12 +70,12 @@ export class URLRouter {
this._applyUrl(this._history.get());
}
dispose() {
this._subscription = this._subscription();
this._pathSubscription = this._pathSubscription();
dispose(): void {
if (this._subscription) { this._subscription = this._subscription(); }
if (this._pathSubscription) { this._pathSubscription = this._pathSubscription(); }
}
_applyNavPathToHistory(path) {
private _applyNavPathToHistory(path: Path<T>): void {
const url = this.urlForPath(path);
if (url !== this._history.get()) {
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,
// 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)
@ -69,22 +96,22 @@ export class URLRouter {
this._isApplyingUrl = false;
}
_urlAsNavPath(url) {
private _urlAsNavPath(url: string): Path<T> {
const urlPath = this._history.urlAsPath(url);
return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId));
}
_applyUrl(url) {
private _applyUrl(url: string): void {
const navPath = this._urlAsNavPath(url);
this._applyNavPathToNavigation(navPath);
}
pushUrl(url) {
pushUrl(url: string): void {
this._history.pushUrl(url);
}
tryRestoreLastUrl() {
const lastNavPath = this._urlAsNavPath(this._history.getLastUrl() || "");
tryRestoreLastUrl(): boolean {
const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
if (lastNavPath.segments.length !== 0) {
this._applyNavPathToNavigation(lastNavPath);
return true;
@ -92,8 +119,8 @@ export class URLRouter {
return false;
}
urlForSegments(segments) {
let path = this._navigation.path;
urlForSegments(segments: Segment<T>[]): string | undefined {
let path: Path<T> | undefined = this._navigation.path;
for (const segment of segments) {
path = path.with(segment);
if (!path) {
@ -103,29 +130,29 @@ export class URLRouter {
return this.urlForPath(path);
}
urlForSegment(type, value) {
return this.urlForSegments([this._navigation.segment(type, value)]);
urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined {
return this.urlForSegments([this._navigation.segment(type, ...value)]);
}
urlUntilSegment(type) {
urlUntilSegment(type: keyof T): string {
return this.urlForPath(this._navigation.path.until(type));
}
urlForPath(path) {
urlForPath(path: Path<T>): string {
return this._history.pathAsUrl(this._stringifyPath(path));
}
openRoomActionUrl(roomId) {
openRoomActionUrl(roomId: string): string {
// not a segment to navigation knowns about, so append it manually
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
return this._history.pathAsUrl(urlPath);
}
createSSOCallbackURL() {
createSSOCallbackURL(): string {
return window.location.origin;
}
normalizeUrl() {
normalizeUrl(): void {
// Remove any queryParameters from the URL
// Gets rid of the loginToken after SSO
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.
*/
import {Navigation, Segment} from "./Navigation.js";
import {URLRouter} from "./URLRouter.js";
import {Navigation, Segment} from "./Navigation";
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);
}
export function createRouter({history, navigation}) {
return new URLRouter({history, navigation, stringifyPath, parseUrlPath});
export function createRouter({history, navigation}: {history: History, navigation: Navigation<SegmentType>}): URLRouter<SegmentType> {
return new URLRouter(history, navigation, parseUrlPath, stringifyPath);
}
function allowsChild(parent, child) {
function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<SegmentType>): boolean {
const {type} = child;
switch (parent?.type) {
case undefined:
@ -45,8 +63,9 @@ function allowsChild(parent, child) {
}
}
export function removeRoomFromPath(path, roomId) {
const rooms = path.get("rooms");
export function removeRoomFromPath(path: Path<SegmentType>, roomId: string): Path<SegmentType> | undefined {
let newPath: Path<SegmentType> | undefined = path;
const rooms = newPath.get("rooms");
let roomIdGridIndex = -1;
// first delete from rooms segment
if (rooms) {
@ -54,22 +73,22 @@ export function removeRoomFromPath(path, roomId) {
if (roomIdGridIndex !== -1) {
const idsWithoutRoom = rooms.value.slice();
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)
if (room && room.value === roomId) {
if (roomIdGridIndex !== -1) {
path = path.with(new Segment("empty-grid-tile", roomIdGridIndex));
newPath = newPath!.with(new Segment("empty-grid-tile", roomIdGridIndex));
} 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)) {
const emptyGridTile = path.get("empty-grid-tile");
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(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 i = segments.findIndex(segment => segment.type === "right-panel");
let _path = path;
if (i !== -1) {
_path = path.until("room");
_path = _path.with(segments[i]);
_path = _path.with(segments[i + 1]);
_path = _path.with(segments[i])!;
_path = _path.with(segments[i + 1])!;
}
return _path;
}
export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
// substr(1) to take of initial /
const parts = urlPath.substr(1).split("/");
export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>, defaultSessionId?: string): Segment<SegmentType>[] {
// substring(1) to take of initial /
const parts = urlPath.substring(1).split("/");
const iterator = parts[Symbol.iterator]();
const segments = [];
const segments: Segment<SegmentType>[] = [];
let next;
while (!(next = iterator.next()).done) {
const type = next.value;
@ -170,9 +189,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
return segments;
}
export function stringifyPath(path) {
export function stringifyPath(path: Path<SegmentType>): string {
let urlPath = "";
let prevSegment;
let prevSegment: Segment<SegmentType> | undefined;
for (const segment of path.segments) {
switch (segment.type) {
case "rooms":
@ -205,9 +224,15 @@ export function stringifyPath(path) {
}
export function tests() {
function createEmptyPath() {
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([]);
return path;
}
return {
"stringify grid url with focused empty tile": assert => {
const nav = new Navigation(allowsChild);
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
@ -217,7 +242,7 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/3");
},
"stringify grid url with focused room": assert => {
const nav = new Navigation(allowsChild);
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
@ -227,7 +252,7 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/1");
},
"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([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
@ -239,13 +264,15 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details");
},
"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[0].type, "sso");
assert.equal(segments[0].value, "a1232aSD123");
},
"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[0].type, "session");
assert.equal(segments[0].value, "1");
@ -255,7 +282,8 @@ export function tests() {
assert.equal(segments[2].value, 3);
},
"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[0].type, "session");
assert.equal(segments[0].value, "1");
@ -265,7 +293,8 @@ export function tests() {
assert.equal(segments[2].value, "b");
},
"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[0].type, "session");
assert.equal(segments[0].value, "1");
@ -275,7 +304,8 @@ export function tests() {
assert.equal(segments[2].value, 0);
},
"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[0].type, "session");
assert.equal(segments[0].value, "1");
@ -285,7 +315,7 @@ export function tests() {
assert.equal(segments[2].value, 1);
},
"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([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
@ -301,7 +331,7 @@ export function tests() {
assert.equal(segments[2].value, "d");
},
"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([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
@ -317,7 +347,7 @@ export function tests() {
assert.equal(segments[2].value, "a");
},
"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([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
@ -339,7 +369,7 @@ export function tests() {
assert.equal(segments[4].value, true);
},
"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([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
@ -361,7 +391,7 @@ export function tests() {
assert.equal(segments[4].value, "foo");
},
"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([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
@ -377,82 +407,83 @@ export function tests() {
assert.equal(segments[2].value, "d");
},
"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[0].type, "session");
assert.strictEqual(segments[0].value, true);
},
"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([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b")
]);
const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 3);
assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]);
assert.equal(newPath.segments[2].type, "empty-grid-tile");
assert.equal(newPath.segments[2].value, 1);
assert.equal(newPath?.segments.length, 3);
assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath?.segments[1].type, "rooms");
assert.deepEqual(newPath?.segments[1].value, ["a", "", "c"]);
assert.equal(newPath?.segments[2].type, "empty-grid-tile");
assert.equal(newPath?.segments[2].value, 1);
},
"remove inactive room from grid path": assert => {
const nav = new Navigation(allowsChild);
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b")
]);
const newPath = removeRoomFromPath(path, "a");
assert.equal(newPath.segments.length, 3);
assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]);
assert.equal(newPath.segments[2].type, "room");
assert.equal(newPath.segments[2].value, "b");
assert.equal(newPath?.segments.length, 3);
assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath?.segments[1].type, "rooms");
assert.deepEqual(newPath?.segments[1].value, ["", "b", "c"]);
assert.equal(newPath?.segments[2].type, "room");
assert.equal(newPath?.segments[2].value, "b");
},
"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([
new Segment("session", 1),
new Segment("rooms", ["a", "b", ""]),
new Segment("empty-grid-tile", 3)
]);
const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 3);
assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["a", "", ""]);
assert.equal(newPath.segments[2].type, "empty-grid-tile");
assert.equal(newPath.segments[2].value, 3);
assert.equal(newPath?.segments.length, 3);
assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath?.segments[1].type, "rooms");
assert.deepEqual(newPath?.segments[1].value, ["a", "", ""]);
assert.equal(newPath?.segments[2].type, "empty-grid-tile");
assert.equal(newPath?.segments[2].value, 3);
},
"remove active room": assert => {
const nav = new Navigation(allowsChild);
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("room", "b")
]);
const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 1);
assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath?.segments.length, 1);
assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath?.segments[0].value, 1);
},
"remove inactive room doesn't do anything": assert => {
const nav = new Navigation(allowsChild);
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("room", "b")
]);
const newPath = removeRoomFromPath(path, "a");
assert.equal(newPath.segments.length, 2);
assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "room");
assert.equal(newPath.segments[1].value, "b");
assert.equal(newPath?.segments.length, 2);
assert.equal(newPath?.segments[0].type, "session");
assert.equal(newPath?.segments[0].value, 1);
assert.equal(newPath?.segments[1].type, "room");
assert.equal(newPath?.segments[1].value, "b");
},
}

65
src/domain/rageshake.ts Normal file
View file

@ -0,0 +1,65 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type {BlobHandle} from "../platform/web/dom/BlobHandle";
import type {RequestFunction} from "../platform/types/types";
// see https://github.com/matrix-org/rageshake#readme
type RageshakeData = {
// A textual description of the problem. Included in the details.log.gz file.
text: string | undefined;
// Application user-agent. Included in the details.log.gz file.
userAgent: string;
// Identifier for the application (eg 'riot-web'). Should correspond to a mapping configured in the configuration file for github issue reporting to work.
app: string;
// Application version. Included in the details.log.gz file.
version: string;
// Label to attach to the github issue, and include in the details file.
label: string | undefined;
};
export async function submitLogsToRageshakeServer(data: RageshakeData, logsBlob: BlobHandle, submitUrl: string, request: RequestFunction): Promise<void> {
const formData = new Map<string, string | {name: string, blob: BlobHandle}>();
if (data.text) {
formData.set("text", data.text);
}
formData.set("user_agent", data.userAgent);
formData.set("app", data.app);
formData.set("version", data.version);
if (data.label) {
formData.set("label", data.label);
}
formData.set("file", {name: "logs.json", blob: logsBlob});
const headers: Map<string, string> = new Map();
headers.set("Accept", "application/json");
const result = request(submitUrl, {
method: "POST",
body: formData,
headers
});
let response;
try {
response = await result.response();
} catch (err) {
throw new Error(`Could not submit logs to ${submitUrl}, got error ${err.message}`);
}
const {status, body} = response;
if (status < 200 || status >= 300) {
throw new Error(`Could not submit logs to ${submitUrl}, got status code ${status} with body ${body}`);
}
// we don't bother with reading report_url from the body as the rageshake server doesn't always return it
// and would have to have CORS setup properly for us to be able to read it.
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import {ViewModel} from "../ViewModel";
import {imageToInfo} from "./common.js";
import {RoomVisibility} from "../../matrix/room/common";
import {RoomType} from "../../matrix/room/common";
export class CreateRoomViewModel extends ViewModel {
constructor(options) {
@ -89,7 +89,7 @@ export class CreateRoomViewModel extends ViewModel {
}
}
const roomBeingCreated = this._session.createRoom({
type: this.isPublic ? RoomVisibility.Public : RoomVisibility.Private,
type: this.isPublic ? RoomType.Public : RoomType.Private,
name: this._name ?? undefined,
topic: this._topic ?? undefined,
isEncrypted: !this.isPublic && this._isEncrypted,

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ObservableValue} from "../../observable/value/ObservableValue";
import {ObservableValue} from "../../observable/ObservableValue";
import {RoomStatus} from "../../matrix/room/common";
/**

View file

@ -99,9 +99,6 @@ export class SessionViewModel extends ViewModel {
start() {
this._sessionStatusViewModel.start();
this._client.session.callHandler.loadCalls("m.ring");
// TODO: only do this when opening the room
this._client.session.callHandler.loadCalls("m.prompt");
}
get activeMiddleViewModel() {
@ -177,7 +174,7 @@ export class SessionViewModel extends ViewModel {
_createRoomViewModelInstance(roomId) {
const room = this._client.session.rooms.get(roomId);
if (room) {
const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session}));
const roomVM = new RoomViewModel(this.childOptions({room}));
roomVM.load();
return roomVM;
}
@ -194,7 +191,7 @@ export class SessionViewModel extends ViewModel {
async _createArchivedRoomViewModel(roomId) {
const room = await this._client.session.loadArchivedRoom(roomId);
if (room) {
const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session}));
const roomVM = new RoomViewModel(this.childOptions({room}));
roomVM.load();
return roomVM;
}

View file

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

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import {ViewModel} from "../../ViewModel";
import {RoomVisibility} from "../../../matrix/room/common";
import {RoomType} from "../../../matrix/room/common";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
export class MemberDetailsViewModel extends ViewModel {
@ -87,7 +87,7 @@ export class MemberDetailsViewModel extends ViewModel {
let roomId = room?.id;
if (!roomId) {
const roomBeingCreated = await this._session.createRoom({
type: RoomVisibility.DirectMessage,
type: RoomType.DirectMessage,
invites: [this.userId]
});
roomId = roomBeingCreated.id;

View file

@ -1,182 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {AvatarSource} from "../../AvatarSource";
import {ViewModel, Options as BaseOptions} from "../../ViewModel";
import {getStreamVideoTrack, getStreamAudioTrack} from "../../../matrix/calls/common";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {EventObservableValue} from "../../../observable/value/EventObservableValue";
import {ObservableValueMap} from "../../../observable/map/ObservableValueMap";
import type {GroupCall} from "../../../matrix/calls/group/GroupCall";
import type {Member} from "../../../matrix/calls/group/Member";
import type {BaseObservableList} from "../../../observable/list/BaseObservableList";
import type {Stream} from "../../../platform/types/MediaDevices";
import type {MediaRepository} from "../../../matrix/net/MediaRepository";
type Options = BaseOptions & {
call: GroupCall,
mediaRepository: MediaRepository
};
export class CallViewModel extends ViewModel<Options> {
public readonly memberViewModels: BaseObservableList<IStreamViewModel>;
constructor(options: Options) {
super(options);
const ownMemberViewModelMap = new ObservableValueMap("self", new EventObservableValue(this.call, "change"))
.mapValues(call => new OwnMemberViewModel(this.childOptions({call: this.call, mediaRepository: this.getOption("mediaRepository")})), () => {});
this.memberViewModels = this.call.members
.filterValues(member => member.isConnected)
.mapValues(member => new CallMemberViewModel(this.childOptions({member, mediaRepository: this.getOption("mediaRepository")})))
.join(ownMemberViewModelMap)
.sortValues((a, b) => a.compare(b));
}
private get call(): GroupCall {
return this.getOption("call");
}
get name(): string {
return this.call.name;
}
get id(): string {
return this.call.id;
}
get stream(): Stream | undefined {
return this.call.localMedia?.userMedia;
}
leave() {
if (this.call.hasJoined) {
this.call.leave();
}
}
async toggleVideo() {
if (this.call.muteSettings) {
this.call.setMuted(this.call.muteSettings.toggleCamera());
}
}
}
type OwnMemberOptions = BaseOptions & {
call: GroupCall,
mediaRepository: MediaRepository
}
class OwnMemberViewModel extends ViewModel<OwnMemberOptions> implements IStreamViewModel {
get stream(): Stream | undefined {
return this.call.localMedia?.userMedia;
}
private get call(): GroupCall {
return this.getOption("call");
}
get isCameraMuted(): boolean {
return isMuted(this.call.muteSettings?.camera, !!getStreamVideoTrack(this.stream));
}
get isMicrophoneMuted(): boolean {
return isMuted(this.call.muteSettings?.microphone, !!getStreamAudioTrack(this.stream));
}
get avatarLetter(): string {
return "I";
}
get avatarColorNumber(): number {
return 3;
}
avatarUrl(size: number): string | undefined {
return undefined;
}
get avatarTitle(): string {
return "Me";
}
compare(other: OwnMemberViewModel | CallMemberViewModel): number {
return -1;
}
}
type MemberOptions = BaseOptions & {member: Member, mediaRepository: MediaRepository};
export class CallMemberViewModel extends ViewModel<MemberOptions> implements IStreamViewModel {
get stream(): Stream | undefined {
return this.member.remoteMedia?.userMedia;
}
private get member(): Member {
return this.getOption("member");
}
get isCameraMuted(): boolean {
return isMuted(this.member.remoteMuteSettings?.camera, !!getStreamVideoTrack(this.stream));
}
get isMicrophoneMuted(): boolean {
return isMuted(this.member.remoteMuteSettings?.microphone, !!getStreamAudioTrack(this.stream));
}
get avatarLetter(): string {
return avatarInitials(this.member.member.name);
}
get avatarColorNumber(): number {
return getIdentifierColorNumber(this.member.userId);
}
avatarUrl(size: number): string | undefined {
const {avatarUrl} = this.member.member;
const mediaRepository = this.getOption("mediaRepository");
return getAvatarHttpUrl(avatarUrl, size, this.platform, mediaRepository);
}
get avatarTitle(): string {
return this.member.member.name;
}
compare(other: OwnMemberViewModel | CallMemberViewModel): number {
if (other instanceof OwnMemberViewModel) {
return -other.compare(this);
}
const myUserId = this.member.member.userId;
const otherUserId = other.member.member.userId;
if(myUserId === otherUserId) {
return 0;
}
return myUserId < otherUserId ? -1 : 1;
}
}
export interface IStreamViewModel extends AvatarSource, ViewModel {
get stream(): Stream | undefined;
get isCameraMuted(): boolean;
get isMicrophoneMuted(): boolean;
}
function isMuted(muted: boolean | undefined, hasTrack: boolean) {
if (muted) {
return true;
} else {
return !hasTrack;
}
}

View file

@ -17,15 +17,13 @@ limitations under the License.
import {TimelineViewModel} from "./timeline/TimelineViewModel.js";
import {ComposerViewModel} from "./ComposerViewModel.js"
import {CallViewModel} from "./CallViewModel"
import {PickMapObservableValue} from "../../../observable/value/PickMapObservableValue";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {ViewModel} from "../../ViewModel";
import {imageToInfo} from "../common.js";
import {LocalMedia} from "../../../matrix/calls/LocalMedia";
// TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry
// this is a breaking SDK change though to make this option mandatory
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
import {RoomStatus} from "../../../matrix/room/common";
export class RoomViewModel extends ViewModel {
constructor(options) {
@ -40,36 +38,12 @@ export class RoomViewModel extends ViewModel {
this._sendError = null;
this._composerVM = null;
if (room.isArchived) {
this._composerVM = new ArchivedViewModel(this.childOptions({archivedRoom: room}));
this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room})));
} else {
this._composerVM = new ComposerViewModel(this);
this._recreateComposerOnPowerLevelChange();
}
this._clearUnreadTimout = null;
this._closeUrl = this.urlCreator.urlUntilSegment("session");
this._setupCallViewModel();
}
_setupCallViewModel() {
// pick call for this room with lowest key
const calls = this.getOption("session").callHandler.calls;
this._callObservable = new PickMapObservableValue(calls.filterValues(c => {
return c.roomId === this._room.id && c.hasJoined;
}));
this._callViewModel = undefined;
this.track(this._callObservable.subscribe(call => {
if (call && this._callViewModel && call.id === this._callViewModel.id) {
return;
}
this._callViewModel = this.disposeTracked(this._callViewModel);
if (call) {
this._callViewModel = this.track(new CallViewModel(this.childOptions({call, mediaRepository: this._room.mediaRepository})));
}
this.emitChange("callViewModel");
}));
const call = this._callObservable.get();
if (call) {
this._callViewModel = new CallViewModel(this.childOptions({call}));
}
}
async load() {
@ -77,7 +51,6 @@ export class RoomViewModel extends ViewModel {
try {
const timeline = await this._room.openTimeline();
this._tileOptions = this.childOptions({
session: this.getOption("session"),
roomVM: this,
timeline,
tileClassForEntry: this._tileClassForEntry,
@ -95,6 +68,30 @@ export class RoomViewModel extends ViewModel {
this._clearUnreadAfterDelay();
}
async _recreateComposerOnPowerLevelChange() {
const powerLevelObservable = await this._room.observePowerLevels();
const canSendMessage = () => powerLevelObservable.get().canSendType("m.room.message");
let oldCanSendMessage = canSendMessage();
const recreateComposer = newCanSendMessage => {
this._composerVM = this.disposeTracked(this._composerVM);
if (newCanSendMessage) {
this._composerVM = this.track(new ComposerViewModel(this));
}
else {
this._composerVM = this.track(new LowerPowerLevelViewModel(this.childOptions()));
}
this.emitChange("powerLevelObservable")
};
this.track(powerLevelObservable.subscribe(() => {
const newCanSendMessage = canSendMessage();
if (oldCanSendMessage !== newCanSendMessage) {
recreateComposer(newCanSendMessage);
oldCanSendMessage = newCanSendMessage;
}
}));
recreateComposer(oldCanSendMessage);
}
async _clearUnreadAfterDelay() {
if (this._room.isArchived || this._clearUnreadTimout) {
return;
@ -201,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) {
if (!this._room.isArchived && message) {
let messinfo = {type : "m.text", message : message};
if (message.startsWith("//")) {
messinfo.message = message.substring(1).trim();
} else if (message.startsWith("/")) {
messinfo = await this._processCommand(message);
}
try {
let msgtype = "m.text";
if (message.startsWith("/me ")) {
message = message.substr(4).trim();
msgtype = "m.emote";
}
if (replyingTo) {
await replyingTo.reply(msgtype, message);
} else {
await this._room.sendEvent("m.room.message", {msgtype, body: message});
const msgtype = messinfo.type;
const message = messinfo.message;
if (msgtype && message) {
if (replyingTo) {
await replyingTo.reply(msgtype, message);
} else {
await this._room.sendEvent("m.room.message", {msgtype, body: message});
}
}
} catch (err) {
console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`);
@ -345,10 +413,6 @@ export class RoomViewModel extends ViewModel {
return this._composerVM;
}
get callViewModel() {
return this._callViewModel;
}
openDetailsPanel() {
let path = this.navigation.path.until("room");
path = path.with(this.navigation.segment("right-panel", true));
@ -361,18 +425,10 @@ export class RoomViewModel extends ViewModel {
this._composerVM.setReplyingTo(entry);
}
}
async startCall() {
try {
const session = this.getOption("session");
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
const localMedia = new LocalMedia().withUserMedia(stream);
// this will set the callViewModel above as a call will be added to callHandler.calls
const call = await session.callHandler.createCall(this._room.id, "m.video", "A call " + Math.round(this.platform.random() * 100));
await call.join(localMedia);
} catch (err) {
console.error(err.stack);
}
dismissError() {
this._sendError = null;
this.emitChange("error");
}
}
@ -407,6 +463,16 @@ class ArchivedViewModel extends ViewModel {
}
get kind() {
return "archived";
return "disabled";
}
}
class LowerPowerLevelViewModel extends ViewModel {
get description() {
return this.i18n`You do not have the powerlevel necessary to send messages`;
}
get kind() {
return "disabled";
}
}

View file

@ -189,7 +189,7 @@ import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js";
// other imports
import {BaseMessageTile} from "./tiles/BaseMessageTile.js";
import {MappedList} from "../../../../observable/list/MappedList";
import {ObservableValue} from "../../../../observable/value/ObservableValue";
import {ObservableValue} from "../../../../observable/ObservableValue";
import {PowerLevels} from "../../../../matrix/room/PowerLevels.js";
export function tests() {

View file

@ -27,6 +27,29 @@ export class BaseMediaTile extends BaseMessageTile {
this._decryptedFile = null;
this._isVisible = false;
this._error = null;
this._downloading = false;
this._downloadError = null;
}
async downloadMedia() {
if (this._downloading || this.isPending) {
return;
}
const content = this._getContent();
const filename = content.body;
this._downloading = true;
this.emitChange("status");
let blob;
try {
blob = await this._mediaRepository.downloadAttachment(content);
this.platform.saveFileAs(blob, filename);
} catch (err) {
this._downloadError = err;
} finally {
blob?.dispose();
this._downloading = false;
}
this.emitChange("status");
}
get isUploading() {
@ -38,7 +61,7 @@ export class BaseMediaTile extends BaseMessageTile {
return pendingEvent && Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100);
}
get sendStatus() {
get status() {
const {pendingEvent} = this._entry;
switch (pendingEvent?.status) {
case SendStatus.Waiting:
@ -53,6 +76,12 @@ export class BaseMediaTile extends BaseMessageTile {
case SendStatus.Error:
return this.i18n`Error: ${pendingEvent.error.message}`;
default:
if (this._downloadError) {
return `Download failed`;
}
if (this._downloading) {
return this.i18n`Downloading…`;
}
return "";
}
}

View file

@ -49,6 +49,14 @@ export class BaseMessageTile extends SimpleTile {
return `https://matrix.to/#/${encodeURIComponent(this.sender)}`;
}
get displayName() {
return this._entry.displayName || this.sender;
}
get sender() {
return this._entry.sender;
}
get memberPanelLink() {
return `${this.urlCreator.urlUntilSegment("room")}/member/${this.sender}`;
}

View file

@ -1,94 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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 {SimpleTile} from "./SimpleTile.js";
import {LocalMedia} from "../../../../../matrix/calls/LocalMedia";
// TODO: timeline entries for state events with the same state key and type
// should also update previous entries in the timeline, so we can update the name of the call, whether it is terminated, etc ...
// alternatively, we could just subscribe to the GroupCall and spontanously emit an update when it updates
export class CallTile extends SimpleTile {
constructor(entry, options) {
super(entry, options);
const calls = this.getOption("session").callHandler.calls;
this._call = calls.get(this._entry.stateKey);
this._callSubscription = undefined;
if (this._call) {
this._callSubscription = this._call.disposableOn("change", () => {
// unsubscribe when terminated
if (this._call.isTerminated) {
this._callSubscription = this._callSubscription();
this._call = undefined;
}
this.emitChange();
});
}
}
get confId() {
return this._entry.stateKey;
}
get shape() {
return "call";
}
get name() {
return this._entry.content["m.name"];
}
get canJoin() {
return this._call && !this._call.hasJoined;
}
get canLeave() {
return this._call && this._call.hasJoined;
}
get label() {
if (this._call) {
if (this._call.hasJoined) {
return `Ongoing call (${this.name}, ${this.confId})`;
} else {
return `${this.displayName} started a call (${this.name}, ${this.confId})`;
}
} else {
return `Call finished, started by ${this.displayName} (${this.name}, ${this.confId})`;
}
}
async join() {
if (this.canJoin) {
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
const localMedia = new LocalMedia().withUserMedia(stream);
await this._call.join(localMedia);
}
}
async leave() {
if (this.canLeave) {
this._call.leave();
}
}
dispose() {
if (this._callSubscription) {
this._callSubscription = this._callSubscription();
}
}
}

View file

@ -159,12 +159,4 @@ export class SimpleTile extends ViewModel {
get _ownMember() {
return this._options.timeline.me;
}
get displayName() {
return this._entry.displayName || this.sender;
}
get sender() {
return this._entry.sender;
}
}

View file

@ -26,11 +26,9 @@ import {RoomMemberTile} from "./RoomMemberTile.js";
import {EncryptedEventTile} from "./EncryptedEventTile.js";
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
import {MissingAttachmentTile} from "./MissingAttachmentTile.js";
import {CallTile} from "./CallTile.js";
import type {SimpleTile} from "./SimpleTile.js";
import type {Room} from "../../../../../matrix/room/Room";
import type {Session} from "../../../../../matrix/Session";
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry";
@ -40,7 +38,6 @@ import type {Options as ViewModelOptions} from "../../../../ViewModel";
export type TimelineEntry = FragmentBoundaryEntry | EventEntry | PendingEventEntry;
export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined;
export type Options = ViewModelOptions & {
session: Session,
room: Room,
timeline: Timeline
tileClassForEntry: TileClassForEntryFn;
@ -89,14 +86,6 @@ export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undef
return EncryptedEventTile;
case "m.room.encryption":
return EncryptionEnabledTile;
case "org.matrix.msc3401.call": {
// if prevContent is present, it's an update to a call event, which we don't render
// as the original event is updated through the call object which receive state event updates
if (entry.stateKey && !entry.prevContent) {
return CallTile;
}
return undefined;
}
default:
// unknown type not rendered
return undefined;

View file

@ -17,7 +17,6 @@ limitations under the License.
import {ViewModel} from "../../ViewModel";
import {KeyType} from "../../../matrix/ssss/index";
import {createEnum} from "../../../utils/enum";
import {FlatMapObservableValue} from "../../../observable/value/FlatMapObservableValue";
export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable");
export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending");
@ -30,8 +29,8 @@ export class KeyBackupViewModel extends ViewModel {
this._isBusy = false;
this._dehydratedDeviceId = undefined;
this._status = undefined;
this._backupOperation = new FlatMapObservableValue(this._session.keyBackup, keyBackup => keyBackup.operationInProgress);
this._progress = new FlatMapObservableValue(this._backupOperation, op => op.progress);
this._backupOperation = this._session.keyBackup.flatMap(keyBackup => keyBackup.operationInProgress);
this._progress = this._backupOperation.flatMap(op => op.progress);
this.track(this._backupOperation.subscribe(() => {
// see if needsNewKey might be set
this._reevaluateStatus();

View file

@ -16,6 +16,7 @@ limitations under the License.
import {ViewModel} from "../../ViewModel";
import {KeyBackupViewModel} from "./KeyBackupViewModel.js";
import {submitLogsToRageshakeServer} from "../../../domain/rageshake";
class PushNotificationStatus {
constructor() {
@ -50,6 +51,8 @@ export class SettingsViewModel extends ViewModel {
this.minSentImageSizeLimit = 400;
this.maxSentImageSizeLimit = 4000;
this.pushNotifications = new PushNotificationStatus();
this._activeTheme = undefined;
this._logsFeedbackMessage = undefined;
}
get _session() {
@ -76,6 +79,9 @@ export class SettingsViewModel extends ViewModel {
this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
this.pushNotifications.supported = await this.platform.notificationService.supportsPush();
this.pushNotifications.enabled = await this._session.arePushNotificationsEnabled();
if (!import.meta.env.DEV) {
this._activeTheme = await this.platform.themeLoader.getActiveTheme();
}
this.emitChange("");
}
@ -127,6 +133,14 @@ export class SettingsViewModel extends ViewModel {
return this._formatBytes(this._estimate?.usage);
}
get themeMapping() {
return this.platform.themeLoader.themeMapping;
}
get activeTheme() {
return this._activeTheme;
}
_formatBytes(n) {
if (typeof n === "number") {
return Math.round(n / (1024 * 1024)).toFixed(1) + " MB";
@ -136,14 +150,53 @@ export class SettingsViewModel extends ViewModel {
}
async exportLogs() {
const logs = await this.exportLogsBlob();
this.platform.saveFileAs(logs, `hydrogen-logs-${this.platform.clock.now()}.json`);
const logExport = await this.logger.export();
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
}
async exportLogsBlob() {
const persister = this.logger.reporters.find(r => typeof r.export === "function");
const logExport = await persister.export();
return logExport.asBlob();
get canSendLogsToServer() {
return !!this.platform.config.bugReportEndpointUrl;
}
get logsServer() {
const {bugReportEndpointUrl} = this.platform.config;
try {
if (bugReportEndpointUrl) {
return new URL(bugReportEndpointUrl).hostname;
}
} catch (e) {}
return "";
}
async sendLogsToServer() {
const {bugReportEndpointUrl} = this.platform.config;
if (bugReportEndpointUrl) {
this._logsFeedbackMessage = this.i18n`Sending logs…`;
this.emitChange();
try {
const logExport = await this.logger.export();
await submitLogsToRageshakeServer(
{
app: "hydrogen",
userAgent: this.platform.description,
version: DEFINE_VERSION,
text: `Submit logs from settings for user ${this._session.userId} on device ${this._session.deviceId}`,
},
logExport.asBlob(),
bugReportEndpointUrl,
this.platform.request
);
this._logsFeedbackMessage = this.i18n`Logs sent succesfully!`;
this.emitChange();
} catch (err) {
this._logsFeedbackMessage = err.message;
this.emitChange();
}
}
}
get logsFeedbackMessage() {
return this._logsFeedbackMessage;
}
async togglePushNotifications() {
@ -175,5 +228,11 @@ export class SettingsViewModel extends ViewModel {
this.emitChange("pushNotifications.serverError");
}
}
changeThemeOption(themeName, themeVariant) {
this.platform.themeLoader.setTheme(themeName, themeVariant);
// emit so that radio-buttons become displayed/hidden
this.emitChange("themeOption");
}
}

View file

@ -14,24 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export {Logger} from "./logging/Logger";
export type {ILogItem} from "./logging/types";
export {IDBLogPersister} from "./logging/IDBLogPersister";
export {ConsoleReporter} from "./logging/ConsoleReporter";
export {Platform} from "./platform/web/Platform.js";
export {Client, LoadStatus} from "./matrix/Client.js";
export {RoomStatus} from "./matrix/room/common";
export {AttachmentUpload} from "./matrix/room/AttachmentUpload";
export {CallIntent} from "./matrix/calls/callEventTypes";
// export everything needed to observe state events on all rooms using session.observeRoomState
export type {RoomStateHandler} from "./matrix/room/state/types";
export type {MemberChange} from "./matrix/room/members/RoomMember";
export type {Transaction} from "./matrix/storage/idb/Transaction";
export type {Room} from "./matrix/room/Room";
export type {StateEvent} from "./matrix/storage/types";
// 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 {RootView} from "./platform/web/ui/RootView.js";
export {SessionViewModel} from "./domain/session/SessionViewModel.js";
@ -81,10 +68,9 @@ export {TemplateView} from "./platform/web/ui/general/TemplateView";
export {ViewModel} from "./domain/ViewModel";
export {LoadingView} from "./platform/web/ui/general/LoadingView.js";
export {AvatarView} from "./platform/web/ui/AvatarView.js";
export {RoomVisibility, RoomType} from "./matrix/room/common";
export {RoomType} from "./matrix/room/common";
export {EventEmitter} from "./utils/EventEmitter";
export {Disposables} from "./utils/Disposables";
export {LocalMedia} from "./matrix/calls/LocalMedia";
// these should eventually be moved to another library
export {
ObservableArray,
@ -94,6 +80,8 @@ export {
ConcatList,
ObservableMap
} from "./observable/index";
export {BaseObservableValue} from "./observable/value/BaseObservableValue";
export {ObservableValue} from "./observable/value/ObservableValue";
export {RetainedObservableValue} from "./observable/value/RetainedObservableValue";
export {
BaseObservableValue,
ObservableValue,
RetainedObservableValue
} from "./observable/ObservableValue";

View file

@ -17,17 +17,17 @@ limitations under the License.
import {LogItem} from "./LogItem";
import {LogLevel, LogFilter} from "./LogFilter";
import type {ILogger, ILogReporter, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types";
import type {ILogger, ILogExport, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types";
import type {Platform} from "../platform/web/Platform.js";
export class Logger implements ILogger {
export abstract class BaseLogger implements ILogger {
protected _openItems: Set<LogItem> = new Set();
protected _platform: Platform;
protected _serializedTransformer: (item: ISerializedItem) => ISerializedItem;
public readonly reporters: ILogReporter[] = [];
constructor({platform}) {
constructor({platform, serializedTransformer = (item: ISerializedItem) => item}) {
this._platform = platform;
this._serializedTransformer = serializedTransformer;
}
log(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info): void {
@ -36,15 +36,6 @@ export class Logger implements ILogger {
this._persistItem(item, undefined, false);
}
/** Prefer `run()` or `log()` above this method; only use it if you have a long-running operation
* *without* a single call stack that should be logged into one sub-tree.
* You need to call `finish()` on the returned item or it will stay open until the app unloads. */
child(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info, filterCreator?: FilterCreator): ILogItem {
const item = new DeferredPersistRootLogItem(labelOrValues, logLevel, this, filterCreator);
this._openItems.add(item);
return item;
}
/** if item is a log item, wrap the callback in a child of it, otherwise start a new root log item. */
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T {
if (item) {
@ -79,10 +70,10 @@ export class Logger implements ILogger {
return this._run(item, callback, logLevel, true, filterCreator);
}
private _run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T;
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T;
// we don't return if we don't throw, as we don't have anything to return when an error is caught but swallowed for the fire-and-forget case.
private _run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void;
private _run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void {
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void;
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void {
this._openItems.add(item);
const finishItem = () => {
@ -134,18 +125,9 @@ export class Logger implements ILogger {
}
}
addReporter(reporter: ILogReporter): void {
reporter.setLogger(this);
this.reporters.push(reporter);
}
getOpenRootItems(): Iterable<ILogItem> {
return this._openItems;
}
forceFinish() {
_finishOpenItems() {
for (const openItem of this._openItems) {
openItem.forceFinish();
openItem.finish();
try {
// for now, serialize with an all-permitting filter
// as the createFilter function would get a distorted image anyway
@ -159,37 +141,20 @@ export class Logger implements ILogger {
this._openItems.clear();
}
/** @internal */
_persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void {
for (var i = 0; i < this.reporters.length; i += 1) {
this.reporters[i].reportItem(item, filter, forced);
}
}
abstract _persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void;
abstract export(): Promise<ILogExport | undefined>;
// expose log level without needing
get level(): typeof LogLevel {
return LogLevel;
}
/** @internal */
_now(): number {
return this._platform.clock.now();
}
/** @internal */
_createRefId(): number {
return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER);
}
}
class DeferredPersistRootLogItem extends LogItem {
finish() {
super.finish();
(this._logger as Logger)._persistItem(this, undefined, false);
}
forceFinish() {
super.finish();
/// no need to persist when force-finishing as _finishOpenItems above will do it
}
}

View file

@ -13,28 +13,17 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {BaseLogger} from "./BaseLogger";
import {LogItem} from "./LogItem";
import type {ILogItem, LogItemValues, ILogExport} from "./types";
import type {ILogger, ILogItem, LogItemValues, ILogReporter} from "./types";
import type {LogItem} from "./LogItem";
export class ConsoleReporter implements ILogReporter {
private logger?: ILogger;
reportItem(item: ILogItem): void {
printToConsole(item as LogItem);
export class ConsoleLogger extends BaseLogger {
_persistItem(item: LogItem): void {
printToConsole(item);
}
setLogger(logger: ILogger) {
this.logger = logger;
}
printOpenItems(): void {
if (!this.logger) {
return;
}
for (const item of this.logger.getOpenRootItems()) {
this.reportItem(item);
}
async export(): Promise<ILogExport | undefined> {
return undefined;
}
}
@ -50,7 +39,7 @@ function filterValues(values: LogItemValues): LogItemValues | null {
}
function printToConsole(item: LogItem): void {
const label = `${itemCaption(item)} (@${item.start}ms, duration: ${item.duration}ms)`;
const label = `${itemCaption(item)} (${item.duration}ms)`;
const filteredValues = filterValues(item.values);
const shouldGroup = item.children || filteredValues;
if (shouldGroup) {
@ -89,8 +78,6 @@ function itemCaption(item: ILogItem): string {
return `${item.values.l} ${item.values.id}`;
} else if (item.values.l && typeof item.values.status !== "undefined") {
return `${item.values.l} (${item.values.status})`;
} else if (item.values.l && typeof item.values.type !== "undefined") {
return `${item.values.l} (${item.values.type})`;
} else if (item.values.l && item.error) {
return `${item.values.l} failed`;
} else if (typeof item.values.ref !== "undefined") {

View file

@ -22,69 +22,36 @@ import {
iterateCursor,
fetchResults,
} from "../matrix/storage/idb/utils";
import {BaseLogger} from "./BaseLogger";
import type {Interval} from "../platform/web/dom/Clock";
import type {Platform} from "../platform/web/Platform.js";
import type {BlobHandle} from "../platform/web/dom/BlobHandle.js";
import type {ILogItem, ILogger, ILogReporter, ISerializedItem} from "./types";
import {LogFilter} from "./LogFilter";
import type {ILogItem, ILogExport, ISerializedItem} from "./types";
import type {LogFilter} from "./LogFilter";
type QueuedItem = {
json: string;
id?: number;
}
type Options = {
name: string,
flushInterval?: number,
limit?: number,
platform: Platform,
serializedTransformer?: (item: ISerializedItem) => ISerializedItem
}
export class IDBLogPersister implements ILogReporter {
export class IDBLogger extends BaseLogger {
private readonly _name: string;
private readonly _limit: number;
private readonly _flushInterval: Interval;
private _queuedItems: QueuedItem[];
private readonly options: Options;
private logger?: ILogger;
constructor(options: Options) {
this.options = options;
constructor(options: {name: string, flushInterval?: number, limit?: number, platform: Platform, serializedTransformer?: (item: ISerializedItem) => ISerializedItem}) {
super(options);
const {name, flushInterval = 60 * 1000, limit = 3000} = options;
this._name = name;
this._limit = limit;
this._queuedItems = this._loadQueuedItems();
// TODO: also listen for unload just in case sync keeps on running after pagehide is fired?
window.addEventListener("pagehide", this, false);
this._flushInterval = this.options.platform.clock.createInterval(
() => this._tryFlush(),
this.options.flushInterval ?? 60 * 1000
);
this._flushInterval = this._platform.clock.createInterval(() => this._tryFlush(), flushInterval);
}
setLogger(logger: ILogger): void {
this.logger = logger;
}
reportItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void {
const queuedItem = this.prepareItemForQueue(logItem, filter, forced);
if (queuedItem) {
this._queuedItems.push(queuedItem);
}
}
async export(): Promise<IDBLogExport> {
const db = await this._openDB();
try {
const txn = db.transaction(["logs"], "readonly");
const logs = txn.objectStore("logs");
const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false);
const openItems = this.getSerializedOpenItems();
const allItems = storedItems.concat(this._queuedItems).concat(openItems);
return new IDBLogExport(allItems, this, this.options.platform);
} finally {
try {
db.close();
} catch (e) {}
}
}
// TODO: move dispose to ILogger, listen to pagehide elsewhere and call dispose from there, which calls _finishAllAndFlush
dispose(): void {
window.removeEventListener("pagehide", this, false);
this._flushInterval.dispose();
@ -96,7 +63,7 @@ export class IDBLogPersister implements ILogReporter {
}
}
private async _tryFlush(): Promise<void> {
async _tryFlush(): Promise<void> {
const db = await this._openDB();
try {
const txn = db.transaction(["logs"], "readwrite");
@ -106,10 +73,9 @@ export class IDBLogPersister implements ILogReporter {
logs.add(i);
}
const itemCount = await reqAsPromise(logs.count());
const limit = this.options.limit ?? 3000;
if (itemCount > limit) {
if (itemCount > this._limit) {
// delete an extra 10% so we don't need to delete every time we flush
let deleteAmount = (itemCount - limit) + Math.round(0.1 * limit);
let deleteAmount = (itemCount - this._limit) + Math.round(0.1 * this._limit);
await iterateCursor(logs.openCursor(), (_, __, cursor) => {
cursor.delete();
deleteAmount -= 1;
@ -127,16 +93,14 @@ export class IDBLogPersister implements ILogReporter {
}
}
private _finishAllAndFlush(): void {
if (this.logger) {
this.logger.log({l: "pagehide, closing logs", t: "navigation"});
this.logger.forceFinish();
}
_finishAllAndFlush(): void {
this._finishOpenItems();
this.log({l: "pagehide, closing logs", t: "navigation"});
this._persistQueuedItems(this._queuedItems);
}
private _loadQueuedItems(): QueuedItem[] {
const key = `${this.options.name}_queuedItems`;
_loadQueuedItems(): QueuedItem[] {
const key = `${this._name}_queuedItems`;
try {
const json = window.localStorage.getItem(key);
if (json) {
@ -149,32 +113,44 @@ export class IDBLogPersister implements ILogReporter {
return [];
}
private _openDB(): Promise<IDBDatabase> {
return openDatabase(this.options.name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1);
_openDB(): Promise<IDBDatabase> {
return openDatabase(this._name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1);
}
private prepareItemForQueue(logItem: ILogItem, filter: LogFilter, forced: boolean): QueuedItem | undefined {
let serializedItem = logItem.serialize(filter, undefined, forced);
_persistItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void {
const serializedItem = logItem.serialize(filter, undefined, forced);
if (serializedItem) {
if (this.options.serializedTransformer) {
serializedItem = this.options.serializedTransformer(serializedItem);
}
return {
json: JSON.stringify(serializedItem)
};
const transformedSerializedItem = this._serializedTransformer(serializedItem);
this._queuedItems.push({
json: JSON.stringify(transformedSerializedItem)
});
}
}
private _persistQueuedItems(items: QueuedItem[]): void {
_persistQueuedItems(items: QueuedItem[]): void {
try {
window.localStorage.setItem(`${this.options.name}_queuedItems`, JSON.stringify(items));
window.localStorage.setItem(`${this._name}_queuedItems`, JSON.stringify(items));
} catch (e) {
console.error("Could not persist queued log items in localStorage, they will likely be lost", e);
}
}
/** @internal called by ILogExport.removeFromStore */
async removeItems(items: QueuedItem[]): Promise<void> {
async export(): Promise<ILogExport> {
const db = await this._openDB();
try {
const txn = db.transaction(["logs"], "readonly");
const logs = txn.objectStore("logs");
const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false);
const allItems = storedItems.concat(this._queuedItems);
return new IDBLogExport(allItems, this, this._platform);
} finally {
try {
db.close();
} catch (e) {}
}
}
async _removeItems(items: QueuedItem[]): Promise<void> {
const db = await this._openDB();
try {
const txn = db.transaction(["logs"], "readwrite");
@ -197,29 +173,14 @@ export class IDBLogPersister implements ILogReporter {
} catch (e) {}
}
}
private getSerializedOpenItems(): QueuedItem[] {
const openItems: QueuedItem[] = [];
if (!this.logger) {
return openItems;
}
const filter = new LogFilter();
for(const item of this.logger!.getOpenRootItems()) {
const openItem = this.prepareItemForQueue(item, filter, false);
if (openItem) {
openItems.push(openItem);
}
}
return openItems;
}
}
export class IDBLogExport {
class IDBLogExport implements ILogExport {
private readonly _items: QueuedItem[];
private readonly _logger: IDBLogPersister;
private readonly _logger: IDBLogger;
private readonly _platform: Platform;
constructor(items: QueuedItem[], logger: IDBLogPersister, platform: Platform) {
constructor(items: QueuedItem[], logger: IDBLogger, platform: Platform) {
this._items = items;
this._logger = logger;
this._platform = platform;
@ -233,23 +194,18 @@ export class IDBLogExport {
* @return {Promise}
*/
removeFromStore(): Promise<void> {
return this._logger.removeItems(this._items);
return this._logger._removeItems(this._items);
}
asBlob(): BlobHandle {
const json = this.toJSON();
const buffer: Uint8Array = this._platform.encoding.utf8.encode(json);
const blob: BlobHandle = this._platform.createBlob(buffer, "application/json");
return blob;
}
toJSON(): string {
const log = {
formatVersion: 1,
appVersion: this._platform.updateService?.version,
items: this._items.map(i => JSON.parse(i.json))
};
const json = JSON.stringify(log);
return json;
const buffer: Uint8Array = this._platform.encoding.utf8.encode(json);
const blob: BlobHandle = this._platform.createBlob(buffer, "application/json");
return blob;
}
}

View file

@ -16,7 +16,7 @@ limitations under the License.
*/
import {LogLevel, LogFilter} from "./LogFilter";
import type {Logger} from "./Logger";
import type {BaseLogger} from "./BaseLogger";
import type {ISerializedItem, ILogItem, LogItemValues, LabelOrValues, FilterCreator, LogCallback} from "./types";
export class LogItem implements ILogItem {
@ -25,11 +25,11 @@ export class LogItem implements ILogItem {
public error?: Error;
public end?: number;
private _values: LogItemValues;
protected _logger: Logger;
private _logger: BaseLogger;
private _filterCreator?: FilterCreator;
private _children?: Array<LogItem>;
constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: Logger, filterCreator?: FilterCreator) {
constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: BaseLogger, filterCreator?: FilterCreator) {
this._logger = logger;
this.start = logger._now();
// (l)abel
@ -38,7 +38,7 @@ export class LogItem implements ILogItem {
this._filterCreator = filterCreator;
}
/** start a new root log item and run it detached mode, see Logger.runDetached */
/** start a new root log item and run it detached mode, see BaseLogger.runDetached */
runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem {
return this._logger.runDetached(labelOrValues, callback, logLevel, filterCreator);
}
@ -221,11 +221,6 @@ export class LogItem implements ILogItem {
}
}
/** @internal */
forceFinish(): void {
this.finish();
}
// expose log level without needing import everywhere
get level(): typeof LogLevel {
return LogLevel;
@ -240,7 +235,7 @@ export class LogItem implements ILogItem {
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): LogItem {
if (this.end) {
console.trace(`log item ${this.values.l} finished, additional log ${JSON.stringify(labelOrValues)} will likely not be recorded`);
console.trace("log item is finished, additional logs will likely not be recorded");
}
if (!logLevel) {
logLevel = this.logLevel || LogLevel.Info;
@ -253,7 +248,7 @@ export class LogItem implements ILogItem {
return item;
}
get logger(): Logger {
get logger(): BaseLogger {
return this._logger;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {LogLevel} from "./LogFilter";
import type {ILogger, ILogItem, ILogReporter, LabelOrValues, LogCallback, LogItemValues} from "./types";
import type {ILogger, ILogExport, ILogItem, LabelOrValues, LogCallback, LogItemValues} from "./types";
function noop (): void {}
@ -23,22 +23,6 @@ export class NullLogger implements ILogger {
log(): void {}
addReporter() {}
get reporters(): ReadonlyArray<ILogReporter> {
return [];
}
getOpenRootItems(): Iterable<ILogItem> {
return [];
}
forceFinish(): void {}
child(): ILogItem {
return this.item;
}
run<T>(_, callback: LogCallback<T>): T {
return callback(this.item);
}
@ -55,7 +39,11 @@ export class NullLogger implements ILogger {
new Promise(r => r(callback(this.item))).then(noop, noop);
return this.item;
}
async export(): Promise<ILogExport | undefined> {
return undefined;
}
get level(): typeof LogLevel {
return LogLevel;
}
@ -73,18 +61,12 @@ export class NullLogItem implements ILogItem {
}
wrap<T>(_: LabelOrValues, callback: LogCallback<T>): T {
return this.run(callback);
}
run<T>(callback: LogCallback<T>): T {
return callback(this);
}
log(): ILogItem {
return this;
}
set(): ILogItem { return this; }
runDetached(_: LabelOrValues, callback: LogCallback<unknown>): ILogItem {
@ -117,7 +99,6 @@ export class NullLogItem implements ILogItem {
}
finish(): void {}
forceFinish(): void {}
serialize(): undefined {
return undefined;

View file

@ -16,6 +16,7 @@ limitations under the License.
*/
import {LogLevel, LogFilter} from "./LogFilter";
import type {BaseLogger} from "./BaseLogger";
import type {BlobHandle} from "../platform/web/dom/BlobHandle.js";
export interface ISerializedItem {
@ -39,10 +40,8 @@ export interface ILogItem {
readonly level: typeof LogLevel;
readonly end?: number;
readonly start?: number;
readonly values: Readonly<LogItemValues>;
readonly values: LogItemValues;
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
/*** This is sort of low-level, you probably want to use wrap. If you do use it, it should only be called once. */
run<T>(callback: LogCallback<T>): T;
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem;
set(key: string | object, value: unknown): ILogItem;
runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
@ -52,41 +51,22 @@ export interface ILogItem {
catch(err: Error): Error;
serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined;
finish(): void;
forceFinish(): void;
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
}
/*
extend both ILogger and ILogItem from this interface, but need to rename ILogger.run => wrap then. Or both to `span`?
export interface ILogItemCreator {
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
refDetached(logItem: ILogItem, logLevel?: LogLevel): void;
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem;
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
get level(): typeof LogLevel;
}
*/
export interface ILogger {
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): void;
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
runDetached<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
run<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
export(): Promise<ILogExport | undefined>;
get level(): typeof LogLevel;
getOpenRootItems(): Iterable<ILogItem>;
addReporter(reporter: ILogReporter): void;
get reporters(): ReadonlyArray<ILogReporter>;
/**
* force-finishes any open items and passes them to the reporter, with the forced flag set.
* Good think to do when the page is being closed to not lose any logs.
**/
forceFinish(): void;
}
export interface ILogReporter {
setLogger(logger: ILogger): void;
reportItem(item: ILogItem, filter?: LogFilter, forced?: boolean): void;
export interface ILogExport {
get count(): number;
removeFromStore(): Promise<void>;
asBlob(): BlobHandle;
}
export type LogItemValues = {

View file

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

View file

@ -16,15 +16,12 @@ limitations under the License.
import {OLM_ALGORITHM} from "./e2ee/common.js";
import {countBy, groupBy} from "../utils/groupBy";
import {LRUCache} from "../utils/LRUCache";
export class DeviceMessageHandler {
constructor({storage, callHandler}) {
constructor({storage}) {
this._storage = storage;
this._olmDecryption = null;
this._megolmDecryption = null;
this._callHandler = callHandler;
this._senderDeviceCache = new LRUCache(10, di => di.curve25519Key);
}
enableEncryption({olmDecryption, megolmDecryption}) {
@ -38,7 +35,6 @@ export class DeviceMessageHandler {
async prepareSync(toDeviceEvents, lock, txn, log) {
log.set("messageTypes", countBy(toDeviceEvents, e => e.type));
this._handleUnencryptedCallEvents(toDeviceEvents, log);
const encryptedEvents = toDeviceEvents.filter(e => e.type === "m.room.encrypted");
if (!this._olmDecryption) {
log.log("can't decrypt, encryption not enabled", log.level.Warn);
@ -53,38 +49,10 @@ export class DeviceMessageHandler {
log.child("decrypt_error").catch(err);
}
const newRoomKeys = this._megolmDecryption.roomKeysFromDeviceMessages(olmDecryptChanges.results, log);
// const callMessages = olmDecryptChanges.results.filter(dr => this._callHandler.handlesDeviceMessageEventType(dr.event?.type));
// // load devices by sender key
// await Promise.all(callMessages.map(async dr => {
// dr.setDevice(await this._getDevice(dr.senderCurve25519Key, txn));
// }));
// // TODO: pass this in the prep and run it in afterSync or afterSyncComplete (as callHandler can send events as well)?
// for (const dr of callMessages) {
// if (dr.device) {
// this._callHandler.handleDeviceMessage(dr.event, dr.device.userId, dr.device.deviceId, log);
// } else {
// console.error("could not deliver message because don't have device for sender key", dr.event);
// }
// }
// TODO: somehow include rooms that received a call to_device message in the sync state?
// or have updates flow through event emitter?
// well, we don't really need to update the room other then when a call starts or stops
// any changes within the call will be emitted on the call object?
return new SyncPreparation(olmDecryptChanges, newRoomKeys);
}
}
_handleUnencryptedCallEvents(toDeviceEvents, log) {
const callMessages = toDeviceEvents.filter(e => this._callHandler.handlesDeviceMessageEventType(e.type));
for (const event of callMessages) {
const userId = event.sender;
const deviceId = event.content.device_id;
this._callHandler.handleDeviceMessage(event, userId, deviceId, log);
}
}
/** check that prep is not undefined before calling this */
async writeSync(prep, txn) {
// write olm changes
@ -92,18 +60,6 @@ export class DeviceMessageHandler {
const didWriteValues = await Promise.all(prep.newRoomKeys.map(key => this._megolmDecryption.writeRoomKey(key, txn)));
return didWriteValues.some(didWrite => !!didWrite);
}
async _getDevice(senderKey, txn) {
let device = this._senderDeviceCache.get(senderKey);
if (!device) {
device = await txn.deviceIdentities.getByCurve25519Key(senderKey);
if (device) {
this._senderDeviceCache.set(device);
}
}
return device;
}
}
class SyncPreparation {

View file

@ -21,7 +21,7 @@ import {RoomStatus} from "./room/common";
import {RoomBeingCreated} from "./room/RoomBeingCreated";
import {Invite} from "./room/Invite.js";
import {Pusher} from "./push/Pusher";
import { ObservableMap } from "../observable/index";
import { ObservableMap } from "../observable/index.js";
import {User} from "./User.js";
import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
import {Account as E2EEAccount} from "./e2ee/Account.js";
@ -45,10 +45,7 @@ import {
keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey
} from "./ssss/index";
import {SecretStorage} from "./ssss/SecretStorage";
import {ObservableValue} from "../observable/value/ObservableValue";
import {RetainedObservableValue} from "../observable/value/RetainedObservableValue";
import {CallHandler} from "./calls/CallHandler";
import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet";
import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue";
const PICKLE_KEY = "DEFAULT_KEY";
const PUSHER_KEY = "pusher";
@ -76,35 +73,7 @@ export class Session {
};
this._roomsBeingCreated = new ObservableMap();
this._user = new User(sessionInfo.userId);
this._callHandler = new CallHandler({
clock: this._platform.clock,
hsApi: this._hsApi,
encryptDeviceMessage: async (roomId, userId, message, log) => {
if (!this._deviceTracker || !this._olmEncryption) {
throw new Error("encryption is not enabled");
}
// TODO: just get the devices we're sending the message to, not all the room devices
// although we probably already fetched all devices to send messages in the likely e2ee room
const devices = await log.wrap("get device keys", async log => {
await this._deviceTracker.trackRoom(this.rooms.get(roomId), log);
return this._deviceTracker.devicesForRoomMembers(roomId, [userId], this._hsApi, log);
});
const encryptedMessage = await this._olmEncryption.encrypt(message.type, message.content, devices, this._hsApi, log);
return encryptedMessage;
},
storage: this._storage,
webRTC: this._platform.webRTC,
ownDeviceId: sessionInfo.deviceId,
ownUserId: sessionInfo.userId,
logger: this._platform.logger,
turnServers: [{
urls: ["stun:turn.matrix.org"],
}],
forceTURN: false,
});
this._roomStateHandler = new RoomStateHandlerSet();
this.observeRoomState(this._callHandler);
this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler});
this._deviceMessageHandler = new DeviceMessageHandler({storage});
this._olm = olm;
this._olmUtil = null;
this._e2eeAccount = null;
@ -149,10 +118,6 @@ export class Session {
return this._sessionInfo.userId;
}
get callHandler() {
return this._callHandler;
}
// called once this._e2eeAccount is assigned
_setupEncryption() {
// TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account
@ -597,8 +562,7 @@ export class Session {
pendingEvents,
user: this._user,
createRoomEncryption: this._createRoomEncryption,
platform: this._platform,
roomStateHandler: this._roomStateHandler
platform: this._platform
});
}
@ -940,10 +904,6 @@ export class Session {
return observable;
}
observeRoomState(roomStateHandler) {
return this._roomStateHandler.subscribe(roomStateHandler);
}
/**
Creates an empty (summary isn't loaded) the archived room if it isn't
loaded already, assuming sync will either remove it (when rejoining) or
@ -1023,18 +983,9 @@ export function tests() {
return {
"session data is not modified until after sync": async (assert) => {
const storage = createStorageMock({
const session = new Session({storage: createStorageMock({
sync: {token: "a", filterId: 5}
});
const session = new Session({
storage,
sessionInfo: {userId: ""},
platform: {
clock: {
createTimeout: () => undefined
}
}
});
}), sessionInfo: {userId: ""}});
await session.load();
let syncSet = false;
const syncTxn = {

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ObservableValue} from "../observable/value/ObservableValue";
import {ObservableValue} from "../observable/ObservableValue";
import {createEnum} from "../utils/enum";
const INCREMENTAL_TIMEOUT = 30000;
@ -224,7 +224,6 @@ export class Sync {
_openPrepareSyncTxn() {
const storeNames = this._storage.storeNames;
return this._storage.readTxn([
storeNames.deviceIdentities, // to read device from olm messages
storeNames.olmSessions,
storeNames.inboundGroupSessions,
// to read fragments when loading sync writer when rejoining archived room
@ -344,7 +343,6 @@ export class Sync {
// to decrypt and store new room keys
storeNames.olmSessions,
storeNames.inboundGroupSessions,
storeNames.calls,
]);
}

View file

@ -1,226 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ObservableMap} from "../../observable/map/ObservableMap";
import {WebRTC, PeerConnection} from "../../platform/types/WebRTC";
import {MediaDevices, Track} from "../../platform/types/MediaDevices";
import {handlesEventType} from "./PeerCall";
import {EventType, CallIntent} from "./callEventTypes";
import {GroupCall} from "./group/GroupCall";
import {makeId} from "../common";
import {CALL_LOG_TYPE} from "./common";
import type {LocalMedia} from "./LocalMedia";
import type {Room} from "../room/Room";
import type {MemberChange} from "../room/members/RoomMember";
import type {StateEvent} from "../storage/types";
import type {ILogItem, ILogger} from "../../logging/types";
import type {Platform} from "../../platform/web/Platform";
import type {BaseObservableMap} from "../../observable/map/BaseObservableMap";
import type {SignallingMessage, MGroupCallBase} from "./callEventTypes";
import type {Options as GroupCallOptions} from "./group/GroupCall";
import type {Transaction} from "../storage/idb/Transaction";
import type {CallEntry} from "../storage/idb/stores/CallStore";
import type {Clock} from "../../platform/web/dom/Clock";
import type {RoomStateHandler} from "../room/state/types";
export type Options = Omit<GroupCallOptions, "emitUpdate" | "createTimeout"> & {
clock: Clock
};
function getRoomMemberKey(roomId: string, userId: string): string {
return JSON.stringify(roomId)+`,`+JSON.stringify(userId);
}
export class CallHandler implements RoomStateHandler {
// group calls by call id
private readonly _calls: ObservableMap<string, GroupCall> = new ObservableMap<string, GroupCall>();
// map of `"roomId","userId"` to set of conf_id's they are in
private roomMemberToCallIds: Map<string, Set<string>> = new Map();
private groupCallOptions: GroupCallOptions;
private sessionId = makeId("s");
constructor(private readonly options: Options) {
this.groupCallOptions = Object.assign({}, this.options, {
emitUpdate: (groupCall, params) => this._calls.update(groupCall.id, params),
createTimeout: this.options.clock.createTimeout,
sessionId: this.sessionId
});
}
async loadCalls(intent: CallIntent = CallIntent.Ring) {
const txn = await this._getLoadTxn();
const callEntries = await txn.calls.getByIntent(intent);
this._loadCallEntries(callEntries, txn);
}
async loadCallsForRoom(intent: CallIntent, roomId: string) {
const txn = await this._getLoadTxn();
const callEntries = await txn.calls.getByIntentAndRoom(intent, roomId);
this._loadCallEntries(callEntries, txn);
}
private async _getLoadTxn(): Promise<Transaction> {
const names = this.options.storage.storeNames;
const txn = await this.options.storage.readTxn([
names.calls,
names.roomState
]);
return txn;
}
private async _loadCallEntries(callEntries: CallEntry[], txn: Transaction): Promise<void> {
return this.options.logger.run({l: "loading calls", t: CALL_LOG_TYPE}, async log => {
log.set("entries", callEntries.length);
await Promise.all(callEntries.map(async callEntry => {
if (this._calls.get(callEntry.callId)) {
return;
}
const event = await txn.roomState.get(callEntry.roomId, EventType.GroupCall, callEntry.callId);
if (event) {
const call = new GroupCall(event.event.state_key, false, event.event.content, event.roomId, this.groupCallOptions);
this._calls.set(call.id, call);
}
}));
const roomIds = Array.from(new Set(callEntries.map(e => e.roomId)));
await Promise.all(roomIds.map(async roomId => {
// const ownCallsMemberEvent = await txn.roomState.get(roomId, EventType.GroupCallMember, this.options.ownUserId);
// if (ownCallsMemberEvent) {
// this.handleCallMemberEvent(ownCallsMemberEvent.event, log);
// }
const callsMemberEvents = await txn.roomState.getAllForType(roomId, EventType.GroupCallMember);
for (const entry of callsMemberEvents) {
this.handleCallMemberEvent(entry.event, roomId, log);
}
// TODO: we should be loading the other members as well at some point
}));
log.set("newSize", this._calls.size);
});
}
async createCall(roomId: string, type: "m.video" | "m.voice", name: string, intent: CallIntent = CallIntent.Ring): Promise<GroupCall> {
const call = new GroupCall(makeId("conf-"), true, {
"m.name": name,
"m.intent": intent
}, roomId, this.groupCallOptions);
this._calls.set(call.id, call);
try {
await call.create(type);
// store call info so it will ring again when reopening the app
const txn = await this.options.storage.readWriteTxn([this.options.storage.storeNames.calls]);
txn.calls.add({
intent: call.intent,
callId: call.id,
timestamp: this.options.clock.now(),
roomId: roomId
});
await txn.complete();
} catch (err) {
//if (err.name === "ConnectionError") {
// if we're offline, give up and remove the call again
this._calls.remove(call.id);
//}
throw err;
}
return call;
}
get calls(): BaseObservableMap<string, GroupCall> { return this._calls; }
// TODO: check and poll turn server credentials here
/** @internal */
handleRoomState(room: Room, event: StateEvent, txn: Transaction, log: ILogItem) {
if (event.type === EventType.GroupCall) {
this.handleCallEvent(event, room.id, txn, log);
}
if (event.type === EventType.GroupCallMember) {
this.handleCallMemberEvent(event, room.id, log);
}
}
/** @internal */
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>) {
// TODO: also have map for roomId to calls, so we can easily update members
// we will also need this to get the call for a room
}
/** @internal */
handlesDeviceMessageEventType(eventType: string): boolean {
return handlesEventType(eventType);
}
/** @internal */
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, userId: string, deviceId: string, log: ILogItem) {
// TODO: buffer messages for calls we haven't received the state event for yet?
const call = this._calls.get(message.content.conf_id);
call?.handleDeviceMessage(message, userId, deviceId, log);
}
private handleCallEvent(event: StateEvent, roomId: string, txn: Transaction, log: ILogItem) {
const callId = event.state_key;
let call = this._calls.get(callId);
if (call) {
call.updateCallEvent(event.content, log);
if (call.isTerminated) {
call.disconnect(log);
this._calls.remove(call.id);
txn.calls.remove(call.intent, roomId, call.id);
}
} else {
call = new GroupCall(event.state_key, false, event.content, roomId, this.groupCallOptions);
this._calls.set(call.id, call);
txn.calls.add({
intent: call.intent,
callId: call.id,
timestamp: event.origin_server_ts,
roomId: roomId
});
}
}
private handleCallMemberEvent(event: StateEvent, roomId: string, log: ILogItem) {
const userId = event.state_key;
const roomMemberKey = getRoomMemberKey(roomId, userId)
const calls = event.content["m.calls"] ?? [];
const eventTimestamp = event.origin_server_ts;
for (const call of calls) {
const callId = call["m.call_id"];
const groupCall = this._calls.get(callId);
// TODO: also check the member when receiving the m.call event
groupCall?.updateMembership(userId, call, eventTimestamp, log);
};
const newCallIdsMemberOf = new Set<string>(calls.map(call => call["m.call_id"]));
let previousCallIdsMemberOf = this.roomMemberToCallIds.get(roomMemberKey);
// remove user as member of any calls not present anymore
if (previousCallIdsMemberOf) {
for (const previousCallId of previousCallIdsMemberOf) {
if (!newCallIdsMemberOf.has(previousCallId)) {
const groupCall = this._calls.get(previousCallId);
groupCall?.removeMembership(userId, log);
}
}
}
if (newCallIdsMemberOf.size === 0) {
this.roomMemberToCallIds.delete(roomMemberKey);
} else {
this.roomMemberToCallIds.set(roomMemberKey, newCallIdsMemberOf);
}
}
}

View file

@ -1,81 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {SDPStreamMetadataPurpose} from "./callEventTypes";
import {Stream} from "../../platform/types/MediaDevices";
import {SDPStreamMetadata} from "./callEventTypes";
import {getStreamVideoTrack, getStreamAudioTrack} from "./common";
export class LocalMedia {
constructor(
public readonly userMedia?: Stream,
public readonly screenShare?: Stream,
public readonly dataChannelOptions?: RTCDataChannelInit,
) {}
withUserMedia(stream: Stream) {
return new LocalMedia(stream, this.screenShare, this.dataChannelOptions);
}
withScreenShare(stream: Stream) {
return new LocalMedia(this.userMedia, stream, this.dataChannelOptions);
}
withDataChannel(options: RTCDataChannelInit): LocalMedia {
return new LocalMedia(this.userMedia, this.screenShare, options);
}
/** @internal */
replaceClone(oldClone: LocalMedia | undefined, oldOriginal: LocalMedia | undefined): LocalMedia {
let userMedia;
let screenShare;
const cloneOrAdoptStream = (oldOriginalStream: Stream | undefined, oldCloneStream: Stream | undefined, newStream: Stream | undefined): Stream | undefined => {
let stream;
if (oldOriginalStream?.id === newStream?.id) {
stream = oldCloneStream;
} else {
stream = newStream?.clone();
getStreamAudioTrack(oldCloneStream)?.stop();
getStreamVideoTrack(oldCloneStream)?.stop();
}
return stream;
}
return new LocalMedia(
cloneOrAdoptStream(oldOriginal?.userMedia, oldClone?.userMedia, this.userMedia),
cloneOrAdoptStream(oldOriginal?.screenShare, oldClone?.screenShare, this.screenShare),
this.dataChannelOptions
);
}
/** @internal */
clone(): LocalMedia {
return new LocalMedia(this.userMedia?.clone(),this.screenShare?.clone(), this.dataChannelOptions);
}
dispose() {
this.stopExcept(undefined);
}
stopExcept(newMedia: LocalMedia | undefined) {
if(newMedia?.userMedia?.id !== this.userMedia?.id) {
getStreamAudioTrack(this.userMedia)?.stop();
getStreamVideoTrack(this.userMedia)?.stop();
}
if(newMedia?.screenShare?.id !== this.screenShare?.id) {
getStreamVideoTrack(this.screenShare)?.stop();
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,225 +0,0 @@
- relevant MSCs next to spec:
- https://github.com/matrix-org/matrix-doc/pull/2746 Improved Signalling for 1:1 VoIP
- https://github.com/matrix-org/matrix-doc/pull/2747 Transferring VoIP Calls
- https://github.com/matrix-org/matrix-doc/pull/3077 Support for multi-stream VoIP
- https://github.com/matrix-org/matrix-doc/pull/3086 Asserted identity on VoIP calls
- https://github.com/matrix-org/matrix-doc/pull/3291 Muting in VoIP calls
- https://github.com/matrix-org/matrix-doc/pull/3401 Native Group VoIP Signalling
## TODO
- DONE: implement receiving hangup
- DONE: implement cloning the localMedia so it works in safari?
- DONE: implement 3 retries per peer
- DONE: implement muting tracks with m.call.sdp_stream_metadata_changed
- DONE: implement renegotiation
- DONE: finish session id support
- call peers are essentially identified by (userid, deviceid, sessionid). If see a new session id, we first disconnect from the current member so we're ready to connect with a clean slate again (in a member event, also in to_device? no harm I suppose, given olm encryption ensures you can't spoof the deviceid).
- DONE: making logging better
- figure out why sometimes leave button does not work
- get correct members and avatars in call
- improve UI while in a call
- allow toggling audio
- support active speaker, sort speakers by last active
- close muted media stream after a while
- support highlight mode where we show active speaker and thumbnails for other participants
- better grid mode:
- we report the call view size to the view model with ResizeObserver, we calculate the A/R
- we calculate the grid based on view A/R, taking into account minimal stream size
- show name on stream view
- when you start a call, or join one, first you go to a SelectCallMedia screen where you can pick whether you want to use camera, audio or both:
- if you are joining a call, we'll default to the call intent
- if you are creating a call, we'll default to video
- when creating a call, adjust the navigation path to room/room_id/call
- when selecting a call, adjust the navigation path to room/room_id/call/call_id
- implement to_device messages arriving before m.call(.member) state event
- DONE for m.call.member, not for m.call and not for to_device other than m.call.invite arriving before invite
- reeable crypto & implement fetching olm keys before sending encrypted signalling message
- local echo for join/leave buttons?
- batch outgoing to_device messages in one request to homeserver for operations that will send out an event to all participants (e.g. mute)
- implement call ringing and rejecting a ringing call
- support screen sharing
- add button to enable, disable
- support showing stream view with large screen share video element and small camera video element (if present)
- don't load all members when loading calls to know whether they are ringing and joined by ourself
- only load our own member once, then have a way to load additional members on a call.
- see if we remove partyId entirely, it is only used for detecting remote echo which is not an issue for group calls? see https://github.com/matrix-org/matrix-spec-proposals/blob/dbkr/msc2746/proposals/2746-reliable-voip.md#add-party_id-to-all-voip-events
- remove PeerCall.waitForState ?
- invite glare is completely untested, does it work?
- how to remove call from m.call.member when just closing client?
- when closing client and still in call, tell service worker to send event on our behalf?
```js
// dispose when leaving call
this.track(platform.registerExitHandler(unloadActions => {
// batch requests will resolve immediately,
// so we can reuse the same send code that does awaits without awaiting?
const batch = new RequestBatch();
const hsApi = this.hsApi.withBatch(batch);
// _leaveCallMemberContent will need to become sync,
// so we'll need to keep track of own member event rather than rely on storage
hsApi.sendStateEvent("m.call.member", this._leaveCallMemberContent());
// does this internally: serviceWorkerHandler.trySend("sendRequestBatch", batch.toJSON());
unloadActions.sendRequestBatch(batch);
}));
```
## TODO (old)
- DONE: PeerCall
- send invite
- implement terminate
- implement waitForState
- find out if we need to do something different when renegotation is triggered (a subsequent onnegotiationneeded event) whether
we sent the invite/offer or answer. e.g. do we always do createOffer/setLocalDescription and then send it over a matrix negotiation event? even if we before called createAnswer.
- handle receiving offer and send anwser
- handle sending ice candidates
- handle ice candidates finished (iceGatheringState === 'complete')
- handle receiving ice candidates
- handle sending renegotiation
- handle receiving renegotiation
- reject call
- hangup call
- handle muting tracks
- handle remote track being muted
- handle adding/removing tracks to an ongoing call
- handle sdp metadata
- DONE: Participant
- handle glare
- encrypt to_device message with olm
- batch outgoing to_device messages in one request to homeserver for operations that will send out an event to all participants (e.g. mute)
- find out if we should start muted or not?
## Store ongoing calls
DONE: Add store with all ongoing calls so when we quit and start again, we don't have to go through all the past calls to know which ones might still be ongoing.
## Notes
we send m.call as state event in room
we add m.call.participant for our own device
we wait for other participants to add their user and device (in the sources)
for each (userid, deviceid)
- if userId < ourUserId
- get local media
- we setup a peer connection
- add local tracks
- we wait for negotation event to get sdp
- peerConn.createOffer
- peerConn.setLocalDescription
- we send an m.call.invite
- else
- wait for invite from other side
on local ice candidate:
- if we haven't ... sent invite yet? or received answer? buffer candidate
- otherwise send candidate (without buffering?)
on incoming call:
- ring, offer to answer
answering incoming call
- get local media
- peerConn.setRemoteDescription
- add local tracks to peerConn
- peerConn.createAnswer()
- peerConn.setLocalDescription
in some cases, we will actually send the invite to all devices (e.g. SFU), so
we probably still need to handle multiple anwsers?
so we would send an invite to multiple devices and pick the one for which we
received the anwser first. between invite and anwser, we could already receive
ice candidates that we need to buffer.
updating the metadata:
if we're renegotiating: use m.call.negotatie
if just muting: use m.call.sdp_stream_metadata_changed
party identification
- for 1:1 calls, we identify with a party_id
- for group calls, we identify with a device_id
## TODO
Build basic version of PeerCall
- add candidates code
DONE: Build basic version of GroupCall
- DONE: add state, block invalid actions
DONE: Make it possible to olm encrypt the messages
Do work needed for state events
- DONEish: receiving (almost done?)
- DONEish: sending
logging
DONE: Expose call objects
expose volume events from audiotrack to group call
DONE: Write view model
DONE: write view
- handle glare edge-cases (not yet sent): https://spec.matrix.org/latest/client-server-api/#glare
## Calls questions
- how do we handle glare between group calls (e.g. different state events with different call ids?)
- Split up DOM part into platform code? What abstractions to choose?
Does it make sense to come up with our own API very similar to DOM api?
- what code do we copy over vs what do we implement ourselves?
- MatrixCall: perhaps we can copy it over and modify it to our needs? Seems to have a lot of edge cases implemented.
- what is partyId about?
- CallFeed: I need better understand where it is used. It's basically a wrapper around a MediaStream with volume detection. Could it make sense to put this in platform for example?
- which parts of MSC2746 are still relevant for group calls?
- which parts of MSC2747 are still relevant for group calls? it seems mostly orthogonal?
- SOLVED: how does switching channels work? This was only enabled by MSC 2746
- you do getUserMedia()/getDisplayMedia() to get the stream(s)
- you call removeTrack/addTrack on the peerConnection
- you receive a negotiationneeded event
- you call createOffer
- you send m.call.negotiate
- SOLVED: wrt to MSC2746, is the screen share track and the audio track (and video track) part of the same stream? or do screen share tracks need to go in a different stream? it sounds incompatible with the MSC2746 requirement.
- SOLVED: how does muting work? MediaStreamTrack.enabled
- SOLVED: so, what's the difference between the call_id and the conf_id in group call events?
- call_id is the specific 1:1 call, conf_id is the thing in the m.call state event key
- so a group call has a conf_id with MxN peer calls, each having their call_id.
I think we need to synchronize the negotiation needed because we don't use a CallState to guard it...
## Thursday 3-3 notes
we probably best keep the perfect negotiation flags, as they are needed for both starting the call AND renegotiation? if only for the former, it would make sense as it is a step in setting up the call, but if the call is ongoing, does it make sense to have a MakingOffer state? it actually looks like they are only needed for renegotiation! for call setup we compare the call_ids. What does that mean for these flags?
## Peer call state transitions
FROM CALLER FROM CALLEE
Fledgling Fledgling
V `call()` V `handleInvite()`: setRemoteDescription(event.offer), add buffered candidates
V Ringing
V V `answer()`
CreateOffer V
V add local tracks V
V wait for negotionneeded events V add local tracks
V setLocalDescription() CreateAnswer
V send invite event V setLocalDescription(createAnswer())
InviteSent |
V receive anwser, setRemoteDescription() |
\___________________________________________________/
V
Connecting
V receive ice candidates and iceConnectionState becomes 'connected'
Connected
V `hangup()` or some terminate condition
Ended
so if we don't want to bother with having two call objects, we can make the existing call hangup his old call_id? That way we keep the old peerConnection.
when glare, won't we drop both calls? No: https://github.com/matrix-org/matrix-spec-proposals/pull/2746#discussion_r819388754

View file

@ -1,227 +0,0 @@
// allow non-camelcase as these are events type that go onto the wire
/* eslint-disable camelcase */
import type {StateEvent} from "../storage/types";
import type {SessionDescription} from "../../platform/types/WebRTC";
export enum EventType {
GroupCall = "org.matrix.msc3401.call",
GroupCallMember = "org.matrix.msc3401.call.member",
Invite = "m.call.invite",
Candidates = "m.call.candidates",
Answer = "m.call.answer",
Hangup = "m.call.hangup",
Reject = "m.call.reject",
SelectAnswer = "m.call.select_answer",
Negotiate = "m.call.negotiate",
SDPStreamMetadataChanged = "m.call.sdp_stream_metadata_changed",
SDPStreamMetadataChangedPrefix = "org.matrix.call.sdp_stream_metadata_changed",
Replaces = "m.call.replaces",
AssertedIdentity = "m.call.asserted_identity",
AssertedIdentityPrefix = "org.matrix.call.asserted_identity",
}
// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged
export const SDPStreamMetadataKey = "org.matrix.msc3077.sdp_stream_metadata";
export interface CallDeviceMembership {
device_id: string,
session_id: string
}
export interface CallMembership {
["m.call_id"]: string,
["m.devices"]: CallDeviceMembership[]
}
export interface CallMemberContent {
["m.calls"]: CallMembership[];
}
export enum SDPStreamMetadataPurpose {
Usermedia = "m.usermedia",
Screenshare = "m.screenshare",
}
export interface SDPStreamMetadataObject {
purpose: SDPStreamMetadataPurpose;
audio_muted: boolean;
video_muted: boolean;
}
export interface SDPStreamMetadata {
[key: string]: SDPStreamMetadataObject;
}
export interface CallCapabilities {
'm.call.transferee': boolean;
'm.call.dtmf': boolean;
}
export interface CallReplacesTarget {
id: string;
display_name: string;
avatar_url: string;
}
export type MCallBase = {
call_id: string;
version: string | number;
seq: number;
}
export type MGroupCallBase = MCallBase & {
conf_id: string;
device_id: string;
sender_session_id: string;
dest_session_id: string;
party_id: string; // Should not need this?
}
export type MCallAnswer<Base extends MCallBase> = Base & {
answer: SessionDescription;
capabilities?: CallCapabilities;
[SDPStreamMetadataKey]: SDPStreamMetadata;
}
export type MCallSelectAnswer<Base extends MCallBase> = Base & {
selected_party_id: string;
}
export type MCallInvite<Base extends MCallBase> = Base & {
offer: SessionDescription;
lifetime: number;
[SDPStreamMetadataKey]: SDPStreamMetadata;
}
export type MCallNegotiate<Base extends MCallBase> = Base & {
description: SessionDescription;
lifetime: number;
[SDPStreamMetadataKey]: SDPStreamMetadata;
}
export type MCallSDPStreamMetadataChanged<Base extends MCallBase> = Base & {
[SDPStreamMetadataKey]: SDPStreamMetadata;
}
export type MCallReplacesEvent<Base extends MCallBase> = Base & {
replacement_id: string;
target_user: CallReplacesTarget;
create_call: string;
await_call: string;
target_room: string;
}
export type MCAllAssertedIdentity<Base extends MCallBase> = Base & {
asserted_identity: {
id: string;
display_name: string;
avatar_url: string;
};
}
export type MCallCandidates<Base extends MCallBase> = Base & {
candidates: RTCIceCandidate[];
}
export type MCallHangupReject<Base extends MCallBase> = Base & {
reason?: CallErrorCode;
}
export enum CallErrorCode {
/** The user chose to end the call */
UserHangup = 'user_hangup',
/** An error code when the local client failed to create an offer. */
LocalOfferFailed = 'local_offer_failed',
/**
* An error code when there is no local mic/camera to use. This may be because
* the hardware isn't plugged in, or the user has explicitly denied access.
*/
NoUserMedia = 'no_user_media',
/**
* Error code used when a call event failed to send
* because unknown devices were present in the room
*/
UnknownDevices = 'unknown_devices',
/**
* Error code used when we fail to send the invite
* for some reason other than there being unknown devices
*/
SendInvite = 'send_invite',
/**
* An answer could not be created
*/
CreateAnswer = 'create_answer',
/**
* Error code used when we fail to send the answer
* for some reason other than there being unknown devices
*/
SendAnswer = 'send_answer',
/**
* The session description from the other side could not be set
*/
SetRemoteDescription = 'set_remote_description',
/**
* The session description from this side could not be set
*/
SetLocalDescription = 'set_local_description',
/**
* A different device answered the call
*/
AnsweredElsewhere = 'answered_elsewhere',
/**
* No media connection could be established to the other party
*/
IceFailed = 'ice_failed',
/**
* The invite timed out whilst waiting for an answer
*/
InviteTimeout = 'invite_timeout',
/**
* The call was replaced by another call
*/
Replaced = 'replaced',
/**
* Signalling for the call could not be sent (other than the initial invite)
*/
SignallingFailed = 'signalling_timeout',
/**
* The remote party is busy
*/
UserBusy = 'user_busy',
/**
* We transferred the call off to somewhere else
*/
Transfered = 'transferred',
/**
* A call from the same user was found with a new session id
*/
NewSession = 'new_session',
}
export type SignallingMessage<Base extends MCallBase> =
{type: EventType.Invite, content: MCallInvite<Base>} |
{type: EventType.Negotiate, content: MCallNegotiate<Base>} |
{type: EventType.Answer, content: MCallAnswer<Base>} |
{type: EventType.SDPStreamMetadataChanged | EventType.SDPStreamMetadataChangedPrefix, content: MCallSDPStreamMetadataChanged<Base>} |
{type: EventType.Candidates, content: MCallCandidates<Base>} |
{type: EventType.Hangup | EventType.Reject, content: MCallHangupReject<Base>};
export enum CallIntent {
Ring = "m.ring",
Prompt = "m.prompt",
Room = "m.room",
};

View file

@ -1,39 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type {Track, Stream} from "../../platform/types/MediaDevices";
export function getStreamAudioTrack(stream: Stream | undefined): Track | undefined {
return stream?.getAudioTracks()[0];
}
export function getStreamVideoTrack(stream: Stream | undefined): Track | undefined {
return stream?.getVideoTracks()[0];
}
export class MuteSettings {
constructor (public readonly microphone: boolean = false, public readonly camera: boolean = false) {}
toggleCamera(): MuteSettings {
return new MuteSettings(this.microphone, !this.camera);
}
toggleMicrophone(): MuteSettings {
return new MuteSettings(!this.microphone, this.camera);
}
}
export const CALL_LOG_TYPE = "call";

View file

@ -1,514 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ObservableMap} from "../../../observable/map/ObservableMap";
import {Member} from "./Member";
import {LocalMedia} from "../LocalMedia";
import {MuteSettings, CALL_LOG_TYPE} from "../common";
import {RoomMember} from "../../room/members/RoomMember";
import {EventEmitter} from "../../../utils/EventEmitter";
import {EventType, CallIntent} from "../callEventTypes";
import type {Options as MemberOptions} from "./Member";
import type {BaseObservableMap} from "../../../observable/map/BaseObservableMap";
import type {Track} from "../../../platform/types/MediaDevices";
import type {SignallingMessage, MGroupCallBase, CallMembership} from "../callEventTypes";
import type {Room} from "../../room/Room";
import type {StateEvent} from "../../storage/types";
import type {Platform} from "../../../platform/web/Platform";
import type {EncryptedMessage} from "../../e2ee/olm/Encryption";
import type {ILogItem, ILogger} from "../../../logging/types";
import type {Storage} from "../../storage/idb/Storage";
export enum GroupCallState {
Fledgling = "fledgling",
Creating = "creating",
Created = "created",
Joining = "joining",
Joined = "joined",
}
function getMemberKey(userId: string, deviceId: string) {
return JSON.stringify(userId)+`,`+JSON.stringify(deviceId);
}
function memberKeyIsForUser(key: string, userId: string) {
return key.startsWith(JSON.stringify(userId)+`,`);
}
function getDeviceFromMemberKey(key: string): string {
return JSON.parse(`[${key}]`)[1];
}
export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDeviceMessage"> & {
emitUpdate: (call: GroupCall, params?: any) => void;
encryptDeviceMessage: (roomId: string, userId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
storage: Storage,
logger: ILogger,
};
class JoinedData {
constructor(
public readonly logItem: ILogItem,
public readonly membersLogItem: ILogItem,
public localMedia: LocalMedia,
public localMuteSettings: MuteSettings
) {}
dispose() {
this.localMedia.dispose();
this.logItem.finish();
}
}
export class GroupCall extends EventEmitter<{change: never}> {
private readonly _members: ObservableMap<string, Member> = new ObservableMap();
private _memberOptions: MemberOptions;
private _state: GroupCallState;
private bufferedDeviceMessages = new Map<string, Set<SignallingMessage<MGroupCallBase>>>();
private joinedData?: JoinedData;
private _deviceIndex?: number;
private _eventTimestamp?: number;
constructor(
public readonly id: string,
newCall: boolean,
private callContent: Record<string, any>,
public readonly roomId: string,
private readonly options: Options,
) {
super();
this._state = newCall ? GroupCallState.Fledgling : GroupCallState.Created;
this._memberOptions = Object.assign({}, options, {
confId: this.id,
emitUpdate: member => this._members.update(getMemberKey(member.userId, member.deviceId), member),
encryptDeviceMessage: (userId: string, message: SignallingMessage<MGroupCallBase>, log) => {
return this.options.encryptDeviceMessage(this.roomId, userId, message, log);
}
});
}
get localMedia(): LocalMedia | undefined { return this.joinedData?.localMedia; }
get members(): BaseObservableMap<string, Member> { return this._members; }
get isTerminated(): boolean {
return this.callContent?.["m.terminated"] === true;
}
get isRinging(): boolean {
return this._state === GroupCallState.Created && this.intent === "m.ring" && !this.isMember(this.options.ownUserId);
}
get name(): string {
return this.callContent?.["m.name"];
}
get intent(): CallIntent {
return this.callContent?.["m.intent"];
}
get deviceIndex(): number | undefined {
return this._deviceIndex;
}
get eventTimestamp(): number | undefined {
return this._eventTimestamp;
}
/**
* Gives access the log item for this call while joined.
* Can be used for call diagnostics while in the call.
**/
get logItem(): ILogItem | undefined {
return this.joinedData?.logItem;
}
async join(localMedia: LocalMedia): Promise<void> {
if (this._state !== GroupCallState.Created || this.joinedData) {
return;
}
const logItem = this.options.logger.child({
l: "answer call",
t: CALL_LOG_TYPE,
id: this.id,
ownSessionId: this.options.sessionId
});
const membersLogItem = logItem.child("member connections");
const joinedData = new JoinedData(
logItem,
membersLogItem,
localMedia,
new MuteSettings()
);
this.joinedData = joinedData;
await joinedData.logItem.wrap("join", async log => {
this._state = GroupCallState.Joining;
this.emitChange();
const memberContent = await this._createJoinPayload();
// send m.call.member state event
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log});
await request.response();
this.emitChange();
// send invite to all members that are < my userId
for (const [,member] of this._members) {
this.connectToMember(member, joinedData, log);
}
});
}
async setMedia(localMedia: LocalMedia): Promise<void> {
if ((this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) && this.joinedData) {
const oldMedia = this.joinedData.localMedia;
this.joinedData.localMedia = localMedia;
await Promise.all(Array.from(this._members.values()).map(m => {
return m.setMedia(localMedia, oldMedia);
}));
oldMedia?.stopExcept(localMedia);
}
}
async setMuted(muteSettings: MuteSettings): Promise<void> {
const {joinedData} = this;
if (!joinedData) {
return;
}
joinedData.localMuteSettings = muteSettings;
await Promise.all(Array.from(this._members.values()).map(m => {
return m.setMuted(joinedData.localMuteSettings);
}));
this.emitChange();
}
get muteSettings(): MuteSettings | undefined {
return this.joinedData?.localMuteSettings;
}
get hasJoined() {
return this._state === GroupCallState.Joining || this._state === GroupCallState.Joined;
}
async leave(): Promise<void> {
const {joinedData} = this;
if (!joinedData) {
return;
}
await joinedData.logItem.wrap("leave", async log => {
try {
const memberContent = await this._leaveCallMemberContent();
// send m.call.member state event
if (memberContent) {
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log});
await request.response();
// our own user isn't included in members, so not in the count
if (this.intent === CallIntent.Ring && this._members.size === 0) {
await this.terminate(log);
}
} else {
log.set("already_left", true);
}
} finally {
this.disconnect(log);
}
});
}
terminate(log?: ILogItem): Promise<void> {
return this.options.logger.wrapOrRun(log, {l: "terminate call", t: CALL_LOG_TYPE}, async log => {
if (this._state === GroupCallState.Fledgling) {
return;
}
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCall, this.id, Object.assign({}, this.callContent, {
"m.terminated": true
}), {log});
await request.response();
});
}
/** @internal */
create(type: "m.video" | "m.voice", log?: ILogItem): Promise<void> {
return this.options.logger.wrapOrRun(log, {l: "create call", t: CALL_LOG_TYPE}, async log => {
if (this._state !== GroupCallState.Fledgling) {
return;
}
this._state = GroupCallState.Creating;
this.emitChange();
this.callContent = Object.assign({
"m.type": type,
}, this.callContent);
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCall, this.id, this.callContent!, {log});
await request.response();
this._state = GroupCallState.Created;
this.emitChange();
});
}
/** @internal */
updateCallEvent(callContent: Record<string, any>, syncLog: ILogItem) {
syncLog.wrap({l: "update call", t: CALL_LOG_TYPE, id: this.id}, log => {
this.callContent = callContent;
if (this._state === GroupCallState.Creating) {
this._state = GroupCallState.Created;
}
log.set("status", this._state);
this.emitChange();
});
}
/** @internal */
updateMembership(userId: string, callMembership: CallMembership, eventTimestamp: number, syncLog: ILogItem) {
syncLog.wrap({l: "update call membership", t: CALL_LOG_TYPE, id: this.id, userId}, log => {
const devices = callMembership["m.devices"];
const previousDeviceIds = this.getDeviceIdsForUserId(userId);
for (let deviceIndex = 0; deviceIndex < devices.length; deviceIndex++) {
const device = devices[deviceIndex];
const deviceId = device.device_id;
const memberKey = getMemberKey(userId, deviceId);
log.wrap({l: "update device membership", id: memberKey, sessionId: device.session_id}, log => {
if (userId === this.options.ownUserId && deviceId === this.options.ownDeviceId) {
this._deviceIndex = deviceIndex;
this._eventTimestamp = eventTimestamp;
if (this._state === GroupCallState.Joining) {
log.set("update_own", true);
this._state = GroupCallState.Joined;
this.emitChange();
}
} else {
let member = this._members.get(memberKey);
const sessionIdChanged = member && member.sessionId !== device.session_id;
if (member && !sessionIdChanged) {
log.set("update", true);
member.updateCallInfo(device, deviceIndex, eventTimestamp, log);
} else {
if (member && sessionIdChanged) {
log.set("removedSessionId", member.sessionId);
const disconnectLogItem = member.disconnect(false);
if (disconnectLogItem) {
log.refDetached(disconnectLogItem);
}
this._members.remove(memberKey);
member = undefined;
}
log.set("add", true);
member = new Member(
RoomMember.fromUserId(this.roomId, userId, "join"),
device, deviceIndex, eventTimestamp, this._memberOptions
);
this._members.add(memberKey, member);
if (this.joinedData) {
this.connectToMember(member, this.joinedData, log);
}
}
// flush pending messages, either after having created the member,
// or updated the session id with updateCallInfo
this.flushPendingIncomingDeviceMessages(member, log);
}
});
}
const newDeviceIds = new Set<string>(devices.map(call => call.device_id));
// remove user as member of any calls not present anymore
for (const previousDeviceId of previousDeviceIds) {
if (!newDeviceIds.has(previousDeviceId)) {
this.removeMemberDevice(userId, previousDeviceId, log);
}
}
if (userId === this.options.ownUserId && !newDeviceIds.has(this.options.ownDeviceId)) {
this.removeOwnDevice(log);
}
});
}
/** @internal */
removeMembership(userId: string, syncLog: ILogItem) {
const deviceIds = this.getDeviceIdsForUserId(userId);
syncLog.wrap({
l: "remove call member",
t: CALL_LOG_TYPE,
id: this.id,
userId
}, log => {
for (const deviceId of deviceIds) {
this.removeMemberDevice(userId, deviceId, log);
}
if (userId === this.options.ownUserId) {
this.removeOwnDevice(log);
}
});
}
private flushPendingIncomingDeviceMessages(member: Member, log: ILogItem) {
const memberKey = getMemberKey(member.userId, member.deviceId);
const bufferedMessages = this.bufferedDeviceMessages.get(memberKey);
// check if we have any pending message for the member with (userid, deviceid, sessionid)
if (bufferedMessages) {
for (const message of bufferedMessages) {
if (message.content.sender_session_id === member.sessionId) {
member.handleDeviceMessage(message, log);
bufferedMessages.delete(message);
}
}
if (bufferedMessages.size === 0) {
this.bufferedDeviceMessages.delete(memberKey);
}
}
}
private getDeviceIdsForUserId(userId: string): string[] {
return Array.from(this._members.keys())
.filter(key => memberKeyIsForUser(key, userId))
.map(key => getDeviceFromMemberKey(key));
}
private isMember(userId: string): boolean {
return Array.from(this._members.keys()).some(key => memberKeyIsForUser(key, userId));
}
private removeOwnDevice(log: ILogItem) {
log.set("leave_own", true);
this.disconnect(log);
}
/** @internal */
disconnect(log: ILogItem) {
if (this._state === GroupCallState.Joined) {
for (const [,member] of this._members) {
const disconnectLogItem = member.disconnect(true);
if (disconnectLogItem) {
log.refDetached(disconnectLogItem);
}
}
this._state = GroupCallState.Created;
}
this.joinedData?.dispose();
this.joinedData = undefined;
this.emitChange();
}
/** @internal */
private removeMemberDevice(userId: string, deviceId: string, log: ILogItem) {
const memberKey = getMemberKey(userId, deviceId);
log.wrap({l: "remove device member", id: memberKey}, log => {
const member = this._members.get(memberKey);
if (member) {
log.set("leave", true);
this._members.remove(memberKey);
const disconnectLogItem = member.disconnect(false);
if (disconnectLogItem) {
log.refDetached(disconnectLogItem);
}
}
this.emitChange();
});
}
/** @internal */
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, userId: string, deviceId: string, syncLog: ILogItem) {
// TODO: return if we are not membering to the call
const key = getMemberKey(userId, deviceId);
let member = this._members.get(key);
if (member && message.content.sender_session_id === member.sessionId) {
member.handleDeviceMessage(message, syncLog);
} else {
const item = syncLog.log({
l: "call: buffering to_device message, member not found",
t: CALL_LOG_TYPE,
id: this.id,
userId,
deviceId,
sessionId: message.content.sender_session_id,
type: message.type
});
syncLog.refDetached(item);
// we haven't received the m.call.member yet for this caller (or with this session id).
// buffer the device messages or create the member/call as it should arrive in a moment
let messages = this.bufferedDeviceMessages.get(key);
if (!messages) {
messages = new Set();
this.bufferedDeviceMessages.set(key, messages);
}
messages.add(message);
}
}
private async _createJoinPayload() {
const {storage} = this.options;
const txn = await storage.readTxn([storage.storeNames.roomState]);
const stateEvent = await txn.roomState.get(this.roomId, EventType.GroupCallMember, this.options.ownUserId);
const stateContent = stateEvent?.event?.content ?? {
["m.calls"]: []
};
const callsInfo = stateContent["m.calls"];
let callInfo = callsInfo.find(c => c["m.call_id"] === this.id);
if (!callInfo) {
callInfo = {
["m.call_id"]: this.id,
["m.devices"]: []
};
callsInfo.push(callInfo);
}
callInfo["m.devices"] = callInfo["m.devices"].filter(d => d["device_id"] !== this.options.ownDeviceId);
callInfo["m.devices"].push({
["device_id"]: this.options.ownDeviceId,
["session_id"]: this.options.sessionId,
feeds: [{purpose: "m.usermedia"}]
});
this._deviceIndex = callInfo["m.devices"].length;
this._eventTimestamp = Date.now();
return stateContent;
}
private async _leaveCallMemberContent(): Promise<Record<string, any> | undefined> {
const {storage} = this.options;
const txn = await storage.readTxn([storage.storeNames.roomState]);
const stateEvent = await txn.roomState.get(this.roomId, EventType.GroupCallMember, this.options.ownUserId);
if (stateEvent) {
const content = stateEvent.event.content;
const callInfo = content["m.calls"]?.find(c => c["m.call_id"] === this.id);
if (callInfo) {
const devicesInfo = callInfo["m.devices"];
const deviceIndex = devicesInfo.findIndex(d => d["device_id"] === this.options.ownDeviceId);
if (deviceIndex !== -1) {
devicesInfo.splice(deviceIndex, 1);
return content;
}
}
}
}
private connectToMember(member: Member, joinedData: JoinedData, log: ILogItem) {
const memberKey = getMemberKey(member.userId, member.deviceId);
const logItem = joinedData.membersLogItem.child({l: "member", id: memberKey});
logItem.set("sessionId", member.sessionId);
log.wrap({l: "connect", id: memberKey}, log => {
// Safari can't send a MediaStream to multiple sources, so clone it
const connectItem = member.connect(joinedData.localMedia.clone(), joinedData.localMuteSettings, logItem);
if (connectItem) {
log.refDetached(connectItem);
}
})
}
protected emitChange() {
this.emit("change");
this.options.emitUpdate(this);
}
}

View file

@ -1,306 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {PeerCall, CallState} from "../PeerCall";
import {makeTxnId, makeId} from "../../common";
import {EventType, CallErrorCode} from "../callEventTypes";
import {formatToDeviceMessagesPayload} from "../../common";
import type {MuteSettings} from "../common";
import type {Options as PeerCallOptions, RemoteMedia} from "../PeerCall";
import type {LocalMedia} from "../LocalMedia";
import type {HomeServerApi} from "../../net/HomeServerApi";
import type {MCallBase, MGroupCallBase, SignallingMessage, CallDeviceMembership} from "../callEventTypes";
import type {GroupCall} from "./GroupCall";
import type {RoomMember} from "../../room/members/RoomMember";
import type {EncryptedMessage} from "../../e2ee/olm/Encryption";
import type {ILogItem} from "../../../logging/types";
export type Options = Omit<PeerCallOptions, "emitUpdate" | "sendSignallingMessage"> & {
confId: string,
ownUserId: string,
ownDeviceId: string,
// local session id of our client
sessionId: string,
hsApi: HomeServerApi,
encryptDeviceMessage: (userId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
emitUpdate: (participant: Member, params?: any) => void,
}
const errorCodesWithoutRetry = [
CallErrorCode.UserHangup,
CallErrorCode.AnsweredElsewhere,
CallErrorCode.Replaced,
CallErrorCode.UserBusy,
CallErrorCode.Transfered,
CallErrorCode.NewSession
];
/** @internal */
class MemberConnection {
public retryCount: number = 0;
public peerCall?: PeerCall;
constructor(
public localMedia: LocalMedia,
public localMuteSettings: MuteSettings,
public readonly logItem: ILogItem
) {}
}
export class Member {
private connection?: MemberConnection;
constructor(
public readonly member: RoomMember,
private callDeviceMembership: CallDeviceMembership,
private _deviceIndex: number,
private _eventTimestamp: number,
private readonly options: Options,
) {}
/**
* Gives access the log item for this item once joined to the group call.
* The signalling for this member will be log in this item.
* Can be used for call diagnostics while in the call.
**/
get logItem(): ILogItem | undefined {
return this.connection?.logItem;
}
get remoteMedia(): RemoteMedia | undefined {
return this.connection?.peerCall?.remoteMedia;
}
get remoteMuteSettings(): MuteSettings | undefined {
return this.connection?.peerCall?.remoteMuteSettings;
}
get isConnected(): boolean {
return this.connection?.peerCall?.state === CallState.Connected;
}
get userId(): string {
return this.member.userId;
}
get deviceId(): string {
return this.callDeviceMembership.device_id;
}
/** session id of the member */
get sessionId(): string {
return this.callDeviceMembership.session_id;
}
get dataChannel(): any | undefined {
return this.connection?.peerCall?.dataChannel;
}
get deviceIndex(): number {
return this._deviceIndex;
}
get eventTimestamp(): number {
return this._eventTimestamp;
}
/** @internal */
connect(localMedia: LocalMedia, localMuteSettings: MuteSettings, memberLogItem: ILogItem): ILogItem | undefined {
if (this.connection) {
return;
}
const connection = new MemberConnection(localMedia, localMuteSettings, memberLogItem);
this.connection = connection;
let connectLogItem;
connection.logItem.wrap("connect", async log => {
connectLogItem = log;
await this.callIfNeeded(log);
});
return connectLogItem;
}
private callIfNeeded(log: ILogItem): Promise<void> {
return log.wrap("callIfNeeded", async log => {
// otherwise wait for it to connect
let shouldInitiateCall;
// the lexicographically lower side initiates the call
if (this.member.userId === this.options.ownUserId) {
shouldInitiateCall = this.deviceId > this.options.ownDeviceId;
} else {
shouldInitiateCall = this.member.userId > this.options.ownUserId;
}
if (shouldInitiateCall) {
const connection = this.connection!;
connection.peerCall = this._createPeerCall(makeId("c"));
await connection.peerCall.call(
connection.localMedia,
connection.localMuteSettings,
log
);
} else {
log.set("wait_for_invite", true);
}
});
}
/** @internal */
disconnect(hangup: boolean): ILogItem | undefined {
const {connection} = this;
if (!connection) {
return;
}
let disconnectLogItem;
connection.logItem.wrap("disconnect", async log => {
disconnectLogItem = log;
if (hangup) {
connection.peerCall?.hangup(CallErrorCode.UserHangup, log);
} else {
await connection.peerCall?.close(undefined, log);
}
connection.peerCall?.dispose();
connection.localMedia?.dispose();
this.connection = undefined;
});
connection.logItem.finish();
return disconnectLogItem;
}
/** @internal */
updateCallInfo(
callDeviceMembership: CallDeviceMembership,
deviceIndex: number,
eventTimestamp: number,
causeItem: ILogItem
) {
this.callDeviceMembership = callDeviceMembership;
this._deviceIndex = deviceIndex;
this._eventTimestamp = eventTimestamp;
if (this.connection) {
this.connection.logItem.refDetached(causeItem);
}
}
/** @internal */
emitUpdateFromPeerCall = (peerCall: PeerCall, params: any, log: ILogItem): void => {
const connection = this.connection!;
if (peerCall.state === CallState.Ringing) {
connection.logItem.wrap("ringing, answer peercall", answerLog => {
log.refDetached(answerLog);
return peerCall.answer(connection.localMedia, connection.localMuteSettings, answerLog);
});
}
else if (peerCall.state === CallState.Ended) {
const hangupReason = peerCall.hangupReason;
peerCall.dispose();
connection.peerCall = undefined;
if (hangupReason && !errorCodesWithoutRetry.includes(hangupReason)) {
connection.retryCount += 1;
const {retryCount} = connection;
connection.logItem.wrap({l: "retry connection", retryCount}, async retryLog => {
log.refDetached(retryLog);
if (retryCount <= 3) {
await this.callIfNeeded(retryLog);
} else {
const disconnectLogItem = this.disconnect(false);
if (disconnectLogItem) {
retryLog.refDetached(disconnectLogItem);
}
}
});
}
}
this.options.emitUpdate(this, params);
}
/** @internal */
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem): Promise<void> => {
const groupMessage = message as SignallingMessage<MGroupCallBase>;
groupMessage.content.conf_id = this.options.confId;
groupMessage.content.device_id = this.options.ownDeviceId;
groupMessage.content.party_id = this.options.ownDeviceId;
groupMessage.content.sender_session_id = this.options.sessionId;
groupMessage.content.dest_session_id = this.sessionId;
// const encryptedMessages = await this.options.encryptDeviceMessage(this.member.userId, groupMessage, log);
// const payload = formatToDeviceMessagesPayload(encryptedMessages);
const payload = {
messages: {
[this.member.userId]: {
[this.deviceId]: groupMessage.content
}
}
};
// TODO: remove this for release
log.set("payload", groupMessage.content);
const request = this.options.hsApi.sendToDevice(
message.type,
//"m.room.encrypted",
payload,
makeTxnId(),
{log}
);
await request.response();
}
/** @internal */
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, syncLog: ILogItem): void{
const {connection} = this;
if (connection) {
const destSessionId = message.content.dest_session_id;
if (destSessionId !== this.options.sessionId) {
const logItem = connection.logItem.log({l: "ignoring to_device event with wrong session_id", destSessionId, type: message.type});
syncLog.refDetached(logItem);
return;
}
if (message.type === EventType.Invite && !connection.peerCall) {
connection.peerCall = this._createPeerCall(message.content.call_id);
}
if (connection.peerCall) {
const item = connection.peerCall.handleIncomingSignallingMessage(message, this.deviceId, connection.logItem);
syncLog.refDetached(item);
} else {
// TODO: need to buffer events until invite comes?
}
} else {
syncLog.log({l: "member not connected", userId: this.userId, deviceId: this.deviceId});
}
}
/** @internal */
async setMedia(localMedia: LocalMedia, previousMedia: LocalMedia): Promise<void> {
const {connection} = this;
if (connection) {
connection.localMedia = connection.localMedia.replaceClone(connection.localMedia, previousMedia);
await connection.peerCall?.setMedia(connection.localMedia, connection.logItem);
}
}
async setMuted(muteSettings: MuteSettings): Promise<void> {
const {connection} = this;
if (connection) {
connection.localMuteSettings = muteSettings;
await connection.peerCall?.setMuted(muteSettings, connection.logItem);
}
}
private _createPeerCall(callId: string): PeerCall {
return new PeerCall(callId, Object.assign({}, this.options, {
emitUpdate: this.emitUpdateFromPeerCall,
sendSignallingMessage: this.sendSignallingMessage
}), this.connection!.logItem);
}
}

View file

@ -15,37 +15,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {groupBy} from "../utils/groupBy";
export function makeTxnId() {
return makeId("t");
}
export function makeId(prefix) {
const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
const str = n.toString(16);
return prefix + "0".repeat(14 - str.length) + str;
return "t" + "0".repeat(14 - str.length) + str;
}
export function isTxnId(txnId) {
return txnId.startsWith("t") && txnId.length === 15;
}
export function formatToDeviceMessagesPayload(messages) {
const messagesByUser = groupBy(messages, message => message.device.userId);
const payload = {
messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => {
userMap[userId] = messages.reduce((deviceMap, message) => {
deviceMap[message.device.deviceId] = message.content;
return deviceMap;
}, {});
return userMap;
}, {})
};
return payload;
}
export function tests() {
return {
"isTxnId succeeds on result of makeTxnId": assert => {

View file

@ -69,14 +69,6 @@ export class DecryptionResult {
}
}
get userId(): string | undefined {
return this.device?.userId;
}
get deviceId(): string | undefined {
return this.device?.deviceId;
}
get isVerificationUnknown(): boolean {
// verification is unknown if we haven't yet fetched the devices for the room
return !this.device && !this.roomTracked;

View file

@ -15,11 +15,13 @@ limitations under the License.
*/
import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js";
import {HistoryVisibility, shouldShareKey} from "./common.js";
import {RoomMember} from "../room/members/RoomMember.js";
const TRACKING_STATUS_OUTDATED = 0;
const TRACKING_STATUS_UPTODATE = 1;
export function addRoomToIdentity(identity, userId, roomId) {
function addRoomToIdentity(identity, userId, roomId) {
if (!identity) {
identity = {
userId: userId,
@ -79,28 +81,57 @@ export class DeviceTracker {
}));
}
writeMemberChanges(room, memberChanges, txn) {
return Promise.all(Array.from(memberChanges.values()).map(async memberChange => {
return this._applyMemberChange(memberChange, txn);
/** @return Promise<{added: string[], removed: string[]}> the user ids for who the room was added or removed to the userIdentity,
* and with who a key should be now be shared
**/
async writeMemberChanges(room, memberChanges, historyVisibility, txn) {
const added = [];
const removed = [];
await Promise.all(Array.from(memberChanges.values()).map(async memberChange => {
// keys should now be shared with this member?
// add the room to the userIdentity if so
if (shouldShareKey(memberChange.membership, historyVisibility)) {
if (await this._addRoomToUserIdentity(memberChange.roomId, memberChange.userId, txn)) {
added.push(memberChange.userId);
}
} else if (shouldShareKey(memberChange.previousMembership, historyVisibility)) {
// try to remove room we were previously sharing the key with the member but not anymore
const {roomId} = memberChange;
// if we left the room, remove room from all user identities in the room
if (memberChange.userId === this._ownUserId) {
const userIds = await txn.roomMembers.getAllUserIds(roomId);
await Promise.all(userIds.map(userId => {
return this._removeRoomFromUserIdentity(roomId, userId, txn);
}));
} else {
await this._removeRoomFromUserIdentity(roomId, memberChange.userId, txn);
}
removed.push(memberChange.userId);
}
}));
return {added, removed};
}
async trackRoom(room, log) {
async trackRoom(room, historyVisibility, log) {
if (room.isTrackingMembers || !room.isEncrypted) {
return;
}
const memberList = await room.loadMemberList(log);
const memberList = await room.loadMemberList(undefined, log);
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.roomSummary,
this._storage.storeNames.userIdentities,
]);
try {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.roomSummary,
this._storage.storeNames.userIdentities,
]);
let isTrackingChanges;
try {
isTrackingChanges = room.writeIsTrackingMembers(true, txn);
const members = Array.from(memberList.members.values());
log.set("members", members.length);
await this._writeJoinedMembers(members, txn);
await Promise.all(members.map(async member => {
if (shouldShareKey(member.membership, historyVisibility)) {
await this._addRoomToUserIdentity(member.roomId, member.userId, txn);
}
}));
} catch (err) {
txn.abort();
throw err;
@ -112,21 +143,43 @@ export class DeviceTracker {
}
}
async _writeJoinedMembers(members, txn) {
await Promise.all(members.map(async member => {
if (member.membership === "join") {
await this._writeMember(member, txn);
}
}));
async writeHistoryVisibility(room, historyVisibility, syncTxn, log) {
const added = [];
const removed = [];
if (room.isTrackingMembers && room.isEncrypted) {
await log.wrap("rewriting userIdentities", async log => {
const memberList = await room.loadMemberList(syncTxn, log);
try {
const members = Array.from(memberList.members.values());
log.set("members", members.length);
await Promise.all(members.map(async member => {
if (shouldShareKey(member.membership, historyVisibility)) {
if (await this._addRoomToUserIdentity(member.roomId, member.userId, syncTxn)) {
added.push(member.userId);
}
} else {
if (await this._removeRoomFromUserIdentity(member.roomId, member.userId, syncTxn)) {
removed.push(member.userId);
}
}
}));
} finally {
memberList.release();
}
});
}
return {added, removed};
}
async _writeMember(member, txn) {
async _addRoomToUserIdentity(roomId, userId, txn) {
const {userIdentities} = txn;
const identity = await userIdentities.get(member.userId);
const updatedIdentity = addRoomToIdentity(identity, member.userId, member.roomId);
const identity = await userIdentities.get(userId);
const updatedIdentity = addRoomToIdentity(identity, userId, roomId);
if (updatedIdentity) {
userIdentities.set(updatedIdentity);
return true;
}
return false;
}
async _removeRoomFromUserIdentity(roomId, userId, txn) {
@ -141,28 +194,9 @@ export class DeviceTracker {
} else {
userIdentities.set(identity);
}
return true;
}
}
async _applyMemberChange(memberChange, txn) {
// TODO: depends whether we encrypt for invited users??
// add room
if (memberChange.hasJoined) {
await this._writeMember(memberChange.member, txn);
}
// remove room
else if (memberChange.hasLeft) {
const {roomId} = memberChange;
// if we left the room, remove room from all user identities in the room
if (memberChange.userId === this._ownUserId) {
const userIds = await txn.roomMembers.getAllUserIds(roomId);
await Promise.all(userIds.map(userId => {
return this._removeRoomFromUserIdentity(roomId, userId, txn);
}));
} else {
await this._removeRoomFromUserIdentity(roomId, memberChange.userId, txn);
}
}
return false;
}
async _queryKeys(userIds, hsApi, log) {
@ -214,11 +248,12 @@ export class DeviceTracker {
const allDeviceIdentities = [];
const deviceIdentitiesToStore = [];
// filter out devices that have changed their ed25519 key since last time we queried them
deviceIdentities = await Promise.all(deviceIdentities.map(async deviceIdentity => {
await Promise.all(deviceIdentities.map(async deviceIdentity => {
if (knownDeviceIds.includes(deviceIdentity.deviceId)) {
const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId);
if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) {
allDeviceIdentities.push(existingDevice);
return;
}
}
allDeviceIdentities.push(deviceIdentity);
@ -363,3 +398,338 @@ export class DeviceTracker {
return await txn.deviceIdentities.getByCurve25519Key(curve25519Key);
}
}
import {createMockStorage} from "../../mocks/Storage";
import {Instance as NullLoggerInstance} from "../../logging/NullLogger";
import {MemberChange} from "../room/members/RoomMember";
export function tests() {
function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) {
return {
id: roomId,
isTrackingMembers: false,
isEncrypted: true,
loadMemberList: () => {
const joinedMembers = joinedUserIds.map(userId => {return RoomMember.fromUserId(roomId, userId, "join");});
const invitedMembers = invitedUserIds.map(userId => {return RoomMember.fromUserId(roomId, userId, "invite");});
const members = joinedMembers.concat(invitedMembers);
const memberMap = members.reduce((map, member) => {
map.set(member.userId, member);
return map;
}, new Map());
return {members: memberMap, release() {}}
},
writeIsTrackingMembers(isTrackingMembers) {
if (this.isTrackingMembers !== isTrackingMembers) {
return isTrackingMembers;
}
return undefined;
},
applyIsTrackingMembersChanges(isTrackingMembers) {
if (isTrackingMembers !== undefined) {
this.isTrackingMembers = isTrackingMembers;
}
},
}
}
function createQueryKeysHSApiMock(createKey = (algorithm, userId, deviceId) => `${algorithm}:${userId}:${deviceId}:key`) {
return {
queryKeys(payload) {
const {device_keys: deviceKeys} = payload;
const userKeys = Object.entries(deviceKeys).reduce((userKeys, [userId, deviceIds]) => {
if (deviceIds.length === 0) {
deviceIds = ["device1"];
}
userKeys[userId] = deviceIds.filter(d => d === "device1").reduce((deviceKeys, deviceId) => {
deviceKeys[deviceId] = {
"algorithms": [
"m.olm.v1.curve25519-aes-sha2",
"m.megolm.v1.aes-sha2"
],
"device_id": deviceId,
"keys": {
[`curve25519:${deviceId}`]: createKey("curve25519", userId, deviceId),
[`ed25519:${deviceId}`]: createKey("ed25519", userId, deviceId),
},
"signatures": {
[userId]: {
[`ed25519:${deviceId}`]: `ed25519:${userId}:${deviceId}:signature`
}
},
"unsigned": {
"device_display_name": `${userId} Phone`
},
"user_id": userId
};
return deviceKeys;
}, {});
return userKeys;
}, {});
const response = {device_keys: userKeys};
return {
async response() {
return response;
}
};
}
};
}
async function writeMemberListToStorage(room, storage) {
const txn = await storage.readWriteTxn([
storage.storeNames.roomMembers,
]);
const memberList = await room.loadMemberList(txn);
try {
for (const member of memberList.members.values()) {
txn.roomMembers.set(member.serialize());
}
} catch (err) {
txn.abort();
throw err;
} finally {
memberList.release();
}
await txn.complete();
}
const roomId = "!abc:hs.tld";
return {
"trackRoom only writes joined members with history visibility of joined": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"], ["@charly:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual(await txn.userIdentities.get("@alice:hs.tld"), {
userId: "@alice:hs.tld",
roomIds: [roomId],
deviceTrackingStatus: TRACKING_STATUS_OUTDATED
});
assert.deepEqual(await txn.userIdentities.get("@bob:hs.tld"), {
userId: "@bob:hs.tld",
roomIds: [roomId],
deviceTrackingStatus: TRACKING_STATUS_OUTDATED
});
assert.equal(await txn.userIdentities.get("@charly:hs.tld"), undefined);
},
"getting devices for tracked room yields correct keys": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
const hsApi = createQueryKeysHSApiMock();
const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
assert.equal(devices.length, 2);
assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key");
assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key");
},
"device with changed key is ignored": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
const hsApi = createQueryKeysHSApiMock();
// query devices first time
await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities]);
// mark alice as outdated, so keys will be fetched again
tracker.writeDeviceChanges(["@alice:hs.tld"], txn, NullLoggerInstance.item);
await txn.complete();
const hsApiWithChangedAliceKey = createQueryKeysHSApiMock((algo, userId, deviceId) => {
return `${algo}:${userId}:${deviceId}:${userId === "@alice:hs.tld" ? "newKey" : "key"}`;
});
const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApiWithChangedAliceKey, NullLoggerInstance.item);
assert.equal(devices.length, 2);
assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key");
assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key");
const txn2 = await storage.readTxn([storage.storeNames.deviceIdentities]);
// also check the modified key was not stored
assert.equal((await txn2.deviceIdentities.get("@alice:hs.tld", "device1")).ed25519Key, "ed25519:@alice:hs.tld:device1:key");
},
"change history visibility from joined to invited adds invitees": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
// alice is joined, bob is invited
const room = await createUntrackedRoomMock(roomId,
["@alice:hs.tld"], ["@bob:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined);
const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Invited, txn, NullLoggerInstance.item);
assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld");
assert.deepEqual(added, ["@bob:hs.tld"]);
assert.deepEqual(removed, []);
},
"change history visibility from invited to joined removes invitees": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
// alice is joined, bob is invited
const room = await createUntrackedRoomMock(roomId,
["@alice:hs.tld"], ["@bob:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld");
const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Joined, txn, NullLoggerInstance.item);
assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined);
assert.deepEqual(added, []);
assert.deepEqual(removed, ["@bob:hs.tld"]);
},
"adding invitee with history visibility of invited adds room to userIdentities": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
// inviting a new member
const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite"));
const {added, removed} = await tracker.writeMemberChanges(room, [inviteChange], HistoryVisibility.Invited, txn);
assert.deepEqual(added, ["@bob:hs.tld"]);
assert.deepEqual(removed, []);
assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld");
},
"adding invitee with history visibility of joined doesn't add room": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
// inviting a new member
const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite"));
const memberChanges = new Map([[inviteChange.userId, inviteChange]]);
const {added, removed} = await tracker.writeMemberChanges(room, memberChanges, HistoryVisibility.Joined, txn);
assert.deepEqual(added, []);
assert.deepEqual(removed, []);
assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined);
},
"getting all devices after changing history visibility now includes invitees": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item);
const hsApi = createQueryKeysHSApiMock();
// write memberlist from room mock to mock storage,
// as devicesForTrackedRoom reads directly from roomMembers store.
await writeMemberListToStorage(room, storage);
const devices = await tracker.devicesForTrackedRoom(roomId, hsApi, NullLoggerInstance.item);
assert.equal(devices.length, 2);
assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key");
assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key");
},
"rejecting invite with history visibility of invited removes room from user identity": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
// alice is joined, bob is invited
const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]);
await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
// reject invite
const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "leave"), "invite");
const memberChanges = new Map([[inviteChange.userId, inviteChange]]);
const {added, removed} = await tracker.writeMemberChanges(room, memberChanges, HistoryVisibility.Invited, txn);
assert.deepEqual(added, []);
assert.deepEqual(removed, ["@bob:hs.tld"]);
assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined);
},
"remove room from user identity sharing multiple rooms with us preserves other room": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
// alice is joined, bob is invited
const room1 = await createUntrackedRoomMock("!abc:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]);
const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]);
await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item);
await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn1 = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]);
const leaveChange = new MemberChange(RoomMember.fromUserId(room2.id, "@bob:hs.tld", "leave"), "join");
const memberChanges = new Map([[leaveChange.userId, leaveChange]]);
const txn2 = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
await tracker.writeMemberChanges(room2, memberChanges, HistoryVisibility.Joined, txn2);
await txn2.complete();
const txn3 = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual((await txn3.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]);
},
"add room to user identity sharing multiple rooms with us preserves other room": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
// alice is joined, bob is invited
const room1 = await createUntrackedRoomMock("!abc:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]);
const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]);
await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn1 = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]);
await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item);
const txn2 = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual((await txn2.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]);
},
}
}

View file

@ -18,9 +18,11 @@ import {MEGOLM_ALGORITHM, DecryptionSource} from "./common.js";
import {groupEventsBySession} from "./megolm/decryption/utils";
import {mergeMap} from "../../utils/mergeMap";
import {groupBy} from "../../utils/groupBy";
import {makeTxnId, formatToDeviceMessagesPayload} from "../common.js";
import {makeTxnId} from "../common.js";
import {iterateResponseStateEvents} from "../room/common";
const ENCRYPTED_TYPE = "m.room.encrypted";
const ROOM_HISTORY_VISIBILITY_TYPE = "m.room.history_visibility";
// how often ensureMessageKeyIsShared can check if it needs to
// create a new outbound session
// note that encrypt could still create a new session
@ -45,6 +47,7 @@ export class RoomEncryption {
this._isFlushingRoomKeyShares = false;
this._lastKeyPreShareTime = null;
this._keySharePromise = null;
this._historyVisibility = undefined;
this._disposed = false;
}
@ -77,22 +80,68 @@ export class RoomEncryption {
this._senderDeviceCache = new Map(); // purge the sender device cache
}
async writeMemberChanges(memberChanges, txn, log) {
let shouldFlush = false;
const memberChangesArray = Array.from(memberChanges.values());
// this also clears our session if we leave the room ourselves
if (memberChangesArray.some(m => m.hasLeft)) {
async writeSync(roomResponse, memberChanges, txn, log) {
let historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility, txn);
const addedMembers = [];
const removedMembers = [];
// update the historyVisibility if needed
await iterateResponseStateEvents(roomResponse, event => {
// TODO: can the same state event appear twice? Hence we would be rewriting the useridentities twice...
// we'll see in the logs
if(event.state_key === "" && event.type === ROOM_HISTORY_VISIBILITY_TYPE) {
const newHistoryVisibility = event?.content?.history_visibility;
if (newHistoryVisibility !== historyVisibility) {
return log.wrap({
l: "history_visibility changed",
from: historyVisibility,
to: newHistoryVisibility
}, async log => {
historyVisibility = newHistoryVisibility;
const result = await this._deviceTracker.writeHistoryVisibility(this._room, historyVisibility, txn, log);
addedMembers.push(...result.added);
removedMembers.push(...result.removed);
});
}
}
});
// process member changes
if (memberChanges.size) {
const result = await this._deviceTracker.writeMemberChanges(
this._room, memberChanges, historyVisibility, txn);
addedMembers.push(...result.added);
removedMembers.push(...result.removed);
}
// discard key if somebody (including ourselves) left
if (removedMembers.length) {
log.log({
l: "discardOutboundSession",
leftUsers: memberChangesArray.filter(m => m.hasLeft).map(m => m.userId),
leftUsers: removedMembers,
});
this._megolmEncryption.discardOutboundSession(this._room.id, txn);
}
if (memberChangesArray.some(m => m.hasJoined)) {
shouldFlush = await this._addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log);
let shouldFlush = false;
// add room to userIdentities if needed, and share the current key with them
if (addedMembers.length) {
shouldFlush = await this._addShareRoomKeyOperationForMembers(addedMembers, txn, log);
}
await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn);
return shouldFlush;
return {shouldFlush, historyVisibility};
}
afterSync({historyVisibility}) {
this._historyVisibility = historyVisibility;
}
async _loadHistoryVisibilityIfNeeded(historyVisibility, txn = undefined) {
if (!historyVisibility) {
if (!txn) {
txn = await this._storage.readTxn([this._storage.storeNames.roomState]);
}
const visibilityEntry = await txn.roomState.get(this._room.id, ROOM_HISTORY_VISIBILITY_TYPE, "");
if (visibilityEntry) {
return visibilityEntry.event?.content?.history_visibility;
}
}
return historyVisibility;
}
async prepareDecryptAll(events, newKeys, source, txn) {
@ -274,10 +323,15 @@ export class RoomEncryption {
}
async _shareNewRoomKey(roomKeyMessage, hsApi, log) {
this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility);
await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log);
const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log);
const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set()));
let writeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]);
let operation;
try {
operation = this._writeRoomKeyShareOperation(roomKeyMessage, null, writeOpTxn);
operation = this._writeRoomKeyShareOperation(roomKeyMessage, userIds, writeOpTxn);
} catch (err) {
writeOpTxn.abort();
throw err;
@ -288,8 +342,7 @@ export class RoomEncryption {
await this._processShareRoomKeyOperation(operation, hsApi, log);
}
async _addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log) {
const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId);
async _addShareRoomKeyOperationForMembers(userIds, txn, log) {
const roomKeyMessage = await this._megolmEncryption.createRoomKeyMessage(
this._room.id, txn);
if (roomKeyMessage) {
@ -342,18 +395,9 @@ export class RoomEncryption {
async _processShareRoomKeyOperation(operation, hsApi, log) {
log.set("id", operation.id);
await this._deviceTracker.trackRoom(this._room, log);
let devices;
if (operation.userIds === null) {
devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log);
const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set()));
operation.userIds = userIds;
await this._updateOperationsStore(operations => operations.update(operation));
} else {
devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi, log);
}
this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility);
await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log);
const devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi, log);
const messages = await log.wrap("olm encrypt", log => this._olmEncryption.encrypt(
"m.room_key", operation.roomKeyMessage, devices, hsApi, log));
const missingDevices = devices.filter(d => !messages.some(m => m.device === d));
@ -386,7 +430,6 @@ export class RoomEncryption {
await writeTxn.complete();
}
// TODO: make this use _sendMessagesToDevices
async _sendSharedMessageToDevices(type, message, devices, hsApi, log) {
const devicesByUser = groupBy(devices, device => device.userId);
const payload = {
@ -404,7 +447,16 @@ export class RoomEncryption {
async _sendMessagesToDevices(type, messages, hsApi, log) {
log.set("messages", messages.length);
const payload = formatToDeviceMessagesPayload(messages);
const messagesByUser = groupBy(messages, message => message.device.userId);
const payload = {
messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => {
userMap[userId] = messages.reduce((deviceMap, message) => {
deviceMap[message.device.deviceId] = message.content;
return deviceMap;
}, {});
return userMap;
}, {})
};
const txnId = makeTxnId();
await hsApi.sendToDevice(type, payload, txnId, {log}).response();
}
@ -499,3 +551,143 @@ class BatchDecryptionResult {
}));
}
}
import {createMockStorage} from "../../mocks/Storage";
import {Clock as MockClock} from "../../mocks/Clock";
import {poll} from "../../mocks/poll";
import {Instance as NullLoggerInstance} from "../../logging/NullLogger";
import {ConsoleLogger} from "../../logging/ConsoleLogger";
import {HomeServer as MockHomeServer} from "../../mocks/HomeServer.js";
export function tests() {
const roomId = "!abc:hs.tld";
return {
"ensureMessageKeyIsShared tracks room and passes correct history visibility to deviceTracker": async assert => {
const storage = await createMockStorage();
const megolmMock = {
async ensureOutboundSession() { return { }; }
};
const olmMock = {
async encrypt() { return []; }
}
let isRoomTracked = false;
let isDevicesRequested = false;
const deviceTracker = {
async trackRoom(room, historyVisibility) {
// only assert on first call
if (isRoomTracked) { return; }
assert(!isDevicesRequested);
assert.equal(room.id, roomId);
assert.equal(historyVisibility, "invited");
isRoomTracked = true;
},
async devicesForTrackedRoom() {
assert(isRoomTracked);
isDevicesRequested = true;
return [];
},
async devicesForRoomMembers() {
return [];
}
}
const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]);
writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: {
history_visibility: "invited"
}});
await writeTxn.complete();
const roomEncryption = new RoomEncryption({
room: {id: roomId},
megolmEncryption: megolmMock,
olmEncryption: olmMock,
storage,
deviceTracker,
clock: new MockClock()
});
const homeServer = new MockHomeServer();
const promise = roomEncryption.ensureMessageKeyIsShared(homeServer.api, NullLoggerInstance.item);
// need to poll because sendToDevice isn't first async step
const request = await poll(() => homeServer.requests.sendToDevice?.[0]);
request.respond({});
await promise;
assert(isRoomTracked);
assert(isDevicesRequested);
},
"encrypt tracks room and passes correct history visibility to deviceTracker": async assert => {
const storage = await createMockStorage();
const megolmMock = {
async encrypt() { return { roomKeyMessage: {} }; }
};
const olmMock = {
async encrypt() { return []; }
}
let isRoomTracked = false;
let isDevicesRequested = false;
const deviceTracker = {
async trackRoom(room, historyVisibility) {
// only assert on first call
if (isRoomTracked) { return; }
assert(!isDevicesRequested);
assert.equal(room.id, roomId);
assert.equal(historyVisibility, "invited");
isRoomTracked = true;
},
async devicesForTrackedRoom() {
assert(isRoomTracked);
isDevicesRequested = true;
return [];
},
async devicesForRoomMembers() {
return [];
}
}
const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]);
writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: {
history_visibility: "invited"
}});
await writeTxn.complete();
const roomEncryption = new RoomEncryption({
room: {id: roomId},
megolmEncryption: megolmMock,
olmEncryption: olmMock,
storage,
deviceTracker
});
const homeServer = new MockHomeServer();
const promise = roomEncryption.encrypt("m.room.message", {body: "hello"}, homeServer.api, NullLoggerInstance.item);
// need to poll because sendToDevice isn't first async step
const request = await poll(() => homeServer.requests.sendToDevice?.[0]);
request.respond({});
await promise;
assert(isRoomTracked);
assert(isDevicesRequested);
},
"writeSync passes correct history visibility to deviceTracker": async assert => {
const storage = await createMockStorage();
let isMemberChangesCalled = false;
const deviceTracker = {
async writeMemberChanges(room, memberChanges, historyVisibility, txn) {
assert.equal(historyVisibility, "invited");
isMemberChangesCalled = true;
return {removed: [], added: []};
},
async devicesForRoomMembers() {
return [];
}
}
const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]);
writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: {
history_visibility: "invited"
}});
const memberChanges = new Map([["@alice:hs.tld", {}]]);
const roomEncryption = new RoomEncryption({
room: {id: roomId},
storage,
deviceTracker
});
const roomResponse = {};
const txn = await storage.readWriteTxn([storage.storeNames.roomState]);
await roomEncryption.writeSync(roomResponse, memberChanges, txn, NullLoggerInstance.item);
assert(isMemberChangesCalled);
},
}
}

View file

@ -69,3 +69,28 @@ export function createRoomEncryptionEvent() {
}
}
}
// Use enum when converting to TS
export const HistoryVisibility = Object.freeze({
Joined: "joined",
Invited: "invited",
WorldReadable: "world_readable",
Shared: "shared",
});
export function shouldShareKey(membership, historyVisibility) {
switch (historyVisibility) {
case HistoryVisibility.WorldReadable:
return true;
case HistoryVisibility.Shared:
// was part of room at some time
return membership !== undefined;
case HistoryVisibility.Joined:
return membership === "join";
case HistoryVisibility.Invited:
return membership === "invite" || membership === "join";
default:
return false;
}
}

View file

@ -19,7 +19,7 @@ import {StoredRoomKey, keyFromBackup} from "../decryption/RoomKey";
import {MEGOLM_ALGORITHM} from "../../common";
import * as Curve25519 from "./Curve25519";
import {AbortableOperation} from "../../../../utils/AbortableOperation";
import {ObservableValue} from "../../../../observable/value/ObservableValue";
import {ObservableValue} from "../../../../observable/ObservableValue";
import {SetAbortableFn} from "../../../../utils/AbortableOperation";
import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types";

View file

@ -311,7 +311,7 @@ class EncryptionTarget {
}
}
export class EncryptedMessage {
class EncryptedMessage {
constructor(
public readonly content: OlmEncryptedMessageContent,
public readonly device: DeviceIdentity

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

@ -159,10 +159,6 @@ export class HomeServerApi {
state(roomId: string, eventType: string, stateKey: string, options?: BaseRequestOptions): IHomeServerRequest {
return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, undefined, options);
}
sendState(roomId: string, eventType: string, stateKey: string, content: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._put(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, content, options);
}
getLoginFlows(): IHomeServerRequest {
return this._unauthedRequest("GET", this._url("/login"));

View file

@ -29,31 +29,32 @@ export class MediaRepository {
this._platform = platform;
}
mxcUrlThumbnail(url: string, width: number, height: number, method: "crop" | "scale"): string | undefined {
mxcUrlThumbnail(url: string, width: number, height: number, method: "crop" | "scale"): string | null {
const parts = this._parseMxcUrl(url);
if (parts) {
const [serverName, mediaId] = parts;
const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
return httpUrl + "?" + encodeQueryParams({width: Math.round(width), height: Math.round(height), method});
}
return undefined;
return null;
}
mxcUrl(url: string): string | undefined {
mxcUrl(url: string): string | null {
const parts = this._parseMxcUrl(url);
if (parts) {
const [serverName, mediaId] = parts;
return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
} else {
return null;
}
return undefined;
}
private _parseMxcUrl(url: string): string[] | undefined {
private _parseMxcUrl(url: string): string[] | null {
const prefix = "mxc://";
if (url.startsWith(prefix)) {
return url.substr(prefix.length).split("/", 2);
} else {
return undefined;
return null;
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ObservableValue} from "../../observable/value/ObservableValue";
import {ObservableValue} from "../../observable/ObservableValue";
import type {ExponentialRetryDelay} from "./ExponentialRetryDelay";
import type {TimeMeasure} from "../../platform/web/dom/Clock.js";
import type {OnlineStatus} from "../../platform/web/dom/OnlineStatus.js";

View file

@ -17,9 +17,12 @@ limitations under the License.
import {BlobHandle} from "../../platform/web/dom/BlobHandle.js";
export type RequestBody = BlobHandle | string | Map<string, string | {blob: BlobHandle, name: string}>;
export type EncodedBody = {
mimeType: string;
body: BlobHandle | string;
// the map gets transformed to a FormData object on the web
body: RequestBody
}
export function encodeQueryParams(queryParams?: object): string {
@ -41,6 +44,11 @@ export function encodeBody(body: BlobHandle | object): EncodedBody {
mimeType: blob.mimeType,
body: blob // will be unwrapped in request fn
};
} else if (body instanceof Map) {
return {
mimeType: "multipart/form-data",
body: body
}
} else if (typeof body === "object") {
const json = JSON.stringify(body);
return {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {iterateResponseStateEvents} from "./common";
import {reduceStateEvents} from "./RoomSummary.js";
import {BaseRoom} from "./BaseRoom.js";
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js";
@ -173,15 +173,15 @@ export class ArchivedRoom extends BaseRoom {
}
function findKickDetails(roomResponse, ownUserId) {
let kickEvent;
iterateResponseStateEvents(roomResponse, event => {
const kickEvent = reduceStateEvents(roomResponse, (kickEvent, event) => {
if (event.type === MEMBER_EVENT_TYPE) {
// did we get kicked?
if (event.state_key === ownUserId && event.sender !== event.state_key) {
kickEvent = event;
}
}
});
return kickEvent;
}, null);
if (kickEvent) {
return {
// this is different from the room membership in the sync section, which can only be leave

View file

@ -29,10 +29,8 @@ import {ObservedEventMap} from "./ObservedEventMap.js";
import {DecryptionSource} from "../e2ee/common.js";
import {ensureLogItem} from "../../logging/utils";
import {PowerLevels} from "./PowerLevels.js";
import {RetainedObservableValue} from "../../observable/value/RetainedObservableValue";
import {RetainedObservableValue} from "../../observable/ObservableValue";
import {TimelineReader} from "./timeline/persistence/TimelineReader";
import {ObservedStateTypeMap} from "./state/ObservedStateTypeMap";
import {ObservedStateKeyValue} from "./state/ObservedStateKeyValue";
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
@ -55,40 +53,11 @@ export class BaseRoom extends EventEmitter {
this._getSyncToken = getSyncToken;
this._platform = platform;
this._observedEvents = null;
this._roomStateObservers = new Set();
this._powerLevels = null;
this._powerLevelLoading = null;
this._observedMembers = null;
}
async observeStateType(type, txn = undefined) {
const map = new ObservedStateTypeMap(type);
await this._addStateObserver(map, txn);
return map;
}
async observeStateTypeAndKey(type, stateKey, txn = undefined) {
const value = new ObservedStateKeyValue(type, stateKey);
await this._addStateObserver(value, txn);
return value;
}
async getStateEvent(type, key = '') {
const txn = await this._storage.readTxn(['roomState']);
return txn.roomState.get(this.id, type, key);
}
async _addStateObserver(stateObserver, txn) {
if (!txn) {
txn = await this._storage.readTxn([this._storage.storeNames.roomState]);
}
await stateObserver.load(this.id, txn);
this._roomStateObservers.add(stateObserver);
stateObserver.setRemoveCallback(() => {
this._roomStateObservers.delete(stateObserver);
});
}
async _eventIdsToEntries(eventIds, txn) {
const retryEntries = [];
await Promise.all(eventIds.map(async eventId => {
@ -274,7 +243,7 @@ export class BaseRoom extends EventEmitter {
/** @public */
async loadMemberList(log = null) {
async loadMemberList(txn = undefined, log = null) {
if (this._memberList) {
// TODO: also await fetchOrLoadMembers promise here
this._memberList.retain();
@ -285,6 +254,9 @@ export class BaseRoom extends EventEmitter {
roomId: this._roomId,
hsApi: this._hsApi,
storage: this._storage,
// pass in a transaction if we know we won't need to fetch (which would abort the transaction)
// and we want to make this operation part of the larger transaction
txn,
syncToken: this._getSyncToken(),
// to handle race between /members and /sync
setChangedMembersMap: map => this._changedMembersDuringSync = map,
@ -414,10 +386,6 @@ export class BaseRoom extends EventEmitter {
return this._roomId;
}
get type() {
return this._summary.data.type;
}
get lastMessageTimestamp() {
return this._summary.data.lastMessageTimestamp;
}
@ -455,10 +423,6 @@ export class BaseRoom extends EventEmitter {
return this._summary.data.membership;
}
get isDirectMessage() {
return this._summary.data.isDirectMessage;
}
isDirectMessageForUserId(userId) {
if (this._summary.data.dmUserId === userId) {
return true;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {BaseObservableValue} from "../../observable/value/BaseObservableValue";
import {BaseObservableValue} from "../../observable/ObservableValue";
export class ObservedEventMap {
constructor(notifyEmpty) {

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