Compare commits
1399 commits
bwindels/v
...
master
Author | SHA1 | Date | |
---|---|---|---|
f9aa7b52f8 | |||
2e54866353 | |||
ce075eb32b | |||
02a50a19cb | |||
a33d9981bd | |||
8335a50308 | |||
ee9e73d8c7 | |||
63f77feb7b | |||
04de39596f | |||
25b634bb78 | |||
96c9ea8de7 | |||
d80e970117 | |||
6db5f34ac2 | |||
df0000783d | |||
|
c898bcb46a | ||
|
97391663d3 | ||
|
7d3f22c106 | ||
|
832597447a | ||
|
236a4ab49b | ||
|
ba8cdea6b4 | ||
|
ef9f90bc36 | ||
|
67e94bd642 | ||
|
f7839135a4 | ||
|
4571ecd851 | ||
|
5091090795 | ||
|
db2b4e693c | ||
|
eee8412621 | ||
|
5e83eca3b9 | ||
|
041e628520 | ||
|
4838e19c92 | ||
|
cb0ac846c7 | ||
|
b40ce6137e | ||
|
fdefea5b88 | ||
|
39817dc36b | ||
|
708637e390 | ||
|
b6f795505d | ||
|
10522cacef | ||
|
02116103a1 | ||
|
06da5a8ae4 | ||
|
02bc7d1d7e | ||
|
09bc77073b | ||
|
4a2e14925a | ||
|
224ab2672a | ||
|
170460f5a9 | ||
|
2a5e0302dc | ||
|
f512bfcfc1 | ||
|
5b5c852401 | ||
|
58a2d1f34c | ||
|
d937b9b14b | ||
|
d3e93196e3 | ||
|
62b3a67e33 | ||
|
319ec37864 | ||
|
f5dacb4e42 | ||
|
302131c447 | ||
|
fb79326747 | ||
|
3c64f7d49b | ||
|
a82df95b82 | ||
|
cadca70946 | ||
|
8b91d8fac8 | ||
|
a5b9cb6b95 | ||
|
aeed978789 | ||
|
7b7b19476c | ||
|
ad0bd82bda | ||
|
d7657dcc4d | ||
|
176caf340f | ||
|
a40bb59dc0 | ||
|
ab64ce02b2 | ||
|
2d3b6fe973 | ||
|
550b9db4dc | ||
|
0df66b5aea | ||
|
f18520a2fe | ||
|
50b6ee91d7 | ||
|
9b0ab0c8f1 | ||
|
402cf17d22 | ||
|
bfaba63f47 | ||
|
544afef902 | ||
|
dd878bb8d6 | ||
|
dea3852425 | ||
|
4c17612b05 | ||
|
f9f49b7640 | ||
|
0718f1e77e | ||
|
09fd1a5113 | ||
|
832b840a15 | ||
|
adfecf0778 | ||
|
5fa6793958 | ||
|
1e5179f835 | ||
|
bc385e2cdc | ||
|
0bf021ea87 | ||
|
fdd60a7516 | ||
|
63bdbee39c | ||
|
8a976861fb | ||
|
a23df8a545 | ||
|
17f42f523a | ||
|
f6011f3f34 | ||
|
86c0e9e669 | ||
|
f337940202 | ||
|
b7fd22c7f9 | ||
|
66a59e6f4d | ||
|
e345d0b33e | ||
|
be8962cec2 | ||
|
8b39346409 | ||
|
fb58d9c9ef | ||
|
22831e710c | ||
|
faa8cae532 | ||
|
8d766ac504 | ||
|
c8a8eb10b5 | ||
|
d79e5f7806 | ||
|
7feaa479c0 | ||
|
1456e308a8 | ||
|
313e65e00c | ||
|
612b878793 | ||
|
8aa96e8031 | ||
|
7ac2c7c7fa | ||
|
de02456641 | ||
|
994667205f | ||
|
ecb3a66dfc | ||
|
e1ee258630 | ||
|
83b5d3b68e | ||
|
7a1591e0ce | ||
|
07db5450b7 | ||
|
081de5afa8 | ||
|
dece42dce3 | ||
|
b29287c47e | ||
|
9bdf9c500b | ||
|
9e2d355573 | ||
|
ce5db47708 | ||
|
da0a918c18 | ||
|
043cc9f12c | ||
|
80fb953688 | ||
|
f15e23762a | ||
|
f440457875 | ||
|
a8cab98666 | ||
|
ac7be0c7a1 | ||
|
d731eab51c | ||
|
f7b302d34f | ||
|
5ba74b1d75 | ||
|
c5f4a75d4b | ||
|
2f3db89e0a | ||
|
1ef382f3a9 | ||
|
161e29b36e | ||
|
2947f9f6ff | ||
|
c873804543 | ||
|
43e8cc9e52 | ||
|
bf87ed7eae | ||
|
8c02541b69 | ||
|
599e519f22 | ||
|
d5e24bf6e8 | ||
|
bb5711db7e | ||
|
88808b0b06 | ||
|
c9bca52e82 | ||
|
6718198d9c | ||
|
7b9e681d55 | ||
|
8291aea2f7 | ||
|
f073f40e31 | ||
|
963324c767 | ||
|
eac75644e7 | ||
|
0bdbb96036 | ||
|
d292e1f5ad | ||
|
cd9e00b847 | ||
|
3941b7e3f0 | ||
|
efd9f70e92 | ||
|
204948db64 | ||
|
a85d2c96d6 | ||
|
28b686dae7 | ||
|
dd82469ab4 | ||
|
3bf6a46a39 | ||
|
e42e76a21c | ||
|
8ec0bd7295 | ||
|
ff2129f36a | ||
|
1aa2ff5c10 | ||
|
34ce8a8e3c | ||
|
652e2c6d3b | ||
|
c0445f2182 | ||
|
b76fd1d792 | ||
|
751dfa66a8 | ||
|
a3c6d744f5 | ||
|
b9f316e7c3 | ||
|
d448ee1722 | ||
|
da87470996 | ||
|
b319c0acb0 | ||
|
e90e573bf9 | ||
|
a68f0bba39 | ||
|
ca94c65dac | ||
|
fba3275f5b | ||
|
fc93acfd8d | ||
|
d398e490eb | ||
|
0ab611b013 | ||
|
bb923b8eb9 | ||
|
73cd96fe3a | ||
|
4929839fe9 | ||
|
c59f65e43b | ||
|
fd3a0f0126 | ||
|
ccfd63dfeb | ||
|
5b54280ac2 | ||
|
bd5bf7d456 | ||
|
ad8ad22cc1 | ||
|
3369bda2f0 | ||
|
7430aa7aab | ||
|
3bc453d5ca | ||
|
84bac0afe9 | ||
|
9cb7d89097 | ||
|
d688fa4737 | ||
|
0dfd24af22 | ||
|
34eac94da3 | ||
|
fbdd512e06 | ||
|
5eec724712 | ||
|
93165cb947 | ||
|
e3372f0f2b | ||
|
5a3cf03f0b | ||
|
c050ade03c | ||
|
cc29dc045d | ||
|
09b2437e72 | ||
|
cfd347335b | ||
|
d322f380ad | ||
|
f658dc2e4b | ||
|
7a3eabf39c | ||
|
48da6c782c | ||
|
b00bbc7daf | ||
|
9fbe8a4e32 | ||
|
623939c671 | ||
|
fccc41f4b9 | ||
|
3b66ed8c17 | ||
|
8fe8981ffa | ||
|
375d8b066c | ||
|
69ada73dd4 | ||
|
2129a97588 | ||
|
4caabae895 | ||
|
d0375141f8 | ||
|
a644621889 | ||
|
4ed7e01dfd | ||
|
e643ffb334 | ||
|
d00ea39dc4 | ||
|
69d8e6031e | ||
|
abee9baf60 | ||
|
d4aaa8117b | ||
|
be66969c9a | ||
|
7bce0d848f | ||
|
53a8915ffc | ||
|
b5fd3656a7 | ||
|
acffd15002 | ||
|
989ecd785a | ||
|
9a5a002293 | ||
|
2cfd08e500 | ||
|
2b4a7f05a6 | ||
|
d31f127982 | ||
|
d08cfe3a29 | ||
|
51a837d459 | ||
|
2f0f7143b5 | ||
|
0dac00f327 | ||
|
a639fc5467 | ||
|
258a604cc6 | ||
|
a2cbac9e0c | ||
|
71c3fb39a2 | ||
|
43244fa026 | ||
|
9e88bc3098 | ||
|
b74f4b612b | ||
|
8de91291dd | ||
|
dc2d1ce700 | ||
|
12a8e94243 | ||
|
9e79b632a8 | ||
|
efb1a67470 | ||
|
e3235ea3eb | ||
|
46d2792dac | ||
|
8ad0b8a726 | ||
|
e8e4c33bae | ||
|
cb03e97e78 | ||
|
f6cec938a7 | ||
|
bbec2effe5 | ||
|
d4084da299 | ||
|
1f00c8f635 | ||
|
0b98473e85 | ||
|
3afbe1148e | ||
|
809c522571 | ||
|
4474458f4b | ||
|
9d8a578dce | ||
|
38c3774869 | ||
|
8b2299852e | ||
|
c62c8da10b | ||
|
bc51644868 | ||
|
3d3d590334 | ||
|
11d7535c23 | ||
|
a49d7eae5d | ||
|
1b2a6b5d0e | ||
|
ba647d012d | ||
|
fc873757d8 | ||
|
ec1cc89cf9 | ||
|
a336623f3a | ||
|
9300347e9b | ||
|
f49d580d49 | ||
|
263948faa3 | ||
|
52f0690c70 | ||
|
7a24059337 | ||
|
4fd1918202 | ||
|
4ae3a5bf7a | ||
|
5be00f051f | ||
|
e7f4ce6175 | ||
|
09bc0f1b60 | ||
|
76d04ee277 | ||
|
f28dfc6964 | ||
|
c14e4f3eed | ||
|
5d42f372f6 | ||
|
4c3e0a6ff0 | ||
|
d9bfca10e1 | ||
|
bf2fb52691 | ||
|
646cbe0fff | ||
|
92e8fc8ad3 | ||
|
92c79c853d | ||
|
55229252d7 | ||
|
3efc426fed | ||
|
04d5b9bfda | ||
|
66f6c4aba1 | ||
|
ed8c98558d | ||
|
514d5c0a50 | ||
|
13428bd03c | ||
|
1555b0f4bc | ||
|
0e46aed0df | ||
|
7b0591be46 | ||
|
f21e103270 | ||
|
7a197c0a1a | ||
|
8a5f1ed9cd | ||
|
36ddd61318 | ||
|
03ab1ee2c7 | ||
|
a550788788 | ||
|
683ffa9ed3 | ||
|
7952a34d64 | ||
|
7426d17e33 | ||
|
660a08db3e | ||
|
1b22a48b54 | ||
|
b725269c7a | ||
|
639358b146 | ||
|
34e8b60917 | ||
|
9ba1534390 | ||
|
4ddfd3b508 | ||
|
e63440527a | ||
|
0984aeb570 | ||
|
654e83a5f9 | ||
|
b306344739 | ||
|
4231037345 | ||
|
d5bc9f5d7d | ||
|
6fde6bbf6b | ||
|
cc88245933 | ||
|
174adc0755 | ||
|
c26dc04b52 | ||
|
2761789f45 | ||
|
213f87378b | ||
|
855298bdaf | ||
|
e8a4ab5ecc | ||
|
5204fe5c99 | ||
|
c39f0d2efb | ||
|
bb3368959f | ||
|
af9cbd727f | ||
|
12a70469eb | ||
|
c611d3f85c | ||
|
ecb83bb277 | ||
|
daae7442bb | ||
|
cc2c74fdff | ||
|
541cd96eeb | ||
|
f16a2e5d22 | ||
|
b7675f46c4 | ||
|
a06474d7ac | ||
|
e903d3a6a4 | ||
|
3888291758 | ||
|
6beff7e552 | ||
|
139a87de99 | ||
|
e54482e4c0 | ||
|
75098b4712 | ||
|
d053d4388f | ||
|
23b621492f | ||
|
83664a1b13 | ||
|
c07a42292c | ||
|
049a477008 | ||
|
fa34315210 | ||
|
bec8cea583 | ||
|
3536d12680 | ||
|
ab893f63b5 | ||
|
6c57c96cb9 | ||
|
6ba5fbeebb | ||
|
d8da128780 | ||
|
7a33c2e00d | ||
|
5a94a2feba | ||
|
c6691cf1cb | ||
|
826835e518 | ||
|
b6e55ef59c | ||
|
4f23944581 | ||
|
1cdc76f5a4 | ||
|
468b7e1595 | ||
|
ce289baba6 | ||
|
f1e07b6842 | ||
|
e9cee2e6a4 | ||
|
5f8a171c2c | ||
|
6cd3c8ee2b | ||
|
2cfcd4653f | ||
|
f56dc582a5 | ||
|
f61bf6090e | ||
|
12d6447b06 | ||
|
480c5c1584 | ||
|
2d6cbcfce0 | ||
|
78f352b839 | ||
|
cbdd7548da | ||
|
3b74e2ea7e | ||
|
3f4dddc004 | ||
|
5170329c79 | ||
|
2d8a3d9f9b | ||
|
83dffef47d | ||
|
23aac5cb45 | ||
|
f7bfab6e08 | ||
|
5e7432b5de | ||
|
2de0450e97 | ||
|
f26b51e5da | ||
|
bf74c3c67b | ||
|
3d304be211 | ||
|
698d47e221 | ||
|
3e2a2b7942 | ||
|
061dc5f824 | ||
|
366e75b242 | ||
|
b76fb70579 | ||
|
aacd0e6dfb | ||
|
bf0cdcd3f1 | ||
|
825c9847fe | ||
|
14523ecc5d | ||
|
efef7147af | ||
|
39bc827aaf | ||
|
bb9954a36c | ||
|
0b241db058 | ||
|
743bd0db1c | ||
|
25a8521efc | ||
|
36782fb4fe | ||
|
6456d4ef76 | ||
|
49535807bf | ||
|
0a95eb0940 | ||
|
ff98ef4465 | ||
|
a6b6fef6d2 | ||
|
c9bc080aef | ||
|
4cbd149c25 | ||
|
cf780ce259 | ||
|
d21d10e4f2 | ||
|
1fea14dd10 | ||
|
1f0cb542c8 | ||
|
57f50cc416 | ||
|
cda96a35ee | ||
|
e977a6829b | ||
|
ac4bb8ca15 | ||
|
a913671f0c | ||
|
5445db2a42 | ||
|
220f35ae03 | ||
|
6aa79cf6e2 | ||
|
88482292e1 | ||
|
9755062563 | ||
|
0a225292f0 | ||
|
1b18b1f815 | ||
|
c0fb8a2c77 | ||
|
f2b4f2e069 | ||
|
7046fcc7c7 | ||
|
8c6400ab2c | ||
|
5d5eb93baa | ||
|
4ded893880 | ||
|
bfd73ae52a | ||
|
6d724e27e7 | ||
|
2dd655cd9a | ||
|
9a96112146 | ||
|
545ff2ec32 | ||
|
5e702171ce | ||
|
cd4fce0c6f | ||
|
1a50effd86 | ||
|
b7a47ae901 | ||
|
0a186dd11b | ||
|
f07a3ea5b5 | ||
|
2d4ec5380e | ||
|
6b4bb762aa | ||
|
97ade0659c | ||
|
b59d6970fc | ||
|
cbff912476 | ||
|
3ae2b4dab4 | ||
|
f897e5132c | ||
|
e0bc9b31a9 | ||
|
f75ee86c0e | ||
|
7f9af5b5fa | ||
|
b0f082e81f | ||
|
d5b5e10230 | ||
|
86c45b5b99 | ||
|
32eb95734a | ||
|
1f6efb4db3 | ||
|
48d0242c80 | ||
|
2401b7f453 | ||
|
dd06d78a72 | ||
|
95d17303c3 | ||
|
d247bc4e28 | ||
|
454345c9b2 | ||
|
76789eacf1 | ||
|
859449ed60 | ||
|
918a3e42b1 | ||
|
4350d2f264 | ||
|
2015fa2d7a | ||
|
e8bd1f3390 | ||
|
66304ed7e0 | ||
|
72785e7c3e | ||
|
59ca8e6309 | ||
|
5d4323cd1d | ||
|
19a6d669a9 | ||
|
bca1648df6 | ||
|
4020ade70c | ||
|
2c068cc3ce | ||
|
6f4a7e074a | ||
|
9f77df0bff | ||
|
ff10297bf8 | ||
|
f732164b5f | ||
|
5210123977 | ||
|
1663782954 | ||
|
63c1f2a7a3 | ||
|
96fa83b508 | ||
|
79f363fb9d | ||
|
ca211f929b | ||
|
6150e91c3f | ||
|
762925d4a5 | ||
|
21080d2110 | ||
|
6d7c983e8e | ||
|
a83850ebf3 | ||
|
41f6b6ab6b | ||
|
a5d46bb40c | ||
|
f170ef0206 | ||
|
e07abfa02a | ||
|
b6f5e68e9e | ||
|
92084e8005 | ||
|
8b8233ff00 | ||
|
60d60e9572 | ||
|
61ce2f9e3d | ||
|
2f4c639cef | ||
|
c09964dc30 | ||
|
2e1283d199 | ||
|
62ce111938 | ||
|
770f7aea00 | ||
|
b6d9993ed0 | ||
|
643ab1a5f3 | ||
|
42141c7063 | ||
|
1087d62705 | ||
|
ee8e45926f | ||
|
4c50dbf7ec | ||
|
4a4856a29e | ||
|
0023ab34ba | ||
|
8fb2b2755a | ||
|
cd007b40e1 | ||
|
17acda7741 | ||
|
7055f02f16 | ||
|
0935f2d23a | ||
|
b993331e06 | ||
|
8adc5a9fae | ||
|
3f9f0e98c7 | ||
|
82299e5aea | ||
|
3330530f68 | ||
|
620409b3f0 | ||
|
78e0bb1ff0 | ||
|
347edb5988 | ||
|
0ff1a01b42 | ||
|
91fd0e433a | ||
|
cdd6112971 | ||
|
ac48a5a4df | ||
|
49f6a2c2eb | ||
|
2821f4d396 | ||
|
2472f11ec0 | ||
|
7f1fed6f8c | ||
|
d971fd1a47 | ||
|
498a43327f | ||
|
d9acc83182 | ||
|
60f5da60bb | ||
|
e3e90ed167 | ||
|
eb5ca200f2 | ||
|
61b264be3b | ||
|
37cec04e9c | ||
|
7a9298328f | ||
|
a76bcd1739 | ||
|
60bc4450f3 | ||
|
ed151c8567 | ||
|
c40801efd9 | ||
|
a4fd1615dd | ||
|
74c640f937 | ||
|
7aeda70ff6 | ||
|
c6dde63abd | ||
|
dea1e7eaf3 | ||
|
7179758c50 | ||
|
1a159f9e9a | ||
|
1795f58ba5 | ||
|
4d82dd22b6 | ||
|
460780d602 | ||
|
dfed04166e | ||
|
75e2618f70 | ||
|
9685ef4dd3 | ||
|
750f3cd8ff | ||
|
34ec0e2c82 | ||
|
ea8f3e5a6a | ||
|
a184ad528f | ||
|
57b1542688 | ||
|
175f869c83 | ||
|
a442b4b009 | ||
|
d65b25f084 | ||
|
2765f48a64 | ||
|
d2008a336b | ||
|
ff46d382ac | ||
|
3adb2c3254 | ||
|
8526461d3c | ||
|
15eecbb463 | ||
|
30c8ea29b2 | ||
|
b0d790543a | ||
|
2c1b29e637 | ||
|
75bbde598d | ||
|
955a6bd6f9 | ||
|
147810864f | ||
|
4c0167ed74 | ||
|
024a6c06aa | ||
|
b5536830d0 | ||
|
20493f9e87 | ||
|
e8c20c28b2 | ||
|
f12841b2d3 | ||
|
d6d1af13d0 | ||
|
bbb1683dbf | ||
|
fed42f13ad | ||
|
5f6308e7c4 | ||
|
74f7879cb6 | ||
|
5c085efc10 | ||
|
a1e14c4eec | ||
|
4b1be30dc0 | ||
|
8523f6feaf | ||
|
83d2b58bad | ||
|
afe8e17a6f | ||
|
743f2270e5 | ||
|
5325b0b466 | ||
|
d7b024eac1 | ||
|
45c8e3a793 | ||
|
e04463c143 | ||
|
26fa2a5d60 | ||
|
e1fbd1242e | ||
|
b868734378 | ||
|
0bb3cfcfad | ||
|
3ff39a9549 | ||
|
94709fd316 | ||
|
4a0db9f984 | ||
|
28931f4103 | ||
|
f7f32ac806 | ||
|
a163cee18d | ||
|
0828ac12b1 | ||
|
b59f916824 | ||
|
2ac63e78ca | ||
|
028b96e4c5 | ||
|
22d5505a2b | ||
|
e66549a067 | ||
|
e8c480426a | ||
|
891375a885 | ||
|
32af7e6f09 | ||
|
0b04612d6c | ||
|
3d8b9cce41 | ||
|
bc09ede09f | ||
|
b6e1d4a7d5 | ||
|
89a97537b0 | ||
|
8a3c0afba6 | ||
|
0ad0ecfcc2 | ||
|
c4894f2c24 | ||
|
e64f4ad7b2 | ||
|
2aad5546bf | ||
|
7bacbec5e9 | ||
|
e13040a49e | ||
|
30cb9f6d15 | ||
|
a351a185a0 | ||
|
fe0add01ee | ||
|
a249a1b2b5 | ||
|
6798a5e429 | ||
|
3a67da8830 | ||
|
1d4b079d0c | ||
|
49ade61ef6 | ||
|
b482d478b4 | ||
|
ac7108b882 | ||
|
7bb7189c6a | ||
|
6eba60bd75 | ||
|
5de1fc1453 | ||
|
2f3865d8cc | ||
|
2d4c106542 | ||
|
a91ba4370d | ||
|
550a560f40 | ||
|
5f11790f6b | ||
|
e8dbbd876c | ||
|
755f934eb2 | ||
|
5e93e048ab | ||
|
bb6a885116 | ||
|
420c12f202 | ||
|
792d5c62c5 | ||
|
fa2e2bc8f3 | ||
|
170d7a5e55 | ||
|
8ab8726b8f | ||
|
18e2fc1089 | ||
|
a59b67ec45 | ||
|
d76a059525 | ||
|
d28ab919bb | ||
|
eb146830ba | ||
|
618d02d838 | ||
|
348de312f9 | ||
|
65dcf8bc36 | ||
|
2e3616e05d | ||
|
00c5e747d2 | ||
|
b29ecd339d | ||
|
c6820eccab | ||
|
247d13f97a | ||
|
f4fa013ebc | ||
|
f4bb420f35 | ||
|
02f06724d0 | ||
|
fd4eb6b50d | ||
|
997666164c | ||
|
9c599d53aa | ||
|
62acd458c6 | ||
|
17275a5390 | ||
|
830786b2fd | ||
|
06a1421e97 | ||
|
6541aacf98 | ||
|
dacaa86386 | ||
|
a757fb3696 | ||
|
7eb0d347f5 | ||
|
ae5cc17290 | ||
|
d9e6164a5c | ||
|
a97d235cf5 | ||
|
c9b5ce6508 | ||
|
e0df003aba | ||
|
c340746a87 | ||
|
eabd303c8e | ||
|
bd2c70b923 | ||
|
504f420293 | ||
|
eb134a6c47 | ||
|
7d3e3b992b | ||
|
c47bdd5715 | ||
|
b692b3ec4f | ||
|
ebc7f1ecd7 | ||
|
b30db544a3 | ||
|
a499689bd8 | ||
|
c81dde53e7 | ||
|
dd2b41ff95 | ||
|
48e72f9b69 | ||
|
6f1484005b | ||
|
0b4954a9ca | ||
|
bf08c0d850 | ||
|
e80acd4d57 | ||
|
60ed276b8a | ||
|
554aa45d48 | ||
|
524090e27d | ||
|
a791641b34 | ||
|
85155a43bb | ||
|
cfb94206f9 | ||
|
86caa5f9b1 | ||
|
933a1b4636 | ||
|
ffece4f357 | ||
|
8f4e3c62ce | ||
|
290aaad63a | ||
|
a3e294bb60 | ||
|
5d87d8bde3 | ||
|
993a86ddb2 | ||
|
a4d924acd1 | ||
|
30438846e9 | ||
|
e6fee75952 | ||
|
acc9167991 | ||
|
b0e8506cb5 | ||
|
f379bf2341 | ||
|
454d2d3666 | ||
|
57bf730241 | ||
|
4bc421527f | ||
|
05d23cc745 | ||
|
4c5b884af7 | ||
|
c6c1d3b3d8 | ||
|
164d72830f | ||
|
c10435e242 | ||
|
2dc9b63051 | ||
|
d673c8714e | ||
|
412db33c36 | ||
|
000c8b27c3 | ||
|
46c61953f6 | ||
|
a8a8355ea4 | ||
|
3d00881508 | ||
|
7197e5427f | ||
|
3243ce2a90 | ||
|
65929194b0 | ||
|
184a16a194 | ||
|
8201a85c47 | ||
|
2321228981 | ||
|
5f99c2360c | ||
|
ad335d5088 | ||
|
1ea4a347e2 | ||
|
b578f4ac84 | ||
|
052ff02571 | ||
|
3c59004e72 | ||
|
17ebc8a066 | ||
|
9220b6675b | ||
|
18a76025c7 | ||
|
dac2d5e685 | ||
|
0af9f10166 | ||
|
d18f4d341c | ||
|
b5a1c419ca | ||
|
1f9be978b7 | ||
|
41fffdf155 | ||
|
51215fda16 | ||
|
d639e169ec | ||
|
e1b9b1161d | ||
|
846e637716 | ||
|
58dd25b58d | ||
|
a77b9d9027 | ||
|
ef5a377bc6 | ||
|
951af49e04 | ||
|
455b747a1c | ||
|
28a534ee49 | ||
|
f9f7f6cc6f | ||
|
7f91653208 | ||
|
086e0c0320 | ||
|
273c44424f | ||
|
b134fa7409 | ||
|
fee6447e22 | ||
|
e99cd41ed0 | ||
|
af5a008d0f | ||
|
27a9f5dd02 | ||
|
cfefe6962a | ||
|
88f9ad09a2 | ||
|
0ae3c60d6d | ||
|
c34d574385 | ||
|
2a124d4195 | ||
|
e352867f5a | ||
|
f645065db7 | ||
|
d69059de68 | ||
|
0c3f16e5f6 | ||
|
cba044eff1 | ||
|
dee22f7120 | ||
|
46b69b3873 | ||
|
687aa5a7e3 | ||
|
4df3654166 | ||
|
4d63b41127 | ||
|
1b9f970d7f | ||
|
7f1b3e25e8 | ||
|
f01d5d95d9 | ||
|
89d6968139 | ||
|
2773642406 | ||
|
13cba84445 | ||
|
bb45d0eae9 | ||
|
df22db256b | ||
|
e0dc853d74 | ||
|
91912bdb8d | ||
|
54004eef4d | ||
|
aa3bb9c6ef | ||
|
73c5562fd3 | ||
|
4a12acf157 | ||
|
67da746b48 | ||
|
545aae31d9 | ||
|
3aa29cfc65 | ||
|
99f4eb6843 | ||
|
61f4d0719f | ||
|
d6233e7c77 | ||
|
540aa6c546 | ||
|
31573b3599 | ||
|
e88ee31991 | ||
|
f6cf3b378b | ||
|
35a13842af | ||
|
65f957f023 | ||
|
4fb0a84d0a | ||
|
30b8e5b5ea | ||
|
8cd430ac07 | ||
|
75012eda9c | ||
|
e9a49fdf74 | ||
|
315acf2fbc | ||
|
310790c84e | ||
|
277638b107 | ||
|
b238357c53 | ||
|
4fa32bac2f | ||
|
58f2192a7e | ||
|
3c28ee1adf | ||
|
2c4610c132 | ||
|
239d16747d | ||
|
764541d3ca | ||
|
ca1831fef6 | ||
|
1ed8d48ced | ||
|
48e6bba100 | ||
|
8c1596d869 | ||
|
93eca757d3 | ||
|
3f60ef8da7 | ||
|
5d15fce343 | ||
|
f526098293 | ||
|
d7290bf750 | ||
|
2f4c0623d0 | ||
|
ed88184757 | ||
|
d0f7570f5e | ||
|
a5eb386f48 | ||
|
b76f97be93 | ||
|
acafae7d3a | ||
|
a59bf7c002 | ||
|
5c1813888c | ||
|
73733ce145 | ||
|
bf6dfcfcad | ||
|
f605608098 | ||
|
31a8227e53 | ||
|
62dcb61536 | ||
|
fda211e7b3 | ||
|
63b6564f70 | ||
|
93bbeee400 | ||
|
66fa8d84a7 | ||
|
091b55a265 | ||
|
ec8f6e8e0a | ||
|
7ad73bb453 | ||
|
3fecce6fe6 | ||
|
9d161a0bcf | ||
|
8cc04e4c25 | ||
|
0a09a50ab9 | ||
|
c6484f1eac | ||
|
68214156d9 | ||
|
314843f5f2 | ||
|
cfbb6d4250 | ||
|
7adce08eee | ||
|
f76217dcce | ||
|
a2ab36480f | ||
|
90c9018aa4 | ||
|
595deb3a3d | ||
|
78f97c6532 | ||
|
9f1764c325 | ||
|
4418700589 | ||
|
d2c7eec8e0 | ||
|
41cf6460d0 | ||
|
8ec75ce4bb | ||
|
a060d54468 | ||
|
3fe824dbd1 | ||
|
7ef79c92f5 | ||
|
2d5bb82077 | ||
|
6f8001bd82 | ||
|
640a3fb9fa | ||
|
05d2defa2d | ||
|
c3bef6d4d2 | ||
|
d1818d2a57 | ||
|
f5fadf700e | ||
|
d924dbb723 | ||
|
544dca3b18 | ||
|
39f68e8c2f | ||
|
5c0bbdd4c8 | ||
|
51b7b21082 | ||
|
0da94e51e0 | ||
|
4a6293dcdc | ||
|
287212956b | ||
|
7a91dd9595 | ||
|
4a81e06e96 | ||
|
ea89c272b9 | ||
|
c690de9f7b | ||
|
7cc3d4b91a | ||
|
053dcf39a5 | ||
|
d191b327c6 | ||
|
06864a65b7 | ||
|
764e38f8c9 | ||
|
696980aca4 | ||
|
0c42f53a2f | ||
|
e901142661 | ||
|
2265d198a6 | ||
|
b753507b8d | ||
|
196e3726cb | ||
|
c9d11d6f19 | ||
|
aabfbf507e | ||
|
205de7e5c5 | ||
|
908f9a7ce3 | ||
|
203a5fd88c | ||
|
13e77636a9 | ||
|
6247ced7b7 | ||
|
ba27d20b24 | ||
|
9238961992 | ||
|
fe26f48c47 | ||
|
b5fe65d0cc | ||
|
24afe1e496 | ||
|
5f389e654a | ||
|
c31215bc2a | ||
|
c3ff571af7 | ||
|
441fa13bfd | ||
|
3bee4b4585 | ||
|
b48280905e | ||
|
163dae647b | ||
|
c921091957 | ||
|
6add3f1da3 | ||
|
ceb0b5793b | ||
|
14b854ad4f | ||
|
df6000c706 | ||
|
c11f0774eb | ||
|
f2b822e5d2 | ||
|
2d2005934a | ||
|
363cd5b046 | ||
|
8922d2aaf2 | ||
|
10368500f2 | ||
|
5ef7ab32df | ||
|
dacdc1aec6 | ||
|
589a002d67 | ||
|
21a41e192b | ||
|
5ea29297cc | ||
|
c5c08ea34b | ||
|
8d315f2741 | ||
|
cd0d9dcbba | ||
|
ba84387722 | ||
|
0ec86b6dc1 | ||
|
5c5193ef48 | ||
|
d9ff4a8484 | ||
|
dea7e7b4f5 | ||
|
62827b92b7 | ||
|
9a82f88e1f | ||
|
23e0d3f2ff | ||
|
a4fac68393 | ||
|
f934262e35 | ||
|
14dffa4ad4 | ||
|
8e4da396ea | ||
|
c344032c0a | ||
|
180681b602 | ||
|
fb8149b6cf | ||
|
4c2c99fc07 | ||
|
c8b0354d07 | ||
|
c87628b614 | ||
|
5bd28da4f3 | ||
|
0e2a22f509 | ||
|
91e69a2bd0 | ||
|
155cd4c9bd | ||
|
734ecccb9c | ||
|
9a3f74c6fb | ||
|
e2abc312d3 | ||
|
d6378133d8 | ||
|
49a56efa82 | ||
|
640cd88b6e | ||
|
66b4f9bfe5 | ||
|
0541cf8f2b | ||
|
bf93bd79c9 | ||
|
f89b937ee7 | ||
|
82de3c9867 | ||
|
b328c54da8 | ||
|
e9cea73357 | ||
|
3fbf65355d | ||
|
b5438f2ba8 | ||
|
4f43398db0 | ||
|
05121e32b1 | ||
|
a8870f2d24 | ||
|
238b9aafb1 | ||
|
2e6b909173 | ||
|
4bdcafad4b | ||
|
69e67ad5ac | ||
|
2dd050bd90 | ||
|
9b315d1564 | ||
|
57d24dcf90 | ||
|
8387215efd | ||
|
885abc59be | ||
|
7403cbc389 | ||
|
145b40f28d | ||
|
cf54b78af7 | ||
|
0aae31a450 | ||
|
f120ce50e6 | ||
|
90e3fde35d | ||
|
ff53c2757d | ||
|
e1a823400a | ||
|
3a24019d96 | ||
|
9688a561b3 | ||
|
2a3b13ecce | ||
|
9bffd31ee3 | ||
|
6dcebde69d | ||
|
e06a0e9e5a | ||
|
7362e38413 | ||
|
d2c09933c7 | ||
|
b2efcb9515 | ||
|
814c0bed2e | ||
|
e45f66a199 | ||
|
dd4704b818 | ||
|
9b8ab9fd8d | ||
|
f9f59fec39 | ||
|
d91aaabeb3 | ||
|
9042520916 | ||
|
d3ab961364 | ||
|
0c46460861 | ||
|
9f82e7f7fc | ||
|
ef3456199c | ||
|
928a5c27f3 | ||
|
bc86bf2d00 | ||
|
fceca845a9 | ||
|
09338d8aa8 | ||
|
a504e74f54 | ||
|
f83a0cec4e | ||
|
69e34d03bd | ||
|
261b17d36c | ||
|
3fd2d39898 | ||
|
bb9362ee8b | ||
|
1a618dd106 | ||
|
7fda78ff2f | ||
|
70c1e4e3ed | ||
|
b469d03677 | ||
|
de24034b22 | ||
|
75bf410320 | ||
|
7e1818b285 | ||
|
73ca2dfb77 | ||
|
dce0ee5ace | ||
|
85385a0aa7 | ||
|
08314bd4b5 | ||
|
8c3ae57497 | ||
|
de8995fa7e | ||
|
581ef47c78 | ||
|
fc3eb7f57f | ||
|
49443d4f6e | ||
|
2e57e99e34 | ||
|
ef712b16f5 | ||
|
19827a0b5b | ||
|
66fbc37ec4 | ||
|
6699b71bd5 | ||
|
fe77b71c97 | ||
|
be5deea1d3 | ||
|
3322827979 | ||
|
7f115b3e4b | ||
|
a134e48ebb | ||
|
104590e34d | ||
|
d981a85239 | ||
|
bc8b3d71d5 | ||
|
2802164bb4 | ||
|
876fcf532f | ||
|
92bf28e104 | ||
|
ae7d4d07df | ||
|
229c584138 | ||
|
bb18af414b | ||
|
3d9fbb685a | ||
|
346e95c33c | ||
|
a31860dc5f | ||
|
c54ca168ed | ||
|
a1367f8e72 | ||
|
64037cb32a | ||
|
e4c443c73a | ||
|
91f2a96403 | ||
|
93abbe83e8 | ||
|
f444160c6a | ||
|
e4be1702c4 | ||
|
7b38df45da | ||
|
e34a92e2ec | ||
|
35fb84c275 | ||
|
9557178ffb | ||
|
4be2f12a14 | ||
|
c64a9c1e23 | ||
|
7897ea88cd | ||
|
c22718811f | ||
|
3aa3b7e160 | ||
|
cf9f43ab9e | ||
|
4a64d0ee17 | ||
|
d625d57aa4 | ||
|
bbeb909bdc | ||
|
33ac34b04e | ||
|
5d54285640 | ||
|
aee135a6cd | ||
|
da715c70b0 | ||
|
e10b494f0c | ||
|
3ae52ea1ca | ||
|
1165683f69 | ||
|
83ff2dd810 | ||
|
587dd3848e | ||
|
a02b6b68d3 | ||
|
168312627d | ||
|
61402e798e | ||
|
1a28b4f887 | ||
|
d4e923f9de | ||
|
c0f7f0a8f1 | ||
|
f1a6a4924e | ||
|
ec71e30ecb | ||
|
f23227fc8b | ||
|
5a747cd829 | ||
|
6980921dab | ||
|
8fcfd713e0 | ||
|
ea2842f37f | ||
|
64a9892ee2 | ||
|
048547828d | ||
|
a14a8c3a07 | ||
|
08ef84d112 | ||
|
5a0c06473c | ||
|
1beb153f21 | ||
|
0c424cb77f | ||
|
ebd1caf6d1 | ||
|
0e18247184 | ||
|
a945edfe07 | ||
|
6c2aa1bf61 | ||
|
afecac3e3c | ||
|
8a169d5ddc | ||
|
ea0adb4407 | ||
|
88ec1b575d | ||
|
1549d8add0 | ||
|
c8eb7ea7ac | ||
|
a3460d8c2a | ||
|
4ce7634201 | ||
|
ef53a12f7a | ||
|
7d12c2ba54 | ||
|
7270918b65 | ||
|
dd74ed1957 | ||
|
7772643b0d | ||
|
0a433b90e3 | ||
|
efccc1e19e | ||
|
692ae25e76 | ||
|
b5e9eb26ba | ||
|
4030a4918d | ||
|
41a10d9697 | ||
|
fde0163b97 | ||
|
42e5fb33ba | ||
|
526a818269 | ||
|
afc538e875 | ||
|
74fb15e426 | ||
|
46dd78162f | ||
|
276d8d4a42 | ||
|
b1d20178f8 | ||
|
5f362cbdbd | ||
|
695996d6e2 | ||
|
1b13f32d94 | ||
|
3ee7e73ff0 | ||
|
90d7b73dd4 | ||
|
f93bdd962a | ||
|
1942c31eff | ||
|
d01271fb15 | ||
|
07a1130db3 | ||
|
835da58b53 | ||
|
9c8f96e233 | ||
|
b0ab8cd77f | ||
|
8fce29caf7 | ||
|
14eaa57434 | ||
|
58105824d9 | ||
|
4704a70cb7 | ||
|
34a8463bf9 | ||
|
e339e730f4 | ||
|
286747c23c | ||
|
3ee1607298 | ||
|
4161d31642 | ||
|
4c1d7a8f2d | ||
|
2da450d69d | ||
|
fe69f84c85 | ||
|
ba5f2032ba | ||
|
7097ba07d1 | ||
|
30a384fe1e | ||
|
520e0f1b89 | ||
|
a7d059b3ed | ||
|
bba44abf52 | ||
|
39d0708cca | ||
|
2d8b719ab0 | ||
|
5efa27c2a3 | ||
|
67e8fc0c43 | ||
|
8e42e3f21f | ||
|
29a8260514 | ||
|
8c7a765e11 | ||
|
f3d0f88f95 | ||
|
2ddd2d16ed | ||
|
9fed2ca41b | ||
|
eb7c5c4437 | ||
|
09851600f7 | ||
|
425a3c85a9 | ||
|
0f7a78ee25 | ||
|
7148f6fd41 | ||
|
e83781b26a | ||
|
cd7dccd804 | ||
|
7a68c971aa | ||
|
cfa7708b57 | ||
|
c47f872f6f | ||
|
ef2aad8956 | ||
|
ab126729e0 | ||
|
e3c85c585e | ||
|
0b4eca4724 | ||
|
142d3ef543 | ||
|
ceb52eedaf | ||
|
772f7a2757 | ||
|
db792ab5a9 | ||
|
97ec680af2 | ||
|
ba4d5453a2 | ||
|
36a982f7e2 | ||
|
2a5d30d749 | ||
|
122528f9a9 | ||
|
55401a746c | ||
|
8cbc81b8bb | ||
|
da7f66a531 | ||
|
7893a121c0 | ||
|
4c5d028509 | ||
|
eef116e26b | ||
|
8fba3f4ca9 | ||
|
839d3fb689 | ||
|
377cc4ca1f | ||
|
030c46264b | ||
|
dad37dece3 | ||
|
57e2c4ea45 | ||
|
c1a8ffd814 | ||
|
b95c918dc6 | ||
|
e9586711e0 | ||
|
ffef4936f9 | ||
|
fcde507183 | ||
|
3b72157e64 | ||
|
7dce579ac3 | ||
|
16918ddb7d | ||
|
b65782e13c | ||
|
eb60f6717a | ||
|
923a1a2057 | ||
|
216afd45cc | ||
|
3fe1c0cdc3 | ||
|
afadd25885 | ||
|
e2b20f466d | ||
|
01712c3f23 | ||
|
b6fda8865f | ||
|
db3e8a9c6b | ||
|
d1491cc203 | ||
|
d31371b486 | ||
|
2afcddbf49 | ||
|
25fb645c4b | ||
|
5c689ac5b1 | ||
|
e1c8088de2 | ||
|
d40037ef49 | ||
|
faa0246e28 | ||
|
0749073120 | ||
|
2dccd36a6d | ||
|
23494ab630 | ||
|
2f15c9a4a7 | ||
|
c3203fdacd | ||
|
222c616148 | ||
|
44e7e25cab | ||
|
fc1b9abe66 | ||
|
365c8d0953 | ||
|
8ffd98162c | ||
|
c671596c6f | ||
|
b22437840d | ||
|
4c5fe824c2 | ||
|
1fee773313 | ||
|
6be952491a | ||
|
781147bf0e | ||
|
a4cd40c2f8 | ||
|
96a2dd7c72 | ||
|
f6b7dcbad7 | ||
|
b011c3df03 | ||
|
014acbfaf5 | ||
|
ee9c9b33ca | ||
|
b2d8f5f023 | ||
|
790b9cbc13 | ||
|
bffe34fe0a | ||
|
a8022077f6 | ||
|
80a98f04c7 | ||
|
8a36eb4532 | ||
|
abef4f0f79 | ||
|
05fe68823a | ||
|
567cdd5510 | ||
|
6d9d8797fe | ||
|
44a26fd340 | ||
|
3b3751c827 | ||
|
6273d723f1 | ||
|
6863fef7e5 | ||
|
3a6e74ae1c | ||
|
16bec0a656 | ||
|
c3dfdde626 | ||
|
544019f67d | ||
|
bef12c7a8f | ||
|
3ef37c15c7 | ||
|
68a6113c26 | ||
|
cbccca20d0 | ||
|
e3378d5636 | ||
|
c89e414bb5 | ||
|
718b410253 | ||
|
faf4ea6434 | ||
|
abb802b881 | ||
|
d9ecf38e42 | ||
|
7ef19e0ead | ||
|
c621ccf679 | ||
|
0f0719eaa2 | ||
|
5b889f0b32 | ||
|
82a0c1024c | ||
|
af85fe3892 | ||
|
f998041748 | ||
|
2b884e73db | ||
|
e3c5def536 | ||
|
fae4493abc | ||
|
67dd929951 | ||
|
805c2657f2 | ||
|
ab2f15b5a2 | ||
|
3c2604b384 | ||
|
12b5bd3a4f | ||
|
74e8bc3bda | ||
|
6bbce06d93 | ||
|
22361bdf42 | ||
|
076f450ec7 | ||
|
6d8ec69a4d | ||
|
b7e3a54e15 | ||
|
2943cb525f | ||
|
1278288a42 | ||
|
66a93ee108 | ||
|
ac23119838 | ||
|
b55930f084 | ||
|
d6e243321b | ||
|
2ddb3fbf72 | ||
|
45dc2162dc | ||
|
77d10c93d6 | ||
|
66a77519d7 | ||
|
3bafc89855 | ||
|
4fa285e85a | ||
|
041cedbc58 | ||
|
cbf82fcd29 | ||
|
5dc0c8c0b3 | ||
|
c83f78044e | ||
|
d7407ecf66 | ||
|
82aac93f36 | ||
|
c92d6ecbb6 | ||
|
a20fe2b5a6 | ||
|
3d2c74a760 | ||
|
7b2e452cd5 | ||
|
1363af24a7 | ||
|
84187ce109 | ||
|
0466b49520 | ||
|
3b131f2db6 | ||
|
588da9b719 | ||
|
ddca467e30 | ||
|
8466a910da | ||
|
0e6c59983f | ||
|
e6de873b6e | ||
|
b148f3ca9e | ||
|
348a9c83f5 | ||
|
6517704850 | ||
|
cc58d27122 |
546 changed files with 16301 additions and 10347 deletions
|
@ -13,5 +13,13 @@ module.exports = {
|
|||
"no-empty": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"no-unused-vars": "warn"
|
||||
},
|
||||
"globals": {
|
||||
"DEFINE_VERSION": "readonly",
|
||||
"DEFINE_GLOBAL_HASH": "readonly",
|
||||
// only available in sw.js
|
||||
"DEFINE_UNHASHED_PRECACHED_ASSETS": "readonly",
|
||||
"DEFINE_HASHED_PRECACHED_ASSETS": "readonly",
|
||||
"DEFINE_HASHED_CACHED_ON_REQUEST_ASSETS": "readonly"
|
||||
}
|
||||
};
|
||||
|
|
44
.github/workflows/docker-publish.yml
vendored
Normal file
44
.github/workflows/docker-publish.yml
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
name: Container Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
tags: [ 'v*' ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
|||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.DS_Store
|
||||
node_modules
|
||||
fetchlogs
|
||||
sessionexports
|
||||
|
@ -8,3 +9,5 @@ target
|
|||
lib
|
||||
*.tar.gz
|
||||
.eslintcache
|
||||
.tmp
|
||||
tmp/
|
||||
|
|
|
@ -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
18
.woodpecker.yml
Normal 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 ]
|
150
CONTRIBUTING.md
Normal file
150
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,150 @@
|
|||
Contributing code to hydrogen-web
|
||||
==================================
|
||||
|
||||
Everyone is welcome to contribute code to hydrogen-web, provided that they are
|
||||
willing to license their contributions under the same license as the project
|
||||
itself. We follow a simple 'inbound=outbound' model for contributions: the act
|
||||
of submitting an 'inbound' contribution means that the contributor agrees to
|
||||
license the code under the same terms as the project's overall 'outbound'
|
||||
license - in this case, Apache Software License v2 (see
|
||||
[LICENSE](LICENSE)).
|
||||
|
||||
How to contribute
|
||||
-----------------
|
||||
|
||||
The preferred and easiest way to contribute changes to the project is to fork
|
||||
it on github, and then create a pull request to ask us to pull your changes
|
||||
into our repo (https://help.github.com/articles/using-pull-requests/)
|
||||
|
||||
We use GitHub's pull request workflow to review the contribution, and either
|
||||
ask you to make any refinements needed or merge it and make them ourselves.
|
||||
|
||||
Things that should go into your PR description:
|
||||
* References to any bugs fixed by the change (in GitHub's `Fixes` notation)
|
||||
* Describe the why and what is changing in the PR description so it's easy for
|
||||
onlookers and reviewers to onboard and context switch.
|
||||
* If your PR makes visual changes, include both **before** and **after** screenshots
|
||||
to easily compare and discuss what's changing.
|
||||
* Include a step-by-step testing strategy so that a reviewer can check out the
|
||||
code locally and easily get to the point of testing your change.
|
||||
* Add comments to the diff for the reviewer that might help them to understand
|
||||
why the change is necessary or how they might better understand and review it.
|
||||
|
||||
We use continuous integration, and all pull requests get automatically tested:
|
||||
if your change breaks the build, then the PR will show that there are failed
|
||||
checks, so please check back after a few minutes.
|
||||
|
||||
Tests
|
||||
-----
|
||||
If your PR is a feature then we require that the PR also includes tests.
|
||||
These need to test that your feature works as expected and ideally test edge cases too.
|
||||
|
||||
Tests are written as unit tests by exporting a `tests` function from the file to be tested.
|
||||
The function returns an object where the key is the test label, and the value is a
|
||||
function that accepts an [assert](https://nodejs.org/api/assert.html) object, and return a Promise or nothing.
|
||||
|
||||
Note that there is currently a limitation that files that are not indirectly included from `src/platform/web/main.js` won't be found by the runner.
|
||||
|
||||
You can run the tests by running `yarn test`.
|
||||
This uses the [impunity](https://github.com/bwindels/impunity) runner.
|
||||
|
||||
We don't require tests for bug fixes.
|
||||
|
||||
In the future we may formalise this more.
|
||||
|
||||
Code style
|
||||
----------
|
||||
The js-sdk aims to target TypeScript/ES6. All new files should be written in
|
||||
TypeScript and existing files should use ES6 principles where possible.
|
||||
|
||||
Please disable any automatic formatting tools you may have active.
|
||||
If present, you'll be asked to undo any unrelated whitespace changes during code review.
|
||||
|
||||
Members should not be exported as a default export in general.
|
||||
In general, avoid using `export default`.
|
||||
|
||||
The remaining code-style for hydrogen is [in the process of being documented](codestyle.md), but
|
||||
contributors are encouraged to read the
|
||||
[code style document for matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md)
|
||||
and follow the principles set out there.
|
||||
|
||||
Please ensure your changes match the cosmetic style of the existing project,
|
||||
and ***never*** mix cosmetic and functional changes in the same commit, as it
|
||||
makes it horribly hard to review otherwise.
|
||||
|
||||
Attribution
|
||||
-----------
|
||||
If you change or create a file, feel free to add yourself to the copyright holders
|
||||
in the license header of that file.
|
||||
|
||||
Sign off
|
||||
--------
|
||||
In order to have a concrete record that your contribution is intentional
|
||||
and you agree to license it under the same terms as the project's license, we've
|
||||
adopted the same lightweight approach that the Linux Kernel
|
||||
(https://www.kernel.org/doc/Documentation/SubmittingPatches), Docker
|
||||
(https://github.com/docker/docker/blob/master/CONTRIBUTING.md), and many other
|
||||
projects use: the DCO (Developer Certificate of Origin:
|
||||
http://developercertificate.org/). This is a simple declaration that you wrote
|
||||
the contribution or otherwise have the right to contribute it to Matrix:
|
||||
|
||||
```
|
||||
Developer Certificate of Origin
|
||||
Version 1.1
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
660 York Street, Suite 102,
|
||||
San Francisco, CA 94110 USA
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
```
|
||||
|
||||
If you agree to this for your contribution, then all that's needed is to
|
||||
include the line in your commit or pull request comment:
|
||||
|
||||
```
|
||||
Signed-off-by: Your Name <your@email.example.org>
|
||||
```
|
||||
|
||||
We accept contributions under a legally identifiable name, such as your name on
|
||||
government documentation or common-law names (names claimed by legitimate usage
|
||||
or repute). Unfortunately, we cannot accept anonymous contributions at this
|
||||
time.
|
||||
|
||||
Git allows you to add this signoff automatically when using the `-s` flag to
|
||||
`git commit`, which uses the name and email set in your `user.name` and
|
||||
`user.email` git configs.
|
||||
|
||||
If you forgot to sign off your commits before making your pull request and are
|
||||
on Git 2.17+ you can mass signoff using rebase:
|
||||
|
||||
```
|
||||
git rebase --signoff origin/develop
|
||||
```
|
14
Makefile
Normal file
14
Makefile
Normal 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}'
|
27
README.md
27
README.md
|
@ -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
|
||||
|
||||
|
|
|
@ -8,3 +8,5 @@
|
|||
otherwise it becomes hard to remember what was a default/named export
|
||||
- should we return promises from storage mutation calls? probably not, as we don't await them anywhere. only read calls should return promises?
|
||||
- we don't anymore
|
||||
- don't use these features, as they are not widely enough supported.
|
||||
- [lookbehind in regular expressions](https://caniuse.com/js-regexp-lookbehind)
|
||||
|
|
|
@ -28,7 +28,7 @@ You can only verify by comparing keys manually currently. In Element, go to your
|
|||
|
||||
## I want to host my own Hydrogen, how do I do that?
|
||||
|
||||
There are no published builds at this point. You need to checkout the version you want to build, or master if you want to run bleeding edge, and run `yarn install` and then `yarn build` in a console (and install nodejs > 14 and yarn if you haven't yet). Now you should find all the files needed to host Hydrogen in the `target/` folder, just copy them all over to your server. As always, don't host your client on the same [origin](https://web.dev/same-origin-policy/#what's-considered-same-origin) as your homeserver.
|
||||
Published builds can be found at https://github.com/vector-im/hydrogen-web/releases. For building your own, you need to checkout the version you want to build, or master if you want to run bleeding edge, and run `yarn install` and then `yarn build` in a console (and install nodejs >= 15 and yarn if you haven't yet). Now you should find all the files needed to host Hydrogen in the `target/` folder, just copy them all over to your server. As always, don't host your client on the same [origin](https://web.dev/same-origin-policy/#what's-considered-same-origin) as your homeserver.
|
||||
|
||||
## I want to embed Hydrogen in my website, how should I do that?
|
||||
|
||||
|
|
11
doc/IMPORT-ISSUES.md
Normal file
11
doc/IMPORT-ISSUES.md
Normal 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.
|
148
doc/SDK.md
148
doc/SDK.md
|
@ -1,78 +1,116 @@
|
|||
# How to use Hydrogen as an SDK
|
||||
# Hydrogen View SDK
|
||||
|
||||
If you want to use end-to-end encryption, it is recommended to use a [supported build system](../src/sdk/paths/) (currently only vite) to be able to locate the olm library files.
|
||||
|
||||
You can create a project using the following commands
|
||||
The Hydrogen view SDK allows developers to integrate parts of the Hydrogen application into the UI of their own application. Hydrogen is written with the MVVM pattern, so to construct a view, you'd first construct a view model, which you then pass into the view. For most view models, you will first need a running client.
|
||||
|
||||
## Example
|
||||
|
||||
The Hydrogen SDK requires some assets to be shipped along with your app for things like downloading attachments, and end-to-end encryption. A convenient way to make this happen is provided by the SDK (importing `hydrogen-view-sdk/paths/vite`) but depends on your build system. Currently, only [vite](https://vitejs.dev/) is supported, so that's what we'll be using in the example below.
|
||||
|
||||
You can create a vite project using the following commands:
|
||||
|
||||
```sh
|
||||
# you can pick "vanilla-ts" here for project type if you're not using react or vue
|
||||
yarn create vite
|
||||
cd <your-project-name>
|
||||
yarn
|
||||
yarn add https://github.com/vector-im/hydrogen-web.git
|
||||
yarn add hydrogen-view-sdk
|
||||
```
|
||||
|
||||
If you go into the `src` directory, you should see a `main.ts` file. If you put this code in there, you should see a basic timeline after login and initial sync have finished.
|
||||
You should see a `index.html` in the project root directory, containing an element with `id="app"`. Add the attribute `class="hydrogen"` to this element, as the CSS we'll include from the SDK assumes for now that the app is rendered in an element with this classname.
|
||||
|
||||
If you go into the `src` directory, you should see a `main.ts` file. If you put this code in there, you should see a basic timeline after login and initial sync have finished (might take a while before you see anything on the screen actually).
|
||||
|
||||
You'll need to provide the username and password of a user that is already in the [#element-dev:matrix.org](https://matrix.to/#/#element-dev:matrix.org) room (or change the room id).
|
||||
|
||||
```ts
|
||||
import {
|
||||
Platform,
|
||||
SessionContainer,
|
||||
Client,
|
||||
LoadStatus,
|
||||
createNavigation,
|
||||
createRouter,
|
||||
RoomViewModel,
|
||||
TimelineView
|
||||
} from "hydrogen-web";
|
||||
import {olmPaths, downloadSandboxPath} from "hydrogen-web/src/sdk/paths/vite";
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||
|
||||
// bootstrap a session container
|
||||
const platform = new Platform(app, {
|
||||
TimelineView,
|
||||
viewClassForTile
|
||||
} 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,
|
||||
olm: olmPaths,
|
||||
}, null, { development: true });
|
||||
const navigation = createNavigation();
|
||||
platform.setNavigation(navigation);
|
||||
const urlRouter = createRouter({
|
||||
navigation: navigation,
|
||||
history: platform.history
|
||||
});
|
||||
urlRouter.attach();
|
||||
const sessionContainer = new SessionContainer({
|
||||
platform,
|
||||
olmPromise: platform.loadOlm(),
|
||||
workerPromise: platform.loadOlmWorker()
|
||||
});
|
||||
worker: workerPath,
|
||||
olm: {
|
||||
wasm: olmWasmPath,
|
||||
legacyBundle: olmLegacyJsPath,
|
||||
wasmBundle: olmJsPath
|
||||
}
|
||||
};
|
||||
import "hydrogen-view-sdk/assets/theme-element-light.css";
|
||||
// OR import "hydrogen-view-sdk/assets/theme-element-dark.css";
|
||||
|
||||
// wait for login and first sync to finish
|
||||
const loginOptions = await sessionContainer.queryLogin("matrix.org").result;
|
||||
sessionContainer.startWithLogin(loginOptions.password("user", "password"));
|
||||
await sessionContainer.loadStatus.waitFor((status: string) => {
|
||||
return status === LoadStatus.Ready ||
|
||||
status === LoadStatus.Error ||
|
||||
status === LoadStatus.LoginFailed;
|
||||
}).promise;
|
||||
// check the result
|
||||
if (sessionContainer.loginFailure) {
|
||||
alert("login failed: " + sessionContainer.loginFailure);
|
||||
} else if (sessionContainer.loadError) {
|
||||
alert("load failed: " + sessionContainer.loadError.message);
|
||||
} else {
|
||||
// we're logged in, we can access the room now
|
||||
const {session} = sessionContainer;
|
||||
// room id for #element-dev:matrix.org
|
||||
const room = session.rooms.get("!bEWtlqtDwCLFIAKAcv:matrix.org");
|
||||
const vm = new RoomViewModel({
|
||||
room,
|
||||
ownUserId: session.userId,
|
||||
platform,
|
||||
urlCreator: urlRouter,
|
||||
navigation,
|
||||
async function main() {
|
||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||
const config = {};
|
||||
const platform = new Platform({container: app, assetPaths, config, options: { development: import.meta.env.DEV }});
|
||||
const navigation = createNavigation();
|
||||
platform.setNavigation(navigation);
|
||||
const urlRouter = createRouter({
|
||||
navigation: navigation,
|
||||
history: platform.history
|
||||
});
|
||||
await vm.load();
|
||||
const view = new TimelineView(vm.timelineViewModel);
|
||||
app.appendChild(view.mount());
|
||||
urlRouter.attach();
|
||||
const client = new Client(platform);
|
||||
|
||||
const loginOptions = await client.queryLogin("matrix.org").result;
|
||||
client.startWithLogin(loginOptions.password("username", "password"));
|
||||
|
||||
await client.loadStatus.waitFor((status: string) => {
|
||||
return status === LoadStatus.Ready ||
|
||||
status === LoadStatus.Error ||
|
||||
status === LoadStatus.LoginFailed;
|
||||
}).promise;
|
||||
|
||||
if (client.loginFailure) {
|
||||
alert("login failed: " + client.loginFailure);
|
||||
} else if (client.loadError) {
|
||||
alert("load failed: " + client.loadError.message);
|
||||
} else {
|
||||
const {session} = client;
|
||||
// looks for room corresponding to #element-dev:matrix.org, assuming it is already joined
|
||||
const room = session.rooms.get("!bEWtlqtDwCLFIAKAcv:matrix.org");
|
||||
const vm = new RoomViewModel({
|
||||
room,
|
||||
ownUserId: session.userId,
|
||||
platform,
|
||||
urlCreator: urlRouter,
|
||||
navigation,
|
||||
});
|
||||
await vm.load();
|
||||
const view = new TimelineView(vm.timelineViewModel, viewClassForTile);
|
||||
app.appendChild(view.mount());
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
## Typescript support
|
||||
|
||||
Typescript support is not yet available while we're converting the Hydrogen codebase to Typescript.
|
||||
In your `src` directory, you'll need to add a `.d.ts` (can be called anything, e.g. `deps.d.ts`)
|
||||
containing this snippet to make Typescript not complain that `hydrogen-view-sdk` doesn't have types:
|
||||
|
||||
```ts
|
||||
declare module "hydrogen-view-sdk";
|
||||
```
|
||||
|
||||
## API Stability
|
||||
|
||||
This library follows semantic versioning; there is no API stability promised as long as the major version is still 0. Once 1.0.0 is released, breaking changes will be released with a change in major versioning.
|
||||
|
||||
## Third-party licenses
|
||||
|
||||
This package bundles the bs58 package ([license](https://github.com/cryptocoinjs/bs58/blob/master/LICENSE)), and the Inter font ([license](https://github.com/rsms/inter/blob/master/LICENSE.txt)).
|
||||
|
|
204
doc/THEMING.md
Normal file
204
doc/THEMING.md
Normal 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.
|
|
@ -1,7 +1,38 @@
|
|||
# Typescript migration
|
||||
# Typescript style guide
|
||||
|
||||
## Introduce `abstract` & `override`
|
||||
## Use `type` rather than `interface` for named parameters and POJO return values.
|
||||
|
||||
- find all methods and getters that throw or are empty in base classes and turn into abstract method or if all methods are abstract, into an interface.
|
||||
- change child impls to not call super.method and to add override
|
||||
- don't allow implicit override in ts config
|
||||
`type` and `interface` can be used somewhat interchangeably, but let's use `type` to describe data and `interface` to describe (polymorphic) behaviour.
|
||||
|
||||
Good examples of data are option objects to have named parameters, and POJO (plain old javascript objects) without any methods, just fields.
|
||||
|
||||
Also see [this playground](https://www.typescriptlang.org/play?#code/C4TwDgpgBACghgJwgO2AeTMAlge2QZygF4oBvAKCiqmTgFsIAuKfYBLZAcwG5LqATCABs4IAPzNkAVzoAjCAl4BfcuVCQoAYQAWWIfwzY8hEvCSpDuAlABkZPlQDGOITgTNW7LstWOR+QjMUYHtqKGcCNilHYDcAChxMK3xmIIsk4wBKewcoFRVyPzgArV19KAgAD2AUfkDEYNDqCM9o2IQEjIJmHT0DLvxsijCw-ClIDsSjAkzeEebjEIYAuE5oEgADABJSKeSAOloGJSgsQh29433nVwQlDbnqfKA)
|
||||
|
||||
## Use `type foo = { [key: string]: any }` for types that you intend to fill in later.
|
||||
|
||||
For instance, if you have a method such as:
|
||||
```js
|
||||
function load(options) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
and you intend to type options at some later point, do:
|
||||
```ts
|
||||
type Options = { [key: string]: any}
|
||||
```
|
||||
This makes it much easier to add the necessary type information at a later time.
|
||||
|
||||
## Use `object` or `Record<string, any>` to describe a type that accepts any javascript object.
|
||||
|
||||
Sometimes a function or method may genuinely need to accept any object; eg:
|
||||
```js
|
||||
function encodeBody(body) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
In this scenario:
|
||||
- Use `object` if you know that you will not access any property
|
||||
- Use `Record<string, any>` if you need to access some property
|
||||
|
||||
Both usages prevent the type from accepting primitives (eg: string, boolean...).
|
||||
If using `Record`, ensure that you have guards to check that the properties really do exist.
|
||||
|
|
206
doc/UI/ui.md
Normal file
206
doc/UI/ui.md
Normal 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.
|
BIN
doc/images/coloring-process.png
Normal file
BIN
doc/images/coloring-process.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.8 KiB |
BIN
doc/images/svg-icon-example.png
Normal file
BIN
doc/images/svg-icon-example.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
BIN
doc/images/theming-architecture.png
Normal file
BIN
doc/images/theming-architecture.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
109
doc/impl-thoughts/SDK.md
Normal file
109
doc/impl-thoughts/SDK.md
Normal file
|
@ -0,0 +1,109 @@
|
|||
SDK:
|
||||
|
||||
- we need to compile src/lib.ts to javascript, with a d.ts file generated as well. We need to compile to javascript once for cjs and once of es modules. The package.json looks like this:
|
||||
|
||||
```
|
||||
"main": "./dist/index.cjs",
|
||||
"exports": {
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"types": "dist/index.d.ts",
|
||||
```
|
||||
|
||||
we don't need to bundle for the sdk case! we might need to do some transpilation to just plain ES6 (e.g. don't assume ?. and ??) we could use a browserslist query for this e.g. `node 14`. esbuild seems to support this as well, tldraw uses esbuild for their build.
|
||||
|
||||
one advantage of not bundling the files for the sdk is that you can still use import overrides in the consuming project build settings. is that an idiomatic way of doing things though?
|
||||
|
||||
|
||||
|
||||
|
||||
this way we will support typescript, non-esm javascript and esm javascript using libhydrogen as an SDK
|
||||
|
||||
got this from https://medium.com/dazn-tech/publishing-npm-packages-as-native-es-modules-41ffbc0a9dea
|
||||
|
||||
how about the assets?
|
||||
|
||||
we also need to build the app
|
||||
|
||||
we need to be able to version libhydrogen independently from hydrogen the app? as any api breaking changes will need a major version increase. we probably want to end up with a monorepo where the app uses the sdk as well and we just use the local code with yarn link?
|
||||
|
||||
## Assets
|
||||
|
||||
we want to provide scss/sass files, but also css that can be included
|
||||
https://github.com/webpack/webpack/issues/7353 seems to imply that we just need to include the assets in the published files and from there on it is the consumer of libhydrogen's problem.
|
||||
|
||||
|
||||
how does all of this tie in with vite?
|
||||
|
||||
|
||||
we want to have hydrogenapp be a consumer of libhydrogen, potentially as two packages in a monorepo ... but we want the SDK to expose views and stylesheets... without having an index.html (which would be in hydrogenapp). this seems a bit odd...?
|
||||
|
||||
what would be in hydrogenapp actually? just an index.html file?
|
||||
|
||||
I'm not sure it makes sense to have them be 2 different packages in a monorepo, they should really be two artifacts from the same directory.
|
||||
|
||||
the stylesheets included in libhydrogen are from the same main.css file as is used in the app
|
||||
|
||||
https://www.freecodecamp.org/news/build-a-css-library-with-vitejs/
|
||||
|
||||
basically, we import the sass file from src/lib.ts so it is included in the assets there too, and we also create a plugin that emits a file for every sass file as suggested in the link above?
|
||||
|
||||
we probably want two different build commands for the app and the sdk though, we could have a parent vite config that both build configs extend from?
|
||||
|
||||
|
||||
### Dependency assets
|
||||
our dependencies should not be bundled for the SDK case. So if we import aesjs, it would be up to the build system of the consuming project to make that import work.
|
||||
|
||||
the paths.ts thingy ... we want to make it easy for people to setup the assets for our dependencies (olm), some assets are also part of the sdk itself. it might make sense to make all of the assets there part of the sdk (e.g. bundle olm.wasm and friends?) although shipping crypto, etc ...
|
||||
|
||||
perhaps we should have an include file per build system that treats own assets and dep assets the same by including the package name as wel for our own deps:
|
||||
```js
|
||||
import _downloadSandboxPath from "@matrix-org/hydrogen-sdk/download-sandbox.html?url";
|
||||
import _serviceWorkerPath from "@matrix-org/hydrogen-sdk/sw.js?url"; // not yet sure this is the way to do it
|
||||
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";
|
||||
|
||||
export const olmPaths = {
|
||||
wasm: olmWasmPath,
|
||||
legacyBundle: olmLegacyJsPath,
|
||||
wasmBundle: olmJsPath,
|
||||
};
|
||||
|
||||
export const downloadSandboxPath = _downloadSandboxPath;
|
||||
```
|
||||
|
||||
we could put this file per build system, as ESM, in dist as well so you can include it to get the paths
|
||||
|
||||
|
||||
## Tooling
|
||||
|
||||
- `vite` a more high-level build tool that takes your index.html and turns it into optimized assets that you can host for production, as well as a very fast dev server. is used to have good default settings for our tools, typescript support, and also deals with asset compiling. good dev server. Would be nice to have the same tool for dev and prod. vite has good support for using `import` for anything that is not javascript, where we had an issue with `snowpack` (to get the prod path of an asset).
|
||||
- `rollup`: inlines
|
||||
- `lerna` is used to handle multi-package monorepos
|
||||
- `esbuild`: a js/ts build tool that we could use for building the lower level sdk where no other assets are involved, `vite` uses it for fast dev builds (`rollup` for prod). For now we won't extract a lower level sdk though.
|
||||
|
||||
|
||||
## TODO
|
||||
|
||||
- finish vite app build (without IE11 for now?)
|
||||
- create vite config to build src/lib.ts in cjs and esm, inheriting from a common base config with the app config
|
||||
- this will create a dist folder with
|
||||
- the whole source tree in es and cjs format
|
||||
- an es file to import get the asset paths as they are expected by Platform, per build system
|
||||
- assets from hydrogen itself:
|
||||
- css files and any resource used therein
|
||||
- download-sandbox.html
|
||||
- a type declaration file (index.d.ts)
|
||||
|
||||
## Questions
|
||||
- can rollup not bundle the source tree and leave modules intact?
|
||||
- if we can use a function that creates a chunk per file to pass to manualChunks and disable chunk hashing we can probably do this. See https://rollupjs.org/guide/en/#outputmanualchunks
|
||||
|
||||
looks like we should be able to disable chunk name hashing with chunkFileNames https://rollupjs.org/guide/en/#outputoptions-object
|
||||
|
||||
|
||||
we should test this with a vite test config
|
||||
|
||||
we also need to compile down to ES6, both for the app and for the sdk
|
|
@ -1,72 +0,0 @@
|
|||
{
|
||||
"name": "hydrogen-web",
|
||||
"version": "0.2.16",
|
||||
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
|
||||
"main": "src/lib.ts",
|
||||
"directories": {
|
||||
"doc": "doc"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint --cache src/",
|
||||
"lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts",
|
||||
"lint-ci": "eslint src/",
|
||||
"test": "impunity --entry-point src/main.js --force-esm-dirs lib/ src/",
|
||||
"start": "snowpack dev --port 3000",
|
||||
"build": "node --experimental-modules scripts/build.mjs",
|
||||
"postinstall": "node ./scripts/post-install.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:vector-im/hydrogen-web.git"
|
||||
},
|
||||
"author": "matrix.org",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vector-im/hydrogen-web/issues"
|
||||
},
|
||||
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.1",
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@rollup/plugin-babel": "^5.1.0",
|
||||
"@rollup/plugin-multi-entry": "^4.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.2",
|
||||
"@typescript-eslint/parser": "^4.29.2",
|
||||
"autoprefixer": "^10.2.6",
|
||||
"cheerio": "^1.0.0-rc.3",
|
||||
"commander": "^6.0.0",
|
||||
"core-js": "^3.6.5",
|
||||
"eslint": "^7.32.0",
|
||||
"fake-indexeddb": "^3.1.2",
|
||||
"finalhandler": "^1.1.1",
|
||||
"impunity": "^1.0.1",
|
||||
"mdn-polyfills": "^5.20.0",
|
||||
"node-html-parser": "^4.0.0",
|
||||
"postcss": "^8.1.1",
|
||||
"postcss-css-variables": "^0.17.0",
|
||||
"postcss-flexbugs-fixes": "^4.2.1",
|
||||
"postcss-import": "^12.0.1",
|
||||
"postcss-url": "^8.0.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rollup-plugin-cleanup": "^3.1.1",
|
||||
"serve-static": "^1.13.2",
|
||||
"snowpack": "^3.8.3",
|
||||
"typescript": "^4.3.5",
|
||||
"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",
|
||||
"@rollup/plugin-commonjs": "^15.0.0",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^9.0.0",
|
||||
"aes-js": "^3.1.2",
|
||||
"another-json": "^0.2.0",
|
||||
"base64-arraybuffer": "^0.2.0",
|
||||
"bs58": "^4.0.1",
|
||||
"dompurify": "^2.3.0",
|
||||
"es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush",
|
||||
"rollup": "^2.26.4",
|
||||
"text-encoding": "^0.7.0",
|
||||
"vite": "^2.6.2"
|
||||
}
|
||||
}
|
71
package.json
71
package.json
|
@ -1,9 +1,66 @@
|
|||
{
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/common",
|
||||
"packages/matrix",
|
||||
"packages/domain",
|
||||
"packages/web"
|
||||
]
|
||||
"name": "hydrogen-web",
|
||||
"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 && ./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",
|
||||
"url": "git@github.com:vector-im/hydrogen-web.git"
|
||||
},
|
||||
"author": "matrix.org",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vector-im/hydrogen-web/issues"
|
||||
},
|
||||
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.2",
|
||||
"@typescript-eslint/parser": "^4.29.2",
|
||||
"acorn": "^8.6.0",
|
||||
"acorn-walk": "^8.2.0",
|
||||
"aes-js": "^3.1.2",
|
||||
"bs58": "^4.0.1",
|
||||
"core-js": "^3.6.5",
|
||||
"es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush",
|
||||
"escodegen": "^2.0.0",
|
||||
"eslint": "^7.32.0",
|
||||
"fake-indexeddb": "^3.1.2",
|
||||
"impunity": "^1.0.9",
|
||||
"mdn-polyfills": "^5.20.0",
|
||||
"merge-options": "^3.0.4",
|
||||
"node-html-parser": "^4.0.0",
|
||||
"postcss-css-variables": "^0.18.0",
|
||||
"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.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.8.tgz",
|
||||
"another-json": "^0.2.0",
|
||||
"base64-arraybuffer": "^0.2.0",
|
||||
"dompurify": "^2.3.0",
|
||||
"off-color": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"name": "hydrogen-common",
|
||||
"version": "0.0.1",
|
||||
"main": "src/lib.ts",
|
||||
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
|
||||
"devDependencies": {
|
||||
"vite": "^2.6.2",
|
||||
"typescript": "^4.3.5"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export * from "./utils/index";
|
||||
export * from "./observable/index";
|
||||
export {IDBLogger} from "./logging/IDBLogger.js";
|
||||
export {NullLogger} from "./logging/NullLogger.js";
|
||||
export {ConsoleLogger} from "./logging/ConsoleLogger.js";
|
||||
export type {LogItem} from "./logging/LogItem.js";
|
|
@ -1,99 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import {LogLevel} from "./LogFilter.js";
|
||||
|
||||
function noop () {}
|
||||
|
||||
|
||||
export class NullLogger {
|
||||
constructor() {
|
||||
this.item = new NullLogItem(this);
|
||||
}
|
||||
|
||||
log() {}
|
||||
|
||||
run(_, callback) {
|
||||
return callback(this.item);
|
||||
}
|
||||
|
||||
wrapOrRun(item, _, callback) {
|
||||
if (item) {
|
||||
return item.wrap(null, callback);
|
||||
} else {
|
||||
return this.run(null, callback);
|
||||
}
|
||||
}
|
||||
|
||||
runDetached(_, callback) {
|
||||
new Promise(r => r(callback(this.item))).then(noop, noop);
|
||||
}
|
||||
|
||||
async export() {
|
||||
return null;
|
||||
}
|
||||
|
||||
get level() {
|
||||
return LogLevel;
|
||||
}
|
||||
}
|
||||
|
||||
export class NullLogItem {
|
||||
constructor(logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
wrap(_, callback) {
|
||||
return callback(this);
|
||||
}
|
||||
log() {}
|
||||
set() {}
|
||||
|
||||
runDetached(_, callback) {
|
||||
new Promise(r => r(callback(this))).then(noop, noop);
|
||||
}
|
||||
|
||||
wrapDetached(_, callback) {
|
||||
return this.refDetached(null, callback);
|
||||
}
|
||||
|
||||
run(callback) {
|
||||
return callback(this);
|
||||
}
|
||||
|
||||
refDetached() {}
|
||||
|
||||
ensureRefId() {}
|
||||
|
||||
get level() {
|
||||
return LogLevel;
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
catch(err) {
|
||||
return err;
|
||||
}
|
||||
|
||||
child() {
|
||||
return this;
|
||||
}
|
||||
|
||||
finish() {}
|
||||
}
|
||||
|
||||
export const Instance = new NullLogger();
|
|
@ -1,16 +0,0 @@
|
|||
// these are helper functions if you can't assume you always have a log item (e.g. some code paths call with one set, others don't)
|
||||
// if you know you always have a log item, better to use the methods on the log item than these utility functions.
|
||||
|
||||
import {Instance as NullLoggerInstance} from "./NullLogger.js";
|
||||
|
||||
export function wrapOrRunNullLogger(logItem, labelOrValues, callback, logLevel = null, filterCreator = null) {
|
||||
if (logItem) {
|
||||
return logItem.wrap(logItem, labelOrValues, callback, logLevel, filterCreator);
|
||||
} else {
|
||||
return NullLoggerInstance.run(null, callback);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureLogItem(logItem) {
|
||||
return logItem || NullLoggerInstance.item;
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export {AbortableOperation} from "./AbortableOperation";
|
||||
export {Disposables} from "./Disposables.js";
|
||||
export {createEnum} from "./enum.js";
|
||||
export {AbortError} from "./error.js";
|
||||
export {EventEmitter} from "./EventEmitter.js";
|
||||
export {formatSize} from "./formatSize.js";
|
||||
export {groupBy} from "./groupBy.js";
|
||||
export {Lock} from "./Lock.js";
|
||||
export {LockMap} from "./LockMap.js";
|
||||
export {LRUCache} from "./LRUCache.js";
|
||||
export {mergeMap} from "./mergeMap.js";
|
||||
export {RetainedValue} from "./RetainedValue.js";
|
||||
export {sortedIndex} from "./sortedIndex.js";
|
||||
export {abortOnTimeout} from "./timeout.js";
|
||||
export {typedJSON} from "./typedJSON.js";
|
|
@ -1,13 +0,0 @@
|
|||
export default {
|
||||
build: {
|
||||
lib: {
|
||||
entry: "src/lib.ts",
|
||||
formats: ["es", "iife"],
|
||||
name: "hydrogenCommon",
|
||||
}
|
||||
},
|
||||
public: false,
|
||||
server: {
|
||||
hmr: false
|
||||
}
|
||||
};
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"name": "hydrogen-domain",
|
||||
"version": "0.0.1",
|
||||
"main": "src/lib.ts",
|
||||
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
|
||||
"devDependencies": {
|
||||
"vite": "^2.6.2",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"hydrogen-common": "0.0.1",
|
||||
"hydrogen-matrix": "0.0.1"
|
||||
}
|
||||
}
|
|
@ -1,195 +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 {SortedArray} from "../observable/index.js";
|
||||
import {ViewModel} from "./ViewModel.js";
|
||||
import {avatarInitials, getIdentifierColorNumber} from "./avatar.js";
|
||||
|
||||
class SessionItemViewModel extends ViewModel {
|
||||
constructor(options, pickerVM) {
|
||||
super(options);
|
||||
this._pickerVM = pickerVM;
|
||||
this._sessionInfo = options.sessionInfo;
|
||||
this._isDeleting = false;
|
||||
this._isClearing = false;
|
||||
this._error = null;
|
||||
this._exportDataUrl = null;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this._error && this._error.message;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
this._isDeleting = true;
|
||||
this.emitChange("isDeleting");
|
||||
try {
|
||||
await this._pickerVM.delete(this.id);
|
||||
} catch(err) {
|
||||
this._error = err;
|
||||
console.error(err);
|
||||
this.emitChange("error");
|
||||
} finally {
|
||||
this._isDeleting = false;
|
||||
this.emitChange("isDeleting");
|
||||
}
|
||||
}
|
||||
|
||||
async clear() {
|
||||
this._isClearing = true;
|
||||
this.emitChange();
|
||||
try {
|
||||
await this._pickerVM.clear(this.id);
|
||||
} catch(err) {
|
||||
this._error = err;
|
||||
console.error(err);
|
||||
this.emitChange("error");
|
||||
} finally {
|
||||
this._isClearing = false;
|
||||
this.emitChange("isClearing");
|
||||
}
|
||||
}
|
||||
|
||||
get isDeleting() {
|
||||
return this._isDeleting;
|
||||
}
|
||||
|
||||
get isClearing() {
|
||||
return this._isClearing;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this._sessionInfo.id;
|
||||
}
|
||||
|
||||
get openUrl() {
|
||||
return this.urlCreator.urlForSegment("session", this.id);
|
||||
}
|
||||
|
||||
get label() {
|
||||
const {userId, comment} = this._sessionInfo;
|
||||
if (comment) {
|
||||
return `${userId} (${comment})`;
|
||||
} else {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
get sessionInfo() {
|
||||
return this._sessionInfo;
|
||||
}
|
||||
|
||||
get exportDataUrl() {
|
||||
return this._exportDataUrl;
|
||||
}
|
||||
|
||||
async export() {
|
||||
try {
|
||||
const data = await this._pickerVM._exportData(this._sessionInfo.id);
|
||||
const json = JSON.stringify(data, undefined, 2);
|
||||
const blob = new Blob([json], {type: "application/json"});
|
||||
this._exportDataUrl = URL.createObjectURL(blob);
|
||||
this.emitChange("exportDataUrl");
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
clearExport() {
|
||||
if (this._exportDataUrl) {
|
||||
URL.revokeObjectURL(this._exportDataUrl);
|
||||
this._exportDataUrl = null;
|
||||
this.emitChange("exportDataUrl");
|
||||
}
|
||||
}
|
||||
|
||||
get avatarColorNumber() {
|
||||
return getIdentifierColorNumber(this._sessionInfo.userId);
|
||||
}
|
||||
|
||||
get avatarInitials() {
|
||||
return avatarInitials(this._sessionInfo.userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class SessionPickerViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._sessions = new SortedArray((s1, s2) => s1.id.localeCompare(s2.id));
|
||||
this._loadViewModel = null;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
// this loads all the sessions
|
||||
async load() {
|
||||
const sessions = await this.platform.sessionInfoStorage.getAll();
|
||||
this._sessions.setManyUnsorted(sessions.map(s => {
|
||||
return new SessionItemViewModel(this.childOptions({sessionInfo: s}), this);
|
||||
}));
|
||||
}
|
||||
|
||||
// for the loading of 1 picked session
|
||||
get loadViewModel() {
|
||||
return this._loadViewModel;
|
||||
}
|
||||
|
||||
async _exportData(id) {
|
||||
const sessionInfo = await this.platform.sessionInfoStorage.get(id);
|
||||
const stores = await this.logger.run("export", log => {
|
||||
return this.platform.storageFactory.export(id, log);
|
||||
});
|
||||
const data = {sessionInfo, stores};
|
||||
return data;
|
||||
}
|
||||
|
||||
async import(json) {
|
||||
try {
|
||||
const data = JSON.parse(json);
|
||||
const {sessionInfo} = data;
|
||||
sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`;
|
||||
sessionInfo.id = this._createSessionContainer().createNewSessionId();
|
||||
await this.logger.run("import", log => {
|
||||
return this.platform.storageFactory.import(sessionInfo.id, data.stores, log);
|
||||
});
|
||||
await this.platform.sessionInfoStorage.add(sessionInfo);
|
||||
this._sessions.set(new SessionItemViewModel(sessionInfo, this));
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
const idx = this._sessions.array.findIndex(s => s.id === id);
|
||||
await this.platform.sessionInfoStorage.delete(id);
|
||||
await this.platform.storageFactory.delete(id);
|
||||
this._sessions.remove(idx);
|
||||
}
|
||||
|
||||
async clear(id) {
|
||||
await this.platform.storageFactory.delete(id);
|
||||
}
|
||||
|
||||
get sessions() {
|
||||
return this._sessions;
|
||||
}
|
||||
|
||||
get cancelUrl() {
|
||||
return this.urlCreator.urlForSegment("login");
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export {createNavigation, createRouter} from "./navigation/index.js";
|
||||
// export main view & view models
|
||||
export {RootViewModel} from "./RootViewModel.js";
|
||||
export {SessionViewModel} from "./session/SessionViewModel.js";
|
||||
export {RoomViewModel} from "./session/room/RoomViewModel.js";
|
||||
export {TimelineViewModel} from "./session/room/timeline/TimelineViewModel.js";
|
|
@ -1,55 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {BaseTileViewModel} from "./BaseTileViewModel.js";
|
||||
|
||||
export class InviteTileViewModel extends BaseTileViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {invite} = options;
|
||||
this._invite = invite;
|
||||
this._url = this.urlCreator.openRoomActionUrl(this._invite.id);
|
||||
}
|
||||
|
||||
get busy() {
|
||||
return this._invite.accepting || this._invite.rejecting;
|
||||
}
|
||||
|
||||
get kind() {
|
||||
return "invite";
|
||||
}
|
||||
|
||||
get url() {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
compare(other) {
|
||||
const parentComparison = super.compare(other);
|
||||
if (parentComparison !== 0) {
|
||||
return parentComparison;
|
||||
}
|
||||
return other._invite.timestamp - this._invite.timestamp;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this._invite.name;
|
||||
}
|
||||
|
||||
get _avatarSource() {
|
||||
return this._invite;
|
||||
}
|
||||
}
|
|
@ -1,36 +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 {BaseMessageTile} from "./BaseMessageTile.js";
|
||||
|
||||
/*
|
||||
map urls:
|
||||
apple: https://developer.apple.com/library/archive/featuredarticles/iPhoneURLScheme_Reference/MapLinks/MapLinks.html
|
||||
android: https://developers.google.com/maps/documentation/urls/guide
|
||||
wp: maps:49.275267 -122.988617
|
||||
https://www.habaneroconsulting.com/stories/insights/2011/opening-native-map-apps-from-the-mobile-browser
|
||||
*/
|
||||
export class LocationTile extends BaseMessageTile {
|
||||
get mapsLink() {
|
||||
const geoUri = this._getContent().geo_uri;
|
||||
const [lat, long] = geoUri.split(":")[1].split(",");
|
||||
return `maps:${lat} ${long}`;
|
||||
}
|
||||
|
||||
get label() {
|
||||
return `${this.sender} sent their location, click to see it in maps.`;
|
||||
}
|
||||
}
|
|
@ -1,77 +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 {GapTile} from "./tiles/GapTile.js";
|
||||
import {TextTile} from "./tiles/TextTile.js";
|
||||
import {RedactedTile} from "./tiles/RedactedTile.js";
|
||||
import {ImageTile} from "./tiles/ImageTile.js";
|
||||
import {VideoTile} from "./tiles/VideoTile.js";
|
||||
import {FileTile} from "./tiles/FileTile.js";
|
||||
import {LocationTile} from "./tiles/LocationTile.js";
|
||||
import {RoomNameTile} from "./tiles/RoomNameTile.js";
|
||||
import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
|
||||
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
|
||||
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
|
||||
import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js";
|
||||
|
||||
export function tilesCreator(baseOptions) {
|
||||
return function tilesCreator(entry, emitUpdate) {
|
||||
const options = Object.assign({entry, emitUpdate}, baseOptions);
|
||||
if (entry.isGap) {
|
||||
return new GapTile(options);
|
||||
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
|
||||
return new MissingAttachmentTile(options);
|
||||
} else if (entry.eventType) {
|
||||
switch (entry.eventType) {
|
||||
case "m.room.message": {
|
||||
if (entry.isRedacted) {
|
||||
return new RedactedTile(options);
|
||||
}
|
||||
const content = entry.content;
|
||||
const msgtype = content && content.msgtype;
|
||||
switch (msgtype) {
|
||||
case "m.text":
|
||||
case "m.notice":
|
||||
case "m.emote":
|
||||
return new TextTile(options);
|
||||
case "m.image":
|
||||
return new ImageTile(options);
|
||||
case "m.video":
|
||||
return new VideoTile(options);
|
||||
case "m.file":
|
||||
return new FileTile(options);
|
||||
case "m.location":
|
||||
return new LocationTile(options);
|
||||
default:
|
||||
// unknown msgtype not rendered
|
||||
return null;
|
||||
}
|
||||
}
|
||||
case "m.room.name":
|
||||
return new RoomNameTile(options);
|
||||
case "m.room.member":
|
||||
return new RoomMemberTile(options);
|
||||
case "m.room.encrypted":
|
||||
return new EncryptedEventTile(options);
|
||||
case "m.room.encryption":
|
||||
return new EncryptionEnabledTile(options);
|
||||
default:
|
||||
// unknown type not rendered
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
|
||||
export class SessionBackupViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._session = options.session;
|
||||
this._showKeySetup = true;
|
||||
this._error = null;
|
||||
this._isBusy = false;
|
||||
this.track(this._session.hasSecretStorageKey.subscribe(() => {
|
||||
this.emitChange("status");
|
||||
}));
|
||||
}
|
||||
|
||||
get isBusy() {
|
||||
return this._isBusy;
|
||||
}
|
||||
|
||||
get backupVersion() {
|
||||
return this._session.sessionBackup?.version;
|
||||
}
|
||||
|
||||
get status() {
|
||||
if (this._session.sessionBackup) {
|
||||
return "enabled";
|
||||
} else {
|
||||
if (this._session.hasSecretStorageKey.get() === false) {
|
||||
return this._showKeySetup ? "setupKey" : "setupPhrase";
|
||||
} else {
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this._error?.message;
|
||||
}
|
||||
|
||||
showPhraseSetup() {
|
||||
this._showKeySetup = false;
|
||||
this.emitChange("status");
|
||||
}
|
||||
|
||||
showKeySetup() {
|
||||
this._showKeySetup = true;
|
||||
this.emitChange("status");
|
||||
}
|
||||
|
||||
async enterSecurityPhrase(passphrase) {
|
||||
if (passphrase) {
|
||||
try {
|
||||
this._isBusy = true;
|
||||
this.emitChange("isBusy");
|
||||
await this._session.enableSecretStorage("phrase", passphrase);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._error = err;
|
||||
this.emitChange("error");
|
||||
} finally {
|
||||
this._isBusy = false;
|
||||
this.emitChange("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async enterSecurityKey(securityKey) {
|
||||
if (securityKey) {
|
||||
try {
|
||||
this._isBusy = true;
|
||||
this.emitChange("isBusy");
|
||||
await this._session.enableSecretStorage("key", securityKey);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._error = err;
|
||||
this.emitChange("error");
|
||||
} finally {
|
||||
this._isBusy = false;
|
||||
this.emitChange("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
export default {
|
||||
build: {
|
||||
lib: {
|
||||
entry: "src/lib.ts",
|
||||
formats: ["es", "iife"],
|
||||
name: "hydrogenDomain",
|
||||
}
|
||||
},
|
||||
public: false,
|
||||
server: {
|
||||
hmr: false
|
||||
}
|
||||
};
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "hydrogen-matrix",
|
||||
"version": "0.0.1",
|
||||
"main": "src/lib.ts",
|
||||
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
|
||||
"devDependencies": {
|
||||
"vite": "^2.6.2",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"hydrogen-common": "0.0.1"
|
||||
}
|
||||
}
|
|
@ -1,365 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js";
|
||||
|
||||
const TRACKING_STATUS_OUTDATED = 0;
|
||||
const TRACKING_STATUS_UPTODATE = 1;
|
||||
|
||||
export function addRoomToIdentity(identity, userId, roomId) {
|
||||
if (!identity) {
|
||||
identity = {
|
||||
userId: userId,
|
||||
roomIds: [roomId],
|
||||
deviceTrackingStatus: TRACKING_STATUS_OUTDATED,
|
||||
};
|
||||
return identity;
|
||||
} else {
|
||||
if (!identity.roomIds.includes(roomId)) {
|
||||
identity.roomIds.push(roomId);
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// map 1 device from /keys/query response to DeviceIdentity
|
||||
function deviceKeysAsDeviceIdentity(deviceSection) {
|
||||
const deviceId = deviceSection["device_id"];
|
||||
const userId = deviceSection["user_id"];
|
||||
return {
|
||||
userId,
|
||||
deviceId,
|
||||
ed25519Key: deviceSection.keys[`ed25519:${deviceId}`],
|
||||
curve25519Key: deviceSection.keys[`curve25519:${deviceId}`],
|
||||
algorithms: deviceSection.algorithms,
|
||||
displayName: deviceSection.unsigned?.device_display_name,
|
||||
};
|
||||
}
|
||||
|
||||
export class DeviceTracker {
|
||||
constructor({storage, getSyncToken, olmUtil, ownUserId, ownDeviceId}) {
|
||||
this._storage = storage;
|
||||
this._getSyncToken = getSyncToken;
|
||||
this._identityChangedForRoom = null;
|
||||
this._olmUtil = olmUtil;
|
||||
this._ownUserId = ownUserId;
|
||||
this._ownDeviceId = ownDeviceId;
|
||||
}
|
||||
|
||||
async writeDeviceChanges(changed, txn, log) {
|
||||
const {userIdentities} = txn;
|
||||
// TODO: should we also look at left here to handle this?:
|
||||
// the usual problem here is that you share a room with a user,
|
||||
// go offline, the remote user leaves the room, changes their devices,
|
||||
// then rejoins the room you share (or another room).
|
||||
// At which point you come online, all of this happens in the gap,
|
||||
// and you don't notice that they ever left,
|
||||
// and so the client doesn't invalidate their device cache for the user
|
||||
log.set("changed", changed.length);
|
||||
await Promise.all(changed.map(async userId => {
|
||||
const user = await userIdentities.get(userId);
|
||||
if (user) {
|
||||
log.log({l: "outdated", id: userId});
|
||||
user.deviceTrackingStatus = TRACKING_STATUS_OUTDATED;
|
||||
userIdentities.set(user);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
writeMemberChanges(room, memberChanges, txn) {
|
||||
return Promise.all(Array.from(memberChanges.values()).map(async memberChange => {
|
||||
return this._applyMemberChange(memberChange, txn);
|
||||
}));
|
||||
}
|
||||
|
||||
async trackRoom(room, log) {
|
||||
if (room.isTrackingMembers || !room.isEncrypted) {
|
||||
return;
|
||||
}
|
||||
const memberList = await room.loadMemberList(log);
|
||||
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);
|
||||
} catch (err) {
|
||||
txn.abort();
|
||||
throw err;
|
||||
}
|
||||
await txn.complete();
|
||||
room.applyIsTrackingMembersChanges(isTrackingChanges);
|
||||
} finally {
|
||||
memberList.release();
|
||||
}
|
||||
}
|
||||
|
||||
async _writeJoinedMembers(members, txn) {
|
||||
await Promise.all(members.map(async member => {
|
||||
if (member.membership === "join") {
|
||||
await this._writeMember(member, txn);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async _writeMember(member, txn) {
|
||||
const {userIdentities} = txn;
|
||||
const identity = await userIdentities.get(member.userId);
|
||||
const updatedIdentity = addRoomToIdentity(identity, member.userId, member.roomId);
|
||||
if (updatedIdentity) {
|
||||
userIdentities.set(updatedIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
async _removeRoomFromUserIdentity(roomId, userId, txn) {
|
||||
const {userIdentities, deviceIdentities} = txn;
|
||||
const identity = await userIdentities.get(userId);
|
||||
if (identity) {
|
||||
identity.roomIds = identity.roomIds.filter(id => id !== roomId);
|
||||
// no more encrypted rooms with this user, remove
|
||||
if (identity.roomIds.length === 0) {
|
||||
userIdentities.remove(userId);
|
||||
deviceIdentities.removeAllForUser(userId);
|
||||
} else {
|
||||
userIdentities.set(identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _queryKeys(userIds, hsApi, log) {
|
||||
// TODO: we need to handle the race here between /sync and /keys/query just like we need to do for the member list ...
|
||||
// there are multiple requests going out for /keys/query though and only one for /members
|
||||
|
||||
const deviceKeyResponse = await hsApi.queryKeys({
|
||||
"timeout": 10000,
|
||||
"device_keys": userIds.reduce((deviceKeysMap, userId) => {
|
||||
deviceKeysMap[userId] = [];
|
||||
return deviceKeysMap;
|
||||
}, {}),
|
||||
"token": this._getSyncToken()
|
||||
}, {log}).response();
|
||||
|
||||
const verifiedKeysPerUser = log.wrap("verify", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log));
|
||||
const txn = await this._storage.readWriteTxn([
|
||||
this._storage.storeNames.userIdentities,
|
||||
this._storage.storeNames.deviceIdentities,
|
||||
]);
|
||||
let deviceIdentities;
|
||||
try {
|
||||
const devicesIdentitiesPerUser = await Promise.all(verifiedKeysPerUser.map(async ({userId, verifiedKeys}) => {
|
||||
const deviceIdentities = verifiedKeys.map(deviceKeysAsDeviceIdentity);
|
||||
return await this._storeQueriedDevicesForUserId(userId, deviceIdentities, txn);
|
||||
}));
|
||||
deviceIdentities = devicesIdentitiesPerUser.reduce((all, devices) => all.concat(devices), []);
|
||||
log.set("devices", deviceIdentities.length);
|
||||
} catch (err) {
|
||||
txn.abort();
|
||||
throw err;
|
||||
}
|
||||
await txn.complete();
|
||||
return deviceIdentities;
|
||||
}
|
||||
|
||||
async _storeQueriedDevicesForUserId(userId, deviceIdentities, txn) {
|
||||
const knownDeviceIds = await txn.deviceIdentities.getAllDeviceIds(userId);
|
||||
// delete any devices that we know off but are not in the response anymore.
|
||||
// important this happens before checking if the ed25519 key changed,
|
||||
// otherwise we would end up deleting existing devices with changed keys.
|
||||
for (const deviceId of knownDeviceIds) {
|
||||
if (deviceIdentities.every(di => di.deviceId !== deviceId)) {
|
||||
txn.deviceIdentities.remove(userId, deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
// all the device identities as we will have them in storage
|
||||
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 => {
|
||||
if (knownDeviceIds.includes(deviceIdentity.deviceId)) {
|
||||
const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId);
|
||||
if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) {
|
||||
allDeviceIdentities.push(existingDevice);
|
||||
}
|
||||
}
|
||||
allDeviceIdentities.push(deviceIdentity);
|
||||
deviceIdentitiesToStore.push(deviceIdentity);
|
||||
}));
|
||||
// store devices
|
||||
for (const deviceIdentity of deviceIdentitiesToStore) {
|
||||
txn.deviceIdentities.set(deviceIdentity);
|
||||
}
|
||||
// mark user identities as up to date
|
||||
const identity = await txn.userIdentities.get(userId);
|
||||
identity.deviceTrackingStatus = TRACKING_STATUS_UPTODATE;
|
||||
txn.userIdentities.set(identity);
|
||||
|
||||
return allDeviceIdentities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Array<{userId, verifiedKeys: Array<DeviceSection>>}
|
||||
*/
|
||||
_filterVerifiedDeviceKeys(keyQueryDeviceKeysResponse, parentLog) {
|
||||
const curve25519Keys = new Set();
|
||||
const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => {
|
||||
const verifiedEntries = Object.entries(keysByDevice).filter(([deviceId, deviceKeys]) => {
|
||||
const deviceIdOnKeys = deviceKeys["device_id"];
|
||||
const userIdOnKeys = deviceKeys["user_id"];
|
||||
if (userIdOnKeys !== userId) {
|
||||
return false;
|
||||
}
|
||||
if (deviceIdOnKeys !== deviceId) {
|
||||
return false;
|
||||
}
|
||||
const ed25519Key = deviceKeys.keys?.[`ed25519:${deviceId}`];
|
||||
const curve25519Key = deviceKeys.keys?.[`curve25519:${deviceId}`];
|
||||
if (typeof ed25519Key !== "string" || typeof curve25519Key !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (curve25519Keys.has(curve25519Key)) {
|
||||
parentLog.log({
|
||||
l: "ignore device with duplicate curve25519 key",
|
||||
keys: deviceKeys
|
||||
}, parentLog.level.Warn);
|
||||
return false;
|
||||
}
|
||||
curve25519Keys.add(curve25519Key);
|
||||
const isValid = this._hasValidSignature(deviceKeys);
|
||||
if (!isValid) {
|
||||
parentLog.log({
|
||||
l: "ignore device with invalid signature",
|
||||
keys: deviceKeys
|
||||
}, parentLog.level.Warn);
|
||||
}
|
||||
return isValid;
|
||||
});
|
||||
const verifiedKeys = verifiedEntries.map(([, deviceKeys]) => deviceKeys);
|
||||
return {userId, verifiedKeys};
|
||||
});
|
||||
return verifiedKeys;
|
||||
}
|
||||
|
||||
_hasValidSignature(deviceSection) {
|
||||
const deviceId = deviceSection["device_id"];
|
||||
const userId = deviceSection["user_id"];
|
||||
const ed25519Key = deviceSection?.keys?.[`${SIGNATURE_ALGORITHM}:${deviceId}`];
|
||||
return verifyEd25519Signature(this._olmUtil, userId, deviceId, ed25519Key, deviceSection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives all the device identities for a room that is already tracked.
|
||||
* Assumes room is already tracked. Call `trackRoom` first if unsure.
|
||||
* @param {String} roomId [description]
|
||||
* @return {[type]} [description]
|
||||
*/
|
||||
async devicesForTrackedRoom(roomId, hsApi, log) {
|
||||
const txn = await this._storage.readTxn([
|
||||
this._storage.storeNames.roomMembers,
|
||||
this._storage.storeNames.userIdentities,
|
||||
]);
|
||||
|
||||
// because we don't have multiEntry support in IE11, we get a set of userIds that is pretty close to what we
|
||||
// need as a good first filter (given that non-join memberships will be in there). After fetching the identities,
|
||||
// we check which ones have the roomId for the room we're looking at.
|
||||
|
||||
// So, this will also contain non-joined memberships
|
||||
const userIds = await txn.roomMembers.getAllUserIds(roomId);
|
||||
|
||||
return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log);
|
||||
}
|
||||
|
||||
async devicesForRoomMembers(roomId, userIds, hsApi, log) {
|
||||
const txn = await this._storage.readTxn([
|
||||
this._storage.storeNames.userIdentities,
|
||||
]);
|
||||
return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomId [description]
|
||||
* @param {Array<string>} userIds a set of user ids to try and find the identity for. Will be check to belong to roomId.
|
||||
* @param {Transaction} userIdentityTxn to read the user identities
|
||||
* @param {HomeServerApi} hsApi
|
||||
* @return {Array<DeviceIdentity>}
|
||||
*/
|
||||
async _devicesForUserIds(roomId, userIds, userIdentityTxn, hsApi, log) {
|
||||
const allMemberIdentities = await Promise.all(userIds.map(userId => userIdentityTxn.userIdentities.get(userId)));
|
||||
const identities = allMemberIdentities.filter(identity => {
|
||||
// identity will be missing for any userIds that don't have
|
||||
// membership join in any of your encrypted rooms
|
||||
return identity && identity.roomIds.includes(roomId);
|
||||
});
|
||||
const upToDateIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE);
|
||||
const outdatedIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED);
|
||||
log.set("uptodate", upToDateIdentities.length);
|
||||
log.set("outdated", outdatedIdentities.length);
|
||||
let queriedDevices;
|
||||
if (outdatedIdentities.length) {
|
||||
// TODO: ignore the race between /sync and /keys/query for now,
|
||||
// where users could get marked as outdated or added/removed from the room while
|
||||
// querying keys
|
||||
queriedDevices = await this._queryKeys(outdatedIdentities.map(i => i.userId), hsApi, log);
|
||||
}
|
||||
|
||||
const deviceTxn = await this._storage.readTxn([
|
||||
this._storage.storeNames.deviceIdentities,
|
||||
]);
|
||||
const devicesPerUser = await Promise.all(upToDateIdentities.map(identity => {
|
||||
return deviceTxn.deviceIdentities.getAllForUserId(identity.userId);
|
||||
}));
|
||||
let flattenedDevices = devicesPerUser.reduce((all, devicesForUser) => all.concat(devicesForUser), []);
|
||||
if (queriedDevices && queriedDevices.length) {
|
||||
flattenedDevices = flattenedDevices.concat(queriedDevices);
|
||||
}
|
||||
// filter out our own device
|
||||
const devices = flattenedDevices.filter(device => {
|
||||
const isOwnDevice = device.userId === this._ownUserId && device.deviceId === this._ownDeviceId;
|
||||
return !isOwnDevice;
|
||||
});
|
||||
return devices;
|
||||
}
|
||||
|
||||
async getDeviceByCurve25519Key(curve25519Key, txn) {
|
||||
return await txn.deviceIdentities.getByCurve25519Key(curve25519Key);
|
||||
}
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export class SessionBackup {
|
||||
constructor({backupInfo, decryption, hsApi}) {
|
||||
this._backupInfo = backupInfo;
|
||||
this._decryption = decryption;
|
||||
this._hsApi = hsApi;
|
||||
}
|
||||
|
||||
async getSession(roomId, sessionId, log) {
|
||||
const sessionResponse = await this._hsApi.roomKeyForRoomAndSession(this._backupInfo.version, roomId, sessionId, {log}).response();
|
||||
const sessionInfo = this._decryption.decrypt(
|
||||
sessionResponse.session_data.ephemeral,
|
||||
sessionResponse.session_data.mac,
|
||||
sessionResponse.session_data.ciphertext,
|
||||
);
|
||||
return JSON.parse(sessionInfo);
|
||||
}
|
||||
|
||||
get version() {
|
||||
return this._backupInfo.version;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._decryption.free();
|
||||
}
|
||||
|
||||
static async fromSecretStorage({platform, olm, secretStorage, hsApi, txn}) {
|
||||
const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1", txn);
|
||||
if (base64PrivateKey) {
|
||||
const privateKey = new Uint8Array(platform.encoding.base64.decode(base64PrivateKey));
|
||||
const backupInfo = await hsApi.roomKeysVersion().response();
|
||||
const expectedPubKey = backupInfo.auth_data.public_key;
|
||||
const decryption = new olm.PkDecryption();
|
||||
try {
|
||||
const pubKey = decryption.init_with_private_key(privateKey);
|
||||
if (pubKey !== expectedPubKey) {
|
||||
throw new Error(`Bad backup key, public key does not match. Calculated ${pubKey} but expected ${expectedPubKey}`);
|
||||
}
|
||||
} catch(err) {
|
||||
decryption.free();
|
||||
throw err;
|
||||
}
|
||||
return new SessionBackup({backupInfo, decryption, hsApi});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
import {SessionInfo} from "./SessionInfo.js";
|
||||
|
||||
export class BaseRoomKey {
|
||||
constructor() {
|
||||
this._sessionInfo = null;
|
||||
this._isBetter = null;
|
||||
this._eventIds = null;
|
||||
}
|
||||
|
||||
async createSessionInfo(olm, pickleKey, txn) {
|
||||
if (this._isBetter === false) {
|
||||
return;
|
||||
}
|
||||
const session = new olm.InboundGroupSession();
|
||||
try {
|
||||
this._loadSessionKey(session);
|
||||
this._isBetter = await this._isBetterThanKnown(session, olm, pickleKey, txn);
|
||||
if (this._isBetter) {
|
||||
const claimedKeys = {ed25519: this.claimedEd25519Key};
|
||||
this._sessionInfo = new SessionInfo(this.roomId, this.senderKey, session, claimedKeys);
|
||||
// retain the session so we don't have to create a new session during write.
|
||||
this._sessionInfo.retain();
|
||||
return this._sessionInfo;
|
||||
} else {
|
||||
session.free();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
this._sessionInfo = null;
|
||||
session.free();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async _isBetterThanKnown(session, olm, pickleKey, txn) {
|
||||
let isBetter = true;
|
||||
// TODO: we could potentially have a small speedup here if we looked first in the SessionCache here...
|
||||
const existingSessionEntry = await txn.inboundGroupSessions.get(this.roomId, this.senderKey, this.sessionId);
|
||||
if (existingSessionEntry?.session) {
|
||||
const existingSession = new olm.InboundGroupSession();
|
||||
try {
|
||||
existingSession.unpickle(pickleKey, existingSessionEntry.session);
|
||||
isBetter = session.first_known_index() < existingSession.first_known_index();
|
||||
} finally {
|
||||
existingSession.free();
|
||||
}
|
||||
}
|
||||
// store the event ids that can be decrypted with this key
|
||||
// before we overwrite them if called from `write`.
|
||||
if (existingSessionEntry?.eventIds) {
|
||||
this._eventIds = existingSessionEntry.eventIds;
|
||||
}
|
||||
return isBetter;
|
||||
}
|
||||
|
||||
async write(olm, pickleKey, txn) {
|
||||
// we checked already and we had a better session in storage, so don't write
|
||||
if (this._isBetter === false) {
|
||||
return false;
|
||||
}
|
||||
if (!this._sessionInfo) {
|
||||
await this.createSessionInfo(olm, pickleKey, txn);
|
||||
}
|
||||
if (this._sessionInfo) {
|
||||
const session = this._sessionInfo.session;
|
||||
const sessionEntry = {
|
||||
roomId: this.roomId,
|
||||
senderKey: this.senderKey,
|
||||
sessionId: this.sessionId,
|
||||
session: session.pickle(pickleKey),
|
||||
claimedKeys: this._sessionInfo.claimedKeys,
|
||||
};
|
||||
txn.inboundGroupSessions.set(sessionEntry);
|
||||
this.dispose();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get eventIds() {
|
||||
return this._eventIds;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._sessionInfo) {
|
||||
this._sessionInfo.release();
|
||||
this._sessionInfo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceMessageRoomKey extends BaseRoomKey {
|
||||
constructor(decryptionResult) {
|
||||
super();
|
||||
this._decryptionResult = decryptionResult;
|
||||
}
|
||||
|
||||
get roomId() { return this._decryptionResult.event.content?.["room_id"]; }
|
||||
get senderKey() { return this._decryptionResult.senderCurve25519Key; }
|
||||
get sessionId() { return this._decryptionResult.event.content?.["session_id"]; }
|
||||
get claimedEd25519Key() { return this._decryptionResult.claimedEd25519Key; }
|
||||
|
||||
_loadSessionKey(session) {
|
||||
const sessionKey = this._decryptionResult.event.content?.["session_key"];
|
||||
session.create(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
class BackupRoomKey extends BaseRoomKey {
|
||||
constructor(roomId, sessionId, backupInfo) {
|
||||
super();
|
||||
this._roomId = roomId;
|
||||
this._sessionId = sessionId;
|
||||
this._backupInfo = backupInfo;
|
||||
}
|
||||
|
||||
get roomId() { return this._roomId; }
|
||||
get senderKey() { return this._backupInfo["sender_key"]; }
|
||||
get sessionId() { return this._sessionId; }
|
||||
get claimedEd25519Key() { return this._backupInfo["sender_claimed_keys"]?.["ed25519"]; }
|
||||
|
||||
_loadSessionKey(session) {
|
||||
const sessionKey = this._backupInfo["session_key"];
|
||||
session.import_session(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function fromDeviceMessage(dr) {
|
||||
const roomId = dr.event.content?.["room_id"];
|
||||
const sessionId = dr.event.content?.["session_id"];
|
||||
const sessionKey = dr.event.content?.["session_key"];
|
||||
if (
|
||||
typeof roomId === "string" ||
|
||||
typeof sessionId === "string" ||
|
||||
typeof senderKey === "string" ||
|
||||
typeof sessionKey === "string"
|
||||
) {
|
||||
return new DeviceMessageRoomKey(dr);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
sessionInfo is a response from key backup and has the following keys:
|
||||
algorithm
|
||||
forwarding_curve25519_key_chain
|
||||
sender_claimed_keys
|
||||
sender_key
|
||||
session_key
|
||||
*/
|
||||
export function fromBackup(roomId, sessionId, sessionInfo) {
|
||||
const sessionKey = sessionInfo["session_key"];
|
||||
const senderKey = sessionInfo["sender_key"];
|
||||
// TODO: can we just trust this?
|
||||
const claimedEd25519Key = sessionInfo["sender_claimed_keys"]?.["ed25519"];
|
||||
|
||||
if (
|
||||
typeof roomId === "string" &&
|
||||
typeof sessionId === "string" &&
|
||||
typeof senderKey === "string" &&
|
||||
typeof sessionKey === "string" &&
|
||||
typeof claimedEd25519Key === "string"
|
||||
) {
|
||||
return new BackupRoomKey(roomId, sessionId, sessionInfo);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {BaseLRUCache} from "../../../../utils/LRUCache.js";
|
||||
const DEFAULT_CACHE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Cache of unpickled inbound megolm session.
|
||||
*/
|
||||
export class SessionCache extends BaseLRUCache {
|
||||
constructor(limit) {
|
||||
limit = typeof limit === "number" ? limit : DEFAULT_CACHE_SIZE;
|
||||
super(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomId
|
||||
* @param {string} senderKey
|
||||
* @param {string} sessionId
|
||||
* @return {SessionInfo?}
|
||||
*/
|
||||
get(roomId, senderKey, sessionId) {
|
||||
return this._get(s => {
|
||||
return s.roomId === roomId &&
|
||||
s.senderKey === senderKey &&
|
||||
sessionId === s.sessionId;
|
||||
});
|
||||
}
|
||||
|
||||
add(sessionInfo) {
|
||||
sessionInfo.retain();
|
||||
this._set(sessionInfo, s => {
|
||||
return s.roomId === sessionInfo.roomId &&
|
||||
s.senderKey === sessionInfo.senderKey &&
|
||||
s.sessionId === sessionInfo.sessionId;
|
||||
});
|
||||
}
|
||||
|
||||
_onEvictEntry(sessionInfo) {
|
||||
sessionInfo.release();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const sessionInfo of this._entries) {
|
||||
sessionInfo.release();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,90 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {DecryptionResult} from "../../DecryptionResult.js";
|
||||
import {DecryptionError} from "../../common.js";
|
||||
import {ReplayDetectionEntry} from "./ReplayDetectionEntry.js";
|
||||
|
||||
/**
|
||||
* Does the actual decryption of all events for a given megolm session in a batch
|
||||
*/
|
||||
export class SessionDecryption {
|
||||
constructor(sessionInfo, events, olmWorker) {
|
||||
sessionInfo.retain();
|
||||
this._sessionInfo = sessionInfo;
|
||||
this._events = events;
|
||||
this._olmWorker = olmWorker;
|
||||
this._decryptionRequests = olmWorker ? [] : null;
|
||||
}
|
||||
|
||||
async decryptAll() {
|
||||
const replayEntries = [];
|
||||
const results = new Map();
|
||||
let errors;
|
||||
const roomId = this._sessionInfo.roomId;
|
||||
|
||||
await Promise.all(this._events.map(async event => {
|
||||
try {
|
||||
const {session} = this._sessionInfo;
|
||||
const ciphertext = event.content.ciphertext;
|
||||
let decryptionResult;
|
||||
if (this._olmWorker) {
|
||||
const request = this._olmWorker.megolmDecrypt(session, ciphertext);
|
||||
this._decryptionRequests.push(request);
|
||||
decryptionResult = await request.response();
|
||||
} else {
|
||||
decryptionResult = session.decrypt(ciphertext);
|
||||
}
|
||||
const plaintext = decryptionResult.plaintext;
|
||||
const messageIndex = decryptionResult.message_index;
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(plaintext);
|
||||
} catch (err) {
|
||||
throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, err});
|
||||
}
|
||||
if (payload.room_id !== roomId) {
|
||||
throw new DecryptionError("MEGOLM_WRONG_ROOM", event,
|
||||
{encryptedRoomId: payload.room_id, eventRoomId: roomId});
|
||||
}
|
||||
replayEntries.push(new ReplayDetectionEntry(session.session_id(), messageIndex, event));
|
||||
const result = new DecryptionResult(payload, this._sessionInfo.senderKey, this._sessionInfo.claimedKeys);
|
||||
results.set(event.event_id, result);
|
||||
} catch (err) {
|
||||
// ignore AbortError from cancelling decryption requests in dispose method
|
||||
if (err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
if (!errors) {
|
||||
errors = new Map();
|
||||
}
|
||||
errors.set(event.event_id, err);
|
||||
}
|
||||
}));
|
||||
|
||||
return {results, errors, replayEntries};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._decryptionRequests) {
|
||||
for (const r of this._decryptionRequests) {
|
||||
r.abort();
|
||||
}
|
||||
}
|
||||
// TODO: cancel decryptions here
|
||||
this._sessionInfo.release();
|
||||
}
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* session loaded in memory with everything needed to create DecryptionResults
|
||||
* and to store/retrieve it in the SessionCache
|
||||
*/
|
||||
export class SessionInfo {
|
||||
constructor(roomId, senderKey, session, claimedKeys) {
|
||||
this.roomId = roomId;
|
||||
this.senderKey = senderKey;
|
||||
this.session = session;
|
||||
this.claimedKeys = claimedKeys;
|
||||
this._refCounter = 0;
|
||||
}
|
||||
|
||||
get sessionId() {
|
||||
return this.session?.session_id();
|
||||
}
|
||||
|
||||
retain() {
|
||||
this._refCounter += 1;
|
||||
}
|
||||
|
||||
release() {
|
||||
this._refCounter -= 1;
|
||||
if (this._refCounter <= 0) {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.session.free();
|
||||
this.session = null;
|
||||
}
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export {SessionContainer} from "./SessionContainer";
|
||||
export {StorageFactory as IDBStorageFactory} from "./storage/idb/StorageFactory";
|
||||
export {SessionInfoStorage} from "./sessioninfo/localstorage/SessionInfoStorage.js";
|
||||
export {OlmWorker} from "./e2ee/OlmWorker.js";
|
|
@ -1,243 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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 {encodeQueryParams, encodeBody} from "./common.js";
|
||||
import {HomeServerRequest} from "./HomeServerRequest.js";
|
||||
|
||||
export class HomeServerApi {
|
||||
constructor({homeserver, accessToken, request, reconnector}) {
|
||||
// store these both in a closure somehow so it's harder to get at in case of XSS?
|
||||
// one could change the homeserver as well so the token gets sent there, so both must be protected from read/write
|
||||
this._homeserver = homeserver;
|
||||
this._accessToken = accessToken;
|
||||
this._requestFn = request;
|
||||
this._reconnector = reconnector;
|
||||
}
|
||||
|
||||
_url(csPath) {
|
||||
return `${this._homeserver}/_matrix/client/r0${csPath}`;
|
||||
}
|
||||
|
||||
_baseRequest(method, url, queryParams, body, options, accessToken) {
|
||||
const queryString = encodeQueryParams(queryParams);
|
||||
url = `${url}?${queryString}`;
|
||||
let log;
|
||||
if (options?.log) {
|
||||
const parent = options?.log;
|
||||
log = parent.child({
|
||||
t: "network",
|
||||
url,
|
||||
method,
|
||||
}, parent.level.Info);
|
||||
}
|
||||
let encodedBody;
|
||||
const headers = new Map();
|
||||
if (accessToken) {
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
}
|
||||
headers.set("Accept", "application/json");
|
||||
if (body) {
|
||||
const encoded = encodeBody(body);
|
||||
headers.set("Content-Type", encoded.mimeType);
|
||||
headers.set("Content-Length", encoded.length);
|
||||
encodedBody = encoded.body;
|
||||
}
|
||||
|
||||
const requestResult = this._requestFn(url, {
|
||||
method,
|
||||
headers,
|
||||
body: encodedBody,
|
||||
timeout: options?.timeout,
|
||||
uploadProgress: options?.uploadProgress,
|
||||
format: "json" // response format
|
||||
});
|
||||
|
||||
const hsRequest = new HomeServerRequest(method, url, requestResult, log);
|
||||
|
||||
if (this._reconnector) {
|
||||
hsRequest.response().catch(err => {
|
||||
// Some endpoints such as /sync legitimately time-out
|
||||
// (which is also reported as a ConnectionError) and will re-attempt,
|
||||
// but spinning up the reconnector in this case is ok,
|
||||
// as all code ran on session and sync start should be reentrant
|
||||
if (err.name === "ConnectionError") {
|
||||
this._reconnector.onRequestFailed(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return hsRequest;
|
||||
}
|
||||
|
||||
_unauthedRequest(method, url, queryParams, body, options) {
|
||||
return this._baseRequest(method, url, queryParams, body, options, null);
|
||||
}
|
||||
|
||||
_authedRequest(method, url, queryParams, body, options) {
|
||||
return this._baseRequest(method, url, queryParams, body, options, this._accessToken);
|
||||
}
|
||||
|
||||
_post(csPath, queryParams, body, options) {
|
||||
return this._authedRequest("POST", this._url(csPath), queryParams, body, options);
|
||||
}
|
||||
|
||||
_put(csPath, queryParams, body, options) {
|
||||
return this._authedRequest("PUT", this._url(csPath), queryParams, body, options);
|
||||
}
|
||||
|
||||
_get(csPath, queryParams, body, options) {
|
||||
return this._authedRequest("GET", this._url(csPath), queryParams, body, options);
|
||||
}
|
||||
|
||||
sync(since, filter, timeout, options = null) {
|
||||
return this._get("/sync", {since, timeout, filter}, null, options);
|
||||
}
|
||||
|
||||
// params is from, dir and optionally to, limit, filter.
|
||||
messages(roomId, params, options = null) {
|
||||
return this._get(`/rooms/${encodeURIComponent(roomId)}/messages`, params, null, options);
|
||||
}
|
||||
|
||||
// params is at, membership and not_membership
|
||||
members(roomId, params, options = null) {
|
||||
return this._get(`/rooms/${encodeURIComponent(roomId)}/members`, params, null, options);
|
||||
}
|
||||
|
||||
send(roomId, eventType, txnId, content, options = null) {
|
||||
return this._put(`/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`, {}, content, options);
|
||||
}
|
||||
|
||||
redact(roomId, eventId, txnId, content, options = null) {
|
||||
return this._put(`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${encodeURIComponent(txnId)}`, {}, content, options);
|
||||
}
|
||||
|
||||
receipt(roomId, receiptType, eventId, options = null) {
|
||||
return this._post(`/rooms/${encodeURIComponent(roomId)}/receipt/${encodeURIComponent(receiptType)}/${encodeURIComponent(eventId)}`,
|
||||
{}, {}, options);
|
||||
}
|
||||
|
||||
state(roomId, eventType, stateKey, options = null) {
|
||||
return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, null, options);
|
||||
}
|
||||
|
||||
getLoginFlows() {
|
||||
return this._unauthedRequest("GET", this._url("/login"), null, null, null);
|
||||
}
|
||||
|
||||
passwordLogin(username, password, initialDeviceDisplayName, options = null) {
|
||||
return this._unauthedRequest("POST", this._url("/login"), null, {
|
||||
"type": "m.login.password",
|
||||
"identifier": {
|
||||
"type": "m.id.user",
|
||||
"user": username
|
||||
},
|
||||
"password": password,
|
||||
"initial_device_display_name": initialDeviceDisplayName
|
||||
}, options);
|
||||
}
|
||||
|
||||
tokenLogin(loginToken, txnId, initialDeviceDisplayName, options = null) {
|
||||
return this._unauthedRequest("POST", this._url("/login"), null, {
|
||||
"type": "m.login.token",
|
||||
"identifier": {
|
||||
"type": "m.id.user",
|
||||
},
|
||||
"token": loginToken,
|
||||
"txn_id": txnId,
|
||||
"initial_device_display_name": initialDeviceDisplayName
|
||||
}, options);
|
||||
}
|
||||
|
||||
createFilter(userId, filter, options = null) {
|
||||
return this._post(`/user/${encodeURIComponent(userId)}/filter`, null, filter, options);
|
||||
}
|
||||
|
||||
versions(options = null) {
|
||||
return this._unauthedRequest("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options);
|
||||
}
|
||||
|
||||
uploadKeys(payload, options = null) {
|
||||
return this._post("/keys/upload", null, payload, options);
|
||||
}
|
||||
|
||||
queryKeys(queryRequest, options = null) {
|
||||
return this._post("/keys/query", null, queryRequest, options);
|
||||
}
|
||||
|
||||
claimKeys(payload, options = null) {
|
||||
return this._post("/keys/claim", null, payload, options);
|
||||
}
|
||||
|
||||
sendToDevice(type, payload, txnId, options = null) {
|
||||
return this._put(`/sendToDevice/${encodeURIComponent(type)}/${encodeURIComponent(txnId)}`, null, payload, options);
|
||||
}
|
||||
|
||||
roomKeysVersion(version = null, options = null) {
|
||||
let versionPart = "";
|
||||
if (version) {
|
||||
versionPart = `/${encodeURIComponent(version)}`;
|
||||
}
|
||||
return this._get(`/room_keys/version${versionPart}`, null, null, options);
|
||||
}
|
||||
|
||||
roomKeyForRoomAndSession(version, roomId, sessionId, options = null) {
|
||||
return this._get(`/room_keys/keys/${encodeURIComponent(roomId)}/${encodeURIComponent(sessionId)}`, {version}, null, options);
|
||||
}
|
||||
|
||||
uploadAttachment(blob, filename, options = null) {
|
||||
return this._authedRequest("POST", `${this._homeserver}/_matrix/media/r0/upload`, {filename}, blob, options);
|
||||
}
|
||||
|
||||
setPusher(pusher, options = null) {
|
||||
return this._post("/pushers/set", null, pusher, options);
|
||||
}
|
||||
|
||||
getPushers(options = null) {
|
||||
return this._get("/pushers", null, null, options);
|
||||
}
|
||||
|
||||
join(roomId, options = null) {
|
||||
return this._post(`/rooms/${encodeURIComponent(roomId)}/join`, null, null, options);
|
||||
}
|
||||
|
||||
joinIdOrAlias(roomIdOrAlias, options = null) {
|
||||
return this._post(`/join/${encodeURIComponent(roomIdOrAlias)}`, null, null, options);
|
||||
}
|
||||
|
||||
leave(roomId, options = null) {
|
||||
return this._post(`/rooms/${encodeURIComponent(roomId)}/leave`, null, null, options);
|
||||
}
|
||||
|
||||
forget(roomId, options = null) {
|
||||
return this._post(`/rooms/${encodeURIComponent(roomId)}/forget`, null, null, options);
|
||||
}
|
||||
}
|
||||
|
||||
import {Request as MockRequest} from "../../mocks/Request.js";
|
||||
|
||||
export function tests() {
|
||||
return {
|
||||
"superficial happy path for GET": async assert => {
|
||||
const hsApi = new HomeServerApi({
|
||||
request: () => new MockRequest().respond(200, 42),
|
||||
homeserver: "https://hs.tld"
|
||||
});
|
||||
const result = await hsApi._get("foo", null, null, null).response();
|
||||
assert.strictEqual(result, 42);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,130 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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 {AbortError} from "../../utils/error.js";
|
||||
import {HomeServerError} from "../error.js";
|
||||
import {HomeServerApi} from "./HomeServerApi.js";
|
||||
import {ExponentialRetryDelay} from "./ExponentialRetryDelay.js";
|
||||
|
||||
class Request {
|
||||
constructor(methodName, args) {
|
||||
this._methodName = methodName;
|
||||
this._args = args;
|
||||
this._responsePromise = new Promise((resolve, reject) => {
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
});
|
||||
this._requestResult = null;
|
||||
}
|
||||
|
||||
abort() {
|
||||
if (this._requestResult) {
|
||||
this._requestResult.abort();
|
||||
} else {
|
||||
this._reject(new AbortError());
|
||||
}
|
||||
}
|
||||
|
||||
response() {
|
||||
return this._responsePromise;
|
||||
}
|
||||
}
|
||||
|
||||
class HomeServerApiWrapper {
|
||||
constructor(scheduler) {
|
||||
this._scheduler = scheduler;
|
||||
}
|
||||
}
|
||||
|
||||
// add request-wrapping methods to prototype
|
||||
for (const methodName of Object.getOwnPropertyNames(HomeServerApi.prototype)) {
|
||||
if (methodName !== "constructor" && !methodName.startsWith("_")) {
|
||||
HomeServerApiWrapper.prototype[methodName] = function(...args) {
|
||||
return this._scheduler._hsApiRequest(methodName, args);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestScheduler {
|
||||
constructor({hsApi, clock}) {
|
||||
this._hsApi = hsApi;
|
||||
this._clock = clock;
|
||||
this._requests = new Set();
|
||||
this._isRateLimited = false;
|
||||
this._isDrainingRateLimit = false;
|
||||
this._stopped = true;
|
||||
this._wrapper = new HomeServerApiWrapper(this);
|
||||
}
|
||||
|
||||
get hsApi() {
|
||||
return this._wrapper;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._stopped = true;
|
||||
for (const request of this._requests) {
|
||||
request.abort();
|
||||
}
|
||||
this._requests.clear();
|
||||
}
|
||||
|
||||
start() {
|
||||
this._stopped = false;
|
||||
}
|
||||
|
||||
_hsApiRequest(name, args) {
|
||||
const request = new Request(name, args);
|
||||
this._doSend(request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async _doSend(request) {
|
||||
this._requests.add(request);
|
||||
try {
|
||||
let retryDelay;
|
||||
while (!this._stopped) {
|
||||
try {
|
||||
const requestResult = this._hsApi[request._methodName].apply(this._hsApi, request._args);
|
||||
// so the request can be aborted
|
||||
request._requestResult = requestResult;
|
||||
const response = await requestResult.response();
|
||||
request._resolve(response);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err instanceof HomeServerError && err.errcode === "M_LIMIT_EXCEEDED") {
|
||||
if (Number.isSafeInteger(err.retry_after_ms)) {
|
||||
await this._clock.createTimeout(err.retry_after_ms).elapsed();
|
||||
} else {
|
||||
if (!retryDelay) {
|
||||
retryDelay = new ExponentialRetryDelay(this._clock.createTimeout);
|
||||
}
|
||||
await retryDelay.waitForRetry();
|
||||
}
|
||||
} else {
|
||||
request._reject(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._stopped) {
|
||||
request.abort();
|
||||
}
|
||||
} finally {
|
||||
this._requests.delete(request);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
export class Pusher {
|
||||
constructor(description) {
|
||||
this._description = description;
|
||||
}
|
||||
|
||||
static httpPusher(host, appId, pushkey, data) {
|
||||
return new Pusher({
|
||||
kind: "http",
|
||||
append: true, // as pushkeys are shared between multiple users on one origin
|
||||
data: Object.assign({}, data, {url: host + "/_matrix/push/v1/notify"}),
|
||||
pushkey,
|
||||
app_id: appId,
|
||||
app_display_name: "Hydrogen",
|
||||
device_display_name: "Hydrogen",
|
||||
lang: "en"
|
||||
});
|
||||
}
|
||||
|
||||
static createDefaultPayload(sessionId) {
|
||||
return {session_id: sessionId};
|
||||
}
|
||||
|
||||
async enable(hsApi, log) {
|
||||
try {
|
||||
log.set("endpoint", new URL(this._description.data.endpoint).host);
|
||||
} catch {
|
||||
log.set("endpoint", null);
|
||||
}
|
||||
await hsApi.setPusher(this._description, {log}).response();
|
||||
}
|
||||
|
||||
async disable(hsApi, log) {
|
||||
const deleteDescription = Object.assign({}, this._description, {kind: null});
|
||||
await hsApi.setPusher(deleteDescription, {log}).response();
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this._description;
|
||||
}
|
||||
|
||||
equals(pusher) {
|
||||
if (this._description.app_id !== pusher._description.app_id) {
|
||||
return false;
|
||||
}
|
||||
if (this._description.pushkey !== pusher._description.pushkey) {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(this._description.data) === JSON.stringify(pusher._description.data);
|
||||
}
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export class RoomStatus {
|
||||
constructor(joined, invited, archived) {
|
||||
this.joined = joined;
|
||||
this.invited = invited;
|
||||
this.archived = archived;
|
||||
}
|
||||
|
||||
withInvited() {
|
||||
if (this.invited) {
|
||||
return this;
|
||||
} else if (this.archived) {
|
||||
return RoomStatus.invitedAndArchived;
|
||||
} else {
|
||||
return RoomStatus.invited;
|
||||
}
|
||||
}
|
||||
|
||||
withoutInvited() {
|
||||
if (!this.invited) {
|
||||
return this;
|
||||
} else if (this.joined) {
|
||||
return RoomStatus.joined;
|
||||
} else if (this.archived) {
|
||||
return RoomStatus.archived;
|
||||
} else {
|
||||
return RoomStatus.none;
|
||||
}
|
||||
}
|
||||
|
||||
withoutArchived() {
|
||||
if (!this.archived) {
|
||||
return this;
|
||||
} else if (this.invited) {
|
||||
return RoomStatus.invited;
|
||||
} else {
|
||||
return RoomStatus.none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RoomStatus.joined = new RoomStatus(true, false, false);
|
||||
RoomStatus.archived = new RoomStatus(false, false, true);
|
||||
RoomStatus.invited = new RoomStatus(false, true, false);
|
||||
RoomStatus.invitedAndArchived = new RoomStatus(false, true, true);
|
||||
RoomStatus.none = new RoomStatus(false, false, false);
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export class KeyDescription {
|
||||
constructor(id, keyAccountData) {
|
||||
this._id = id;
|
||||
this._keyAccountData = keyAccountData;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get passphraseParams() {
|
||||
return this._keyAccountData?.content?.passphrase;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return this._keyAccountData?.content?.algorithm;
|
||||
}
|
||||
}
|
||||
|
||||
export class Key {
|
||||
constructor(keyDescription, binaryKey) {
|
||||
this._keyDescription = keyDescription;
|
||||
this._binaryKey = binaryKey;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this._keyDescription.id;
|
||||
}
|
||||
|
||||
get binaryKey() {
|
||||
return this._binaryKey;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return this._keyDescription.algorithm;
|
||||
}
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {KeyDescription, Key} from "./common.js";
|
||||
import {keyFromPassphrase} from "./passphrase.js";
|
||||
import {keyFromRecoveryKey} from "./recoveryKey.js";
|
||||
import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common.js";
|
||||
|
||||
const SSSS_KEY = `${SESSION_E2EE_KEY_PREFIX}ssssKey`;
|
||||
|
||||
async function readDefaultKeyDescription(storage) {
|
||||
const txn = await storage.readTxn([
|
||||
storage.storeNames.accountData
|
||||
]);
|
||||
const defaultKeyEvent = await txn.accountData.get("m.secret_storage.default_key");
|
||||
const id = defaultKeyEvent?.content?.key;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const keyAccountData = await txn.accountData.get(`m.secret_storage.key.${id}`);
|
||||
if (!keyAccountData) {
|
||||
return;
|
||||
}
|
||||
return new KeyDescription(id, keyAccountData);
|
||||
}
|
||||
|
||||
export async function writeKey(key, txn) {
|
||||
txn.session.set(SSSS_KEY, {id: key.id, binaryKey: key.binaryKey});
|
||||
}
|
||||
|
||||
export async function readKey(txn) {
|
||||
const keyData = await txn.session.get(SSSS_KEY);
|
||||
if (!keyData) {
|
||||
return;
|
||||
}
|
||||
const keyAccountData = await txn.accountData.get(`m.secret_storage.key.${keyData.id}`);
|
||||
return new Key(new KeyDescription(keyData.id, keyAccountData), keyData.binaryKey);
|
||||
}
|
||||
|
||||
export async function keyFromCredential(type, credential, storage, platform, olm) {
|
||||
const keyDescription = await readDefaultKeyDescription(storage);
|
||||
if (!keyDescription) {
|
||||
throw new Error("Could not find a default secret storage key in account data");
|
||||
}
|
||||
let key;
|
||||
if (type === "phrase") {
|
||||
key = await keyFromPassphrase(keyDescription, credential, platform);
|
||||
} else if (type === "key") {
|
||||
key = keyFromRecoveryKey(keyDescription, credential, olm, platform);
|
||||
} else {
|
||||
throw new Error(`Invalid type: ${type}`);
|
||||
}
|
||||
return key;
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MIN_UNICODE, MAX_UNICODE} from "./common";
|
||||
import {Store} from "../Store";
|
||||
|
||||
interface InboundGroupSession {
|
||||
roomId: string;
|
||||
senderKey: string;
|
||||
sessionId: string;
|
||||
session?: string;
|
||||
claimedKeys?: { [algorithm : string] : string };
|
||||
eventIds?: string[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
function encodeKey(roomId: string, senderKey: string, sessionId: string): string {
|
||||
return `${roomId}|${senderKey}|${sessionId}`;
|
||||
}
|
||||
|
||||
export class InboundGroupSessionStore {
|
||||
private _store: Store<InboundGroupSession>;
|
||||
|
||||
constructor(store: Store<InboundGroupSession>) {
|
||||
this._store = store;
|
||||
}
|
||||
|
||||
async has(roomId: string, senderKey: string, sessionId: string): Promise<boolean> {
|
||||
const key = encodeKey(roomId, senderKey, sessionId);
|
||||
const fetchedKey = await this._store.getKey(key);
|
||||
return key === fetchedKey;
|
||||
}
|
||||
|
||||
get(roomId: string, senderKey: string, sessionId: string): Promise<InboundGroupSession | null> {
|
||||
return this._store.get(encodeKey(roomId, senderKey, sessionId));
|
||||
}
|
||||
|
||||
set(session: InboundGroupSession): void {
|
||||
session.key = encodeKey(session.roomId, session.senderKey, session.sessionId);
|
||||
this._store.put(session);
|
||||
}
|
||||
|
||||
removeAllForRoom(roomId: string) {
|
||||
const range = this._store.IDBKeyRange.bound(
|
||||
encodeKey(roomId, MIN_UNICODE, MIN_UNICODE),
|
||||
encodeKey(roomId, MAX_UNICODE, MAX_UNICODE)
|
||||
);
|
||||
this._store.delete(range);
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
export default {
|
||||
build: {
|
||||
lib: {
|
||||
entry: "src/lib.ts",
|
||||
formats: ["es", "iife"],
|
||||
name: "hydrogenMatrix",
|
||||
}
|
||||
},
|
||||
public: false,
|
||||
server: {
|
||||
hmr: false
|
||||
}
|
||||
};
|
|
@ -1,37 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
<meta name="application-name" content="Hydrogen Chat"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="Hydrogen Chat">
|
||||
<meta name="description" content="A matrix chat application">
|
||||
<link rel="apple-touch-icon" href="assets/icon-maskable.png">
|
||||
<link rel="stylesheet" type="text/css" href="src/ui/css/main.css">
|
||||
<link rel="stylesheet" type="text/css" href="src/ui/css/themes/element/theme.css">
|
||||
</head>
|
||||
<body class="hydrogen">
|
||||
<script id="version" type="disabled">
|
||||
window.HYDROGEN_VERSION = "%%VERSION%%";
|
||||
window.HYDROGEN_GLOBAL_HASH = "%%GLOBAL_HASH%%";
|
||||
</script>
|
||||
<script id="main" type="module">
|
||||
import {main} from "./src/main.js";
|
||||
import {Platform} from "./src/Platform.js";
|
||||
import {olmPaths, downloadSandboxPath} from "./src/paths/vite";
|
||||
main(new Platform(document.body, {
|
||||
worker: "src/worker.js",
|
||||
downloadSandbox: downloadSandboxPath,
|
||||
defaultHomeServer: "matrix.org",
|
||||
// NOTE: uncomment this if you want the service worker for local development
|
||||
// serviceWorker: "sw.js",
|
||||
// NOTE: provide push config if you want push notifs for local development
|
||||
// see assets/config.json for what the config looks like
|
||||
// push: {...},
|
||||
olm: olmPaths
|
||||
}, null, {development: true}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,72 +0,0 @@
|
|||
{
|
||||
"name": "hydrogen-web",
|
||||
"version": "0.2.16",
|
||||
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
|
||||
"main": "src/lib.ts",
|
||||
"directories": {
|
||||
"doc": "doc"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint --cache src/",
|
||||
"lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts",
|
||||
"lint-ci": "eslint src/",
|
||||
"test": "impunity --entry-point src/main.js --force-esm-dirs lib/ src/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:vector-im/hydrogen-web.git"
|
||||
},
|
||||
"author": "matrix.org",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vector-im/hydrogen-web/issues"
|
||||
},
|
||||
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.11.1",
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@rollup/plugin-babel": "^5.1.0",
|
||||
"@rollup/plugin-multi-entry": "^4.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.2",
|
||||
"@typescript-eslint/parser": "^4.29.2",
|
||||
"autoprefixer": "^10.2.6",
|
||||
"cheerio": "^1.0.0-rc.3",
|
||||
"commander": "^6.0.0",
|
||||
"core-js": "^3.6.5",
|
||||
"eslint": "^7.32.0",
|
||||
"fake-indexeddb": "^3.1.2",
|
||||
"finalhandler": "^1.1.1",
|
||||
"impunity": "^1.0.1",
|
||||
"mdn-polyfills": "^5.20.0",
|
||||
"node-html-parser": "^4.0.0",
|
||||
"postcss": "^8.1.1",
|
||||
"postcss-css-variables": "^0.17.0",
|
||||
"postcss-flexbugs-fixes": "^4.2.1",
|
||||
"postcss-import": "^12.0.1",
|
||||
"postcss-url": "^8.0.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rollup-plugin-cleanup": "^3.1.1",
|
||||
"serve-static": "^1.13.2",
|
||||
"snowpack": "^3.8.3",
|
||||
"typescript": "^4.3.5",
|
||||
"xxhashjs": "^0.2.2",
|
||||
"vite": "^2.6.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",
|
||||
"@rollup/plugin-commonjs": "^15.0.0",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^9.0.0",
|
||||
"aes-js": "^3.1.2",
|
||||
"another-json": "^0.2.0",
|
||||
"base64-arraybuffer": "^0.2.0",
|
||||
"bs58": "^4.0.1",
|
||||
"dompurify": "^2.3.0",
|
||||
"es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush",
|
||||
"rollup": "^2.26.4",
|
||||
"text-encoding": "^0.7.0",
|
||||
"hydrogen-common": "0.0.1",
|
||||
"hydrogen-matrix": "0.0.1",
|
||||
"hydrogen-domain": "0.0.1"
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
import _downloadSandboxPath from "../../assets/download-sandbox.html?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";
|
||||
|
||||
export const olmPaths = {
|
||||
wasm: olmWasmPath,
|
||||
legacyBundle: olmLegacyJsPath,
|
||||
wasmBundle: olmJsPath,
|
||||
};
|
||||
|
||||
export const downloadSandboxPath = _downloadSandboxPath;
|
|
@ -1,280 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {el} from "./html";
|
||||
import {mountView} from "./utils";
|
||||
import {ListView} from "./ListView";
|
||||
import {insertAt} from "./utils";
|
||||
|
||||
class ItemRange {
|
||||
constructor(topCount, renderCount, bottomCount) {
|
||||
this.topCount = topCount;
|
||||
this.renderCount = renderCount;
|
||||
this.bottomCount = bottomCount;
|
||||
}
|
||||
|
||||
contains(range) {
|
||||
// don't contain empty ranges
|
||||
// as it will prevent clearing the list
|
||||
// once it is scrolled far enough out of view
|
||||
if (!range.renderCount && this.renderCount) {
|
||||
return false;
|
||||
}
|
||||
return range.topCount >= this.topCount &&
|
||||
(range.topCount + range.renderCount) <= (this.topCount + this.renderCount);
|
||||
}
|
||||
|
||||
containsIndex(idx) {
|
||||
return idx >= this.topCount && idx <= (this.topCount + this.renderCount);
|
||||
}
|
||||
|
||||
expand(amount) {
|
||||
// don't expand ranges that won't render anything
|
||||
if (this.renderCount === 0) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const topGrow = Math.min(amount, this.topCount);
|
||||
const bottomGrow = Math.min(amount, this.bottomCount);
|
||||
return new ItemRange(
|
||||
this.topCount - topGrow,
|
||||
this.renderCount + topGrow + bottomGrow,
|
||||
this.bottomCount - bottomGrow,
|
||||
);
|
||||
}
|
||||
|
||||
totalSize() {
|
||||
return this.topCount + this.renderCount + this.bottomCount;
|
||||
}
|
||||
|
||||
normalize(idx) {
|
||||
/*
|
||||
map index from list to index in rendered range
|
||||
eg: if the index range of this._list is [0, 200] and we have rendered
|
||||
elements in range [50, 60] then index 50 in list must map to index 0
|
||||
in DOM tree/childInstance array.
|
||||
*/
|
||||
return idx - this.topCount;
|
||||
}
|
||||
}
|
||||
|
||||
export class LazyListView extends ListView {
|
||||
constructor({itemHeight, overflowMargin = 5, overflowItems = 20,...options}, childCreator) {
|
||||
super(options, childCreator);
|
||||
this._itemHeight = itemHeight;
|
||||
this._overflowMargin = overflowMargin;
|
||||
this._overflowItems = overflowItems;
|
||||
}
|
||||
|
||||
_getVisibleRange() {
|
||||
const length = this._list ? this._list.length : 0;
|
||||
const scrollTop = this._parent.scrollTop;
|
||||
const topCount = Math.min(Math.max(0, Math.floor(scrollTop / this._itemHeight)), length);
|
||||
const itemsAfterTop = length - topCount;
|
||||
const visibleItems = this._height !== 0 ? Math.ceil(this._height / this._itemHeight) : 0;
|
||||
const renderCount = Math.min(visibleItems, itemsAfterTop);
|
||||
const bottomCount = itemsAfterTop - renderCount;
|
||||
return new ItemRange(topCount, renderCount, bottomCount);
|
||||
}
|
||||
|
||||
_renderIfNeeded(forceRender = false) {
|
||||
/*
|
||||
forceRender only because we don't optimize onAdd/onRemove yet.
|
||||
Ideally, onAdd/onRemove should only render whatever has changed + update padding + update renderRange
|
||||
*/
|
||||
const range = this._getVisibleRange();
|
||||
const intersectRange = range.expand(this._overflowMargin);
|
||||
const renderRange = range.expand(this._overflowItems);
|
||||
// only update render Range if the new range + overflowMargin isn't contained by the old anymore
|
||||
// or if we are force rendering
|
||||
if (forceRender || !this._renderRange.contains(intersectRange)) {
|
||||
this._renderRange = renderRange;
|
||||
this._renderElementsInRange();
|
||||
}
|
||||
}
|
||||
|
||||
async _initialRender() {
|
||||
/*
|
||||
Wait two frames for the return from mount() to be inserted into DOM.
|
||||
This should be enough, but if this gives us trouble we can always use
|
||||
MutationObserver.
|
||||
*/
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
|
||||
this._height = this._parent.clientHeight;
|
||||
if (this._height === 0) { console.error("LazyListView could not calculate parent height."); }
|
||||
const range = this._getVisibleRange();
|
||||
const renderRange = range.expand(this._overflowItems);
|
||||
this._renderRange = renderRange;
|
||||
this._renderElementsInRange();
|
||||
}
|
||||
|
||||
_itemsFromList(start, end) {
|
||||
const array = [];
|
||||
let i = 0;
|
||||
for (const item of this._list) {
|
||||
if (i >= start && i < end) {
|
||||
array.push(item);
|
||||
}
|
||||
i = i + 1;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
_itemAtIndex(idx) {
|
||||
let i = 0;
|
||||
for (const item of this._list) {
|
||||
if (i === idx) {
|
||||
return item;
|
||||
}
|
||||
i = i + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_renderElementsInRange() {
|
||||
const { topCount, renderCount, bottomCount } = this._renderRange;
|
||||
const paddingTop = topCount * this._itemHeight;
|
||||
const paddingBottom = bottomCount * this._itemHeight;
|
||||
const renderedItems = this._itemsFromList(topCount, topCount + renderCount);
|
||||
this._root.style.paddingTop = `${paddingTop}px`;
|
||||
this._root.style.paddingBottom = `${paddingBottom}px`;
|
||||
for (const child of this._childInstances) {
|
||||
this._removeChild(child);
|
||||
}
|
||||
this._childInstances = [];
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const item of renderedItems) {
|
||||
const view = this._childCreator(item);
|
||||
this._childInstances.push(view);
|
||||
fragment.appendChild(mountView(view, this._mountArgs));
|
||||
}
|
||||
this._root.appendChild(fragment);
|
||||
}
|
||||
|
||||
mount() {
|
||||
const root = super.mount();
|
||||
this._parent = el("div", {className: "LazyListParent"}, root);
|
||||
/*
|
||||
Hooking to scroll events can be expensive.
|
||||
Do we need to do more (like event throttling)?
|
||||
*/
|
||||
this._parent.addEventListener("scroll", () => this._renderIfNeeded());
|
||||
this._initialRender();
|
||||
return this._parent;
|
||||
}
|
||||
|
||||
update(attributes) {
|
||||
this._renderRange = null;
|
||||
super.update(attributes);
|
||||
this._initialRender();
|
||||
}
|
||||
|
||||
loadList() {
|
||||
if (!this._list) { return; }
|
||||
this._subscription = this._list.subscribe(this);
|
||||
this._childInstances = [];
|
||||
/*
|
||||
super.loadList() would render the entire list at this point.
|
||||
We instead lazy render a part of the list in _renderIfNeeded
|
||||
*/
|
||||
}
|
||||
|
||||
_removeChild(child) {
|
||||
child.root().remove();
|
||||
child.unmount();
|
||||
}
|
||||
|
||||
// If size of the list changes, re-render
|
||||
onAdd() {
|
||||
this._renderIfNeeded(true);
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
this._renderIfNeeded(true);
|
||||
}
|
||||
|
||||
onUpdate(idx, value, params) {
|
||||
if (this._renderRange.containsIndex(idx)) {
|
||||
const normalizedIdx = this._renderRange.normalize(idx);
|
||||
super.onUpdate(normalizedIdx, value, params);
|
||||
}
|
||||
}
|
||||
|
||||
recreateItem(idx, value) {
|
||||
if (this._renderRange.containsIndex(idx)) {
|
||||
const normalizedIdx = this._renderRange.normalize(idx);
|
||||
super.recreateItem(normalizedIdx, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render additional element from top or bottom to offset the outgoing element
|
||||
*/
|
||||
_renderExtraOnMove(fromIdx, toIdx) {
|
||||
const {topCount, renderCount} = this._renderRange;
|
||||
if (toIdx < fromIdx) {
|
||||
// Element is moved up the list, so render element from top boundary
|
||||
const index = topCount;
|
||||
const child = this._childCreator(this._itemAtIndex(index));
|
||||
this._childInstances.unshift(child);
|
||||
this._root.insertBefore(mountView(child, this._mountArgs), this._root.firstChild);
|
||||
}
|
||||
else {
|
||||
// Element is moved down the list, so render element from bottom boundary
|
||||
const index = topCount + renderCount - 1;
|
||||
const child = this._childCreator(this._itemAtIndex(index));
|
||||
this._childInstances.push(child);
|
||||
this._root.appendChild(mountView(child, this._mountArgs));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an element from top or bottom to make space for the incoming element
|
||||
*/
|
||||
_removeElementOnMove(fromIdx, toIdx) {
|
||||
// If element comes from the bottom, remove element at bottom and vice versa
|
||||
const child = toIdx < fromIdx ? this._childInstances.pop() : this._childInstances.shift();
|
||||
this._removeChild(child);
|
||||
}
|
||||
|
||||
onMove(fromIdx, toIdx, value) {
|
||||
const fromInRange = this._renderRange.containsIndex(fromIdx);
|
||||
const toInRange = this._renderRange.containsIndex(toIdx);
|
||||
const normalizedFromIdx = this._renderRange.normalize(fromIdx);
|
||||
const normalizedToIdx = this._renderRange.normalize(toIdx);
|
||||
if (fromInRange && toInRange) {
|
||||
super.onMove(normalizedFromIdx, normalizedToIdx, value);
|
||||
}
|
||||
else if (fromInRange && !toInRange) {
|
||||
this.onBeforeListChanged();
|
||||
const [child] = this._childInstances.splice(normalizedFromIdx, 1);
|
||||
this._removeChild(child);
|
||||
this._renderExtraOnMove(fromIdx, toIdx);
|
||||
this.onListChanged();
|
||||
}
|
||||
else if (!fromInRange && toInRange) {
|
||||
this.onBeforeListChanged();
|
||||
const child = this._childCreator(value);
|
||||
this._removeElementOnMove(fromIdx, toIdx);
|
||||
this._childInstances.splice(normalizedToIdx, 0, child);
|
||||
insertAt(this._root, normalizedToIdx, mountView(child, this._mountArgs));
|
||||
this.onListChanged();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {TemplateView} from "../../general/TemplateView";
|
||||
import {renderStaticAvatar} from "../../avatar.js";
|
||||
import {spinner} from "../../common.js";
|
||||
|
||||
export class InviteTileView extends TemplateView {
|
||||
render(t, vm) {
|
||||
const classes = {
|
||||
"active": vm => vm.isOpen,
|
||||
"hidden": vm => vm.hidden
|
||||
};
|
||||
return t.li({"className": classes}, [
|
||||
t.a({href: vm.url}, [
|
||||
renderStaticAvatar(vm, 32),
|
||||
t.div({className: "description"}, [
|
||||
t.div({className: "name"}, vm.name),
|
||||
t.map(vm => vm.busy, busy => {
|
||||
if (busy) {
|
||||
return spinner(t);
|
||||
} else {
|
||||
return t.div({className: "badge highlighted"}, "!");
|
||||
}
|
||||
})
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {TemplateView} from "../../general/TemplateView";
|
||||
import {StaticView} from "../../general/StaticView.js";
|
||||
|
||||
export class SessionBackupSettingsView extends TemplateView {
|
||||
render(t, vm) {
|
||||
return t.mapView(vm => vm.status, status => {
|
||||
switch (status) {
|
||||
case "enabled": return new TemplateView(vm, renderEnabled)
|
||||
case "setupKey": return new TemplateView(vm, renderEnableFromKey)
|
||||
case "setupPhrase": return new TemplateView(vm, renderEnableFromPhrase)
|
||||
case "pending": return new StaticView(vm, t => t.p(vm.i18n`Waiting to go online…`))
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderEnabled(t, vm) {
|
||||
return t.p(vm.i18n`Session backup is enabled, using backup version ${vm.backupVersion}.`);
|
||||
}
|
||||
|
||||
function renderEnableFromKey(t, vm) {
|
||||
const useASecurityPhrase = t.button({className: "link", onClick: () => vm.showPhraseSetup()}, vm.i18n`use a security phrase`);
|
||||
return t.div([
|
||||
t.p(vm.i18n`Enter your secret storage security key below to set up session backup, which will enable you to decrypt messages received before you logged into this session. The security key is a code of 12 groups of 4 characters separated by a space that Element created for you when setting up security.`),
|
||||
renderError(t),
|
||||
renderEnableFieldRow(t, vm, vm.i18n`Security key`, key => vm.enterSecurityKey(key)),
|
||||
t.p([vm.i18n`Alternatively, you can `, useASecurityPhrase, vm.i18n` if you have one.`]),
|
||||
]);
|
||||
}
|
||||
|
||||
function renderEnableFromPhrase(t, vm) {
|
||||
const useASecurityKey = t.button({className: "link", onClick: () => vm.showKeySetup()}, vm.i18n`use your security key`);
|
||||
return t.div([
|
||||
t.p(vm.i18n`Enter your secret storage security phrase below to set up session backup, which will enable you to decrypt messages received before you logged into this session. The security phrase is a freeform secret phrase you optionally chose when setting up security in Element. It is different from your password to login, unless you chose to set them to the same value.`),
|
||||
renderError(t),
|
||||
renderEnableFieldRow(t, vm, vm.i18n`Security phrase`, phrase => vm.enterSecurityPhrase(phrase)),
|
||||
t.p([vm.i18n`You can also `, useASecurityKey, vm.i18n`.`]),
|
||||
]);
|
||||
}
|
||||
|
||||
function renderEnableFieldRow(t, vm, label, callback) {
|
||||
const eventHandler = () => callback(input.value);
|
||||
const input = t.input({type: "password", disabled: vm => vm.isBusy, placeholder: label, onChange: eventHandler});
|
||||
return t.div({className: `row`}, [
|
||||
t.div({className: "label"}, label),
|
||||
t.div({className: "content"}, [
|
||||
input,
|
||||
t.button({disabled: vm => vm.isBusy, onClick: eventHandler}, vm.i18n`Set up`),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
function renderError(t) {
|
||||
return t.if(vm => vm.error, (t, vm) => {
|
||||
return t.div([
|
||||
t.p({className: "error"}, vm => vm.i18n`Could not enable session backup: ${vm.error}.`),
|
||||
t.p(vm.i18n`Try double checking that you did not mix up your security key, security phrase and login password as explained above.`)
|
||||
])
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
public: false,
|
||||
server: {
|
||||
hmr: false
|
||||
}
|
||||
};
|
18
scripts/.eslintrc.js
Normal file
18
scripts/.eslintrc.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
module.exports = {
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"no-empty": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"no-unused-vars": "warn"
|
||||
},
|
||||
};
|
||||
|
51
scripts/build-plugins/manifest.js
Normal file
51
scripts/build-plugins/manifest.js
Normal file
|
@ -0,0 +1,51 @@
|
|||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = function injectWebManifest(manifestFile) {
|
||||
let root;
|
||||
let base;
|
||||
let manifestHref;
|
||||
return {
|
||||
name: "hydrogen:injectWebManifest",
|
||||
apply: "build",
|
||||
configResolved: config => {
|
||||
root = config.root;
|
||||
base = config.base;
|
||||
},
|
||||
transformIndexHtml: {
|
||||
transform(html) {
|
||||
return [{
|
||||
tag: "link",
|
||||
attrs: {rel: "manifest", href: manifestHref},
|
||||
injectTo: "head"
|
||||
}];
|
||||
},
|
||||
},
|
||||
generateBundle: async function() {
|
||||
const absoluteManifestFile = path.resolve(root, manifestFile);
|
||||
const manifestDir = path.dirname(absoluteManifestFile);
|
||||
const json = await fs.readFile(absoluteManifestFile, {encoding: "utf8"});
|
||||
const manifest = JSON.parse(json);
|
||||
for (const icon of manifest.icons) {
|
||||
const iconFileName = path.resolve(manifestDir, icon.src);
|
||||
const imgData = await fs.readFile(iconFileName);
|
||||
const ref = this.emitFile({
|
||||
type: "asset",
|
||||
name: path.basename(iconFileName),
|
||||
source: imgData
|
||||
});
|
||||
// we take the basename as getFileName gives the filename
|
||||
// relative to the output dir, but the manifest is an asset
|
||||
// just like they icon, so we assume they end up in the same dir
|
||||
icon.src = path.basename(this.getFileName(ref));
|
||||
}
|
||||
const outputName = path.basename(absoluteManifestFile);
|
||||
const manifestRef = this.emitFile({
|
||||
type: "asset",
|
||||
name: outputName,
|
||||
source: JSON.stringify(manifest)
|
||||
});
|
||||
manifestHref = base + this.getFileName(manifestRef);
|
||||
}
|
||||
};
|
||||
}
|
376
scripts/build-plugins/rollup-plugin-build-themes.js
Normal file
376
scripts/build-plugins/rollup-plugin-build-themes.js
Normal file
|
@ -0,0 +1,376 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
const path = require('path').posix;
|
||||
const {optimize} = require('svgo');
|
||||
|
||||
async function readCSSSource(location) {
|
||||
const fs = require("fs").promises;
|
||||
const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`);
|
||||
const data = await fs.readFile(resolvedLocation);
|
||||
return data;
|
||||
}
|
||||
|
||||
function getRootSectionWithVariables(variables) {
|
||||
return `:root{\n${Object.entries(variables).reduce((acc, [key, value]) => acc + `--${key}: ${value};\n`, "")} }\n\n`;
|
||||
}
|
||||
|
||||
function appendVariablesToCSS(variables, cssSource) {
|
||||
return cssSource + getRootSectionWithVariables(variables);
|
||||
}
|
||||
|
||||
function addThemesToConfig(bundle, manifestLocations, defaultThemes) {
|
||||
for (const [fileName, info] of Object.entries(bundle)) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
if (!location) {
|
||||
throw new Error("Cannot find location of css chunk!");
|
||||
}
|
||||
const array = chunkMap.get(location);
|
||||
if (!array) {
|
||||
chunkMap.set(location, [info]);
|
||||
}
|
||||
else {
|
||||
array.push(info);
|
||||
}
|
||||
}
|
||||
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, defaultThemes = {};
|
||||
let isDevelopment = false;
|
||||
const virtualModuleId = '@theme/'
|
||||
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
||||
const themeToManifestLocation = new Map();
|
||||
|
||||
return {
|
||||
name: "build-themes",
|
||||
enforce: "pre",
|
||||
|
||||
configResolved(config) {
|
||||
if (config.command === "serve") {
|
||||
isDevelopment = true;
|
||||
}
|
||||
},
|
||||
|
||||
async buildStart() {
|
||||
const { themeConfig } = options;
|
||||
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-${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
|
||||
if (!isDevelopment) {
|
||||
this.emitFile({ type: "chunk", id: `${location}/theme.css?variant=${variant}${details.dark ? "&dark=true" : ""}`, fileName, });
|
||||
}
|
||||
}
|
||||
// emit the css as runtime theme bundle
|
||||
if (!isDevelopment) {
|
||||
this.emitFile({ type: "chunk", id: `${location}/theme.css?type=runtime`, fileName: `theme-${themeCollectionId}-runtime.css`, });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
resolveId(id) {
|
||||
if (id.startsWith(virtualModuleId)) {
|
||||
return '\0' + id;
|
||||
}
|
||||
},
|
||||
|
||||
async load(id) {
|
||||
if (isDevelopment) {
|
||||
/**
|
||||
* To load the theme during dev, we need to take a different approach because emitFile is not supported in dev.
|
||||
* We solve this by resolving virtual file "@theme/name/variant" into the necessary css import.
|
||||
* This virtual file import is removed when hydrogen is built (see transform hook).
|
||||
*/
|
||||
if (id.startsWith(resolvedVirtualModuleId)) {
|
||||
let [theme, variant, file] = id.substr(resolvedVirtualModuleId.length).split("/");
|
||||
if (theme === "default") {
|
||||
theme = options.themeConfig.default;
|
||||
}
|
||||
const location = themeToManifestLocation.get(theme);
|
||||
const manifest = require(`${location}/manifest.json`);
|
||||
const variants = manifest.values.variants;
|
||||
if (!variant || variant === "default") {
|
||||
// choose the first default variant for now
|
||||
// this will need to support light/dark variants as well
|
||||
variant = Object.keys(variants).find(variantName => variants[variantName].default);
|
||||
}
|
||||
if (!file) {
|
||||
file = "index.js";
|
||||
}
|
||||
switch (file) {
|
||||
case "index.js": {
|
||||
const isDark = variants[variant].dark;
|
||||
return `import "${path.resolve(`${location}/theme.css`)}${isDark? "?dark=true": ""}";` +
|
||||
`import "@theme/${theme}/${variant}/variables.css"`;
|
||||
}
|
||||
case "variables.css": {
|
||||
const variables = variants[variant].variables;
|
||||
const css = getRootSectionWithVariables(variables);
|
||||
return css;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
const result = id.match(/(.+)\/theme.css\?variant=([^&]+)/);
|
||||
if (result) {
|
||||
const [, location, variant] = result;
|
||||
const cssSource = await readCSSSource(location);
|
||||
const config = variants[variant];
|
||||
return appendVariablesToCSS(config.variables, cssSource);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
transform(code, id) {
|
||||
if (isDevelopment) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Removes develop-only script tag; this cannot be done in transformIndexHtml hook because
|
||||
* by the time that hook runs, the import is added to the bundled js file which would
|
||||
* result in a runtime error.
|
||||
*/
|
||||
|
||||
const devScriptTag =
|
||||
/<script type="module"> import "@theme\/.+"; <\/script>/;
|
||||
if (id.endsWith("index.html")) {
|
||||
const htmlWithoutDevScript = code.replace(devScriptTag, "");
|
||||
return htmlWithoutDevScript;
|
||||
}
|
||||
},
|
||||
|
||||
transformIndexHtml(_, ctx) {
|
||||
if (isDevelopment) {
|
||||
// Don't add default stylesheets to index.html on dev
|
||||
return;
|
||||
}
|
||||
let darkThemeLocation, lightThemeLocation;
|
||||
for (const [, bundle] of Object.entries(ctx.bundle)) {
|
||||
if (bundle.name === defaultDark) {
|
||||
darkThemeLocation = bundle.fileName;
|
||||
}
|
||||
if (bundle.name === defaultLight) {
|
||||
lightThemeLocation = bundle.fileName;
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
tag: "link",
|
||||
attrs: {
|
||||
rel: "stylesheet",
|
||||
type: "text/css",
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
href: `./${darkThemeLocation}`,
|
||||
class: "theme",
|
||||
}
|
||||
},
|
||||
{
|
||||
tag: "link",
|
||||
attrs: {
|
||||
rel: "stylesheet",
|
||||
type: "text/css",
|
||||
media: "(prefers-color-scheme: light)",
|
||||
href: `./${lightThemeLocation}`,
|
||||
class: "theme",
|
||||
}
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
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-assets": builtAssets,
|
||||
"runtime-asset": runtimeAssetLocation,
|
||||
"derived-variables": derivedVariables,
|
||||
"icon": icon,
|
||||
};
|
||||
const name = `theme-${themeKey}.json`;
|
||||
manifestLocations.push(`${manifestLocation}/${name}`);
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
name,
|
||||
source: JSON.stringify(manifest),
|
||||
});
|
||||
}
|
||||
addThemesToConfig(bundle, manifestLocations, defaultThemes);
|
||||
},
|
||||
}
|
||||
}
|
157
scripts/build-plugins/service-worker.js
Normal file
157
scripts/build-plugins/service-worker.js
Normal file
|
@ -0,0 +1,157 @@
|
|||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const xxhash = require('xxhashjs');
|
||||
|
||||
function contentHash(str) {
|
||||
var hasher = new xxhash.h32(0);
|
||||
hasher.update(str);
|
||||
return hasher.digest();
|
||||
}
|
||||
|
||||
function injectServiceWorker(swFile, findUnhashedFileNamesFromBundle, placeholdersPerChunk) {
|
||||
const swName = path.basename(swFile);
|
||||
let root;
|
||||
let version;
|
||||
let logger;
|
||||
|
||||
return {
|
||||
name: "hydrogen:injectServiceWorker",
|
||||
apply: "build",
|
||||
enforce: "post",
|
||||
buildStart() {
|
||||
this.emitFile({
|
||||
type: "chunk",
|
||||
fileName: swName,
|
||||
id: swFile,
|
||||
});
|
||||
},
|
||||
configResolved: config => {
|
||||
root = config.root;
|
||||
version = JSON.parse(config.define.DEFINE_VERSION); // unquote
|
||||
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];
|
||||
if (!chunkOrAsset) {
|
||||
throw new Error("could not get content for uncached asset or chunk " + fileName);
|
||||
}
|
||||
map[fileName] = chunkOrAsset.source || chunkOrAsset.code;
|
||||
return map;
|
||||
}, {});
|
||||
const assets = Object.values(bundle);
|
||||
const hashedFileNames = assets.map(o => o.fileName).filter(fileName => !unhashedFileContentMap[fileName]);
|
||||
const globalHash = getBuildHash(hashedFileNames, unhashedFileContentMap);
|
||||
const placeholderValues = {
|
||||
DEFINE_GLOBAL_HASH: `"${globalHash}"`,
|
||||
...getCacheFileNamePlaceholderValues(swName, unhashedFilenames, assets, placeholdersPerChunk)
|
||||
};
|
||||
replacePlaceholdersInChunks(assets, placeholdersPerChunk, placeholderValues);
|
||||
logger.info(`\nBuilt ${version} (${globalHash})`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getBuildHash(hashedFileNames, unhashedFileContentMap) {
|
||||
const unhashedHashes = Object.entries(unhashedFileContentMap).map(([fileName, content]) => {
|
||||
return `${fileName}-${contentHash(Buffer.from(content))}`;
|
||||
});
|
||||
const globalHashAssets = hashedFileNames.concat(unhashedHashes);
|
||||
globalHashAssets.sort();
|
||||
return contentHash(globalHashAssets.join(",")).toString();
|
||||
}
|
||||
|
||||
const NON_PRECACHED_JS = [
|
||||
"hydrogen-legacy",
|
||||
"olm_legacy.js",
|
||||
// most environments don't need the worker
|
||||
"main.js"
|
||||
];
|
||||
|
||||
function isPreCached(asset) {
|
||||
const {name, fileName} = asset;
|
||||
return name.endsWith(".svg") ||
|
||||
name.endsWith(".png") ||
|
||||
name.endsWith(".css") ||
|
||||
name.endsWith(".wasm") ||
|
||||
name.endsWith(".html") ||
|
||||
// the index and vendor chunks don't have an extension in `name`, so check extension on `fileName`
|
||||
fileName.endsWith(".js") && !NON_PRECACHED_JS.includes(path.basename(name));
|
||||
}
|
||||
|
||||
function getCacheFileNamePlaceholderValues(swName, unhashedFilenames, assets) {
|
||||
const unhashedPreCachedAssets = [];
|
||||
const hashedPreCachedAssets = [];
|
||||
const hashedCachedOnRequestAssets = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
const {name, fileName} = asset;
|
||||
// the service worker should not be cached at all,
|
||||
// it's how updates happen
|
||||
if (fileName === swName) {
|
||||
continue;
|
||||
} else if (unhashedFilenames.includes(fileName)) {
|
||||
unhashedPreCachedAssets.push(fileName);
|
||||
} else if (isPreCached(asset)) {
|
||||
hashedPreCachedAssets.push(fileName);
|
||||
} else {
|
||||
hashedCachedOnRequestAssets.push(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
DEFINE_UNHASHED_PRECACHED_ASSETS: JSON.stringify(unhashedPreCachedAssets),
|
||||
DEFINE_HASHED_PRECACHED_ASSETS: JSON.stringify(hashedPreCachedAssets),
|
||||
DEFINE_HASHED_CACHED_ON_REQUEST_ASSETS: JSON.stringify(hashedCachedOnRequestAssets)
|
||||
}
|
||||
}
|
||||
|
||||
function replacePlaceholdersInChunks(assets, placeholdersPerChunk, placeholderValues) {
|
||||
for (const [name, placeholderMap] of Object.entries(placeholdersPerChunk)) {
|
||||
const chunk = assets.find(a => a.type === "chunk" && a.name === name);
|
||||
if (!chunk) {
|
||||
throw new Error(`could not find chunk ${name} to replace placeholders`);
|
||||
}
|
||||
for (const [placeholderName, placeholderLiteral] of Object.entries(placeholderMap)) {
|
||||
const replacedValue = placeholderValues[placeholderName];
|
||||
const oldCode = chunk.code;
|
||||
chunk.code = chunk.code.replaceAll(placeholderLiteral, replacedValue);
|
||||
if (chunk.code === oldCode) {
|
||||
throw new Error(`Could not replace ${placeholderName} in ${name}, looking for literal ${placeholderLiteral}:\n${chunk.code}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** creates a value to be include in the `define` build settings,
|
||||
* but can be replace at the end of the build in certain chunks.
|
||||
* We need this for injecting the global build hash and the final
|
||||
* filenames in the service worker and index chunk.
|
||||
* These values are only known in the generateBundle step, so we
|
||||
* replace them by unique strings wrapped in a prompt call so no
|
||||
* transformation will touch them (minifying, ...) and we can do a
|
||||
* string replacement still at the end of the build. */
|
||||
function definePlaceholderValue(mode, name, devValue) {
|
||||
if (mode === "production") {
|
||||
// note that `prompt(...)` will never be in the final output, it's replaced by the final value
|
||||
// once we know at the end of the build what it is and just used as a temporary value during the build
|
||||
// as something that will not be transformed.
|
||||
// I first considered Symbol but it's not inconceivable that babel would transform this.
|
||||
return `prompt(${JSON.stringify(name)})`;
|
||||
} else {
|
||||
return JSON.stringify(devValue);
|
||||
}
|
||||
}
|
||||
|
||||
function createPlaceholderValues(mode) {
|
||||
return {
|
||||
DEFINE_GLOBAL_HASH: definePlaceholderValue(mode, "DEFINE_GLOBAL_HASH", null),
|
||||
DEFINE_UNHASHED_PRECACHED_ASSETS: definePlaceholderValue(mode, "UNHASHED_PRECACHED_ASSETS", []),
|
||||
DEFINE_HASHED_PRECACHED_ASSETS: definePlaceholderValue(mode, "HASHED_PRECACHED_ASSETS", []),
|
||||
DEFINE_HASHED_CACHED_ON_REQUEST_ASSETS: definePlaceholderValue(mode, "HASHED_CACHED_ON_REQUEST_ASSETS", []),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {injectServiceWorker, createPlaceholderValues};
|
|
@ -1,578 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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 {build as snowpackBuild, loadConfiguration} from "snowpack"
|
||||
import cheerio from "cheerio";
|
||||
import fsRoot from "fs";
|
||||
const fs = fsRoot.promises;
|
||||
import path from "path";
|
||||
import xxhash from 'xxhashjs';
|
||||
import { rollup } from 'rollup';
|
||||
import postcss from "postcss";
|
||||
import postcssImport from "postcss-import";
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import commander from "commander";
|
||||
// needed for legacy bundle
|
||||
import babel from '@rollup/plugin-babel';
|
||||
// needed to find the polyfill modules in the main-legacy.js bundle
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
||||
// needed because some of the polyfills are written as commonjs modules
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
// multi-entry plugin so we can add polyfill file to main
|
||||
import multi from '@rollup/plugin-multi-entry';
|
||||
import removeJsComments from 'rollup-plugin-cleanup';
|
||||
// replace urls of asset names with content hashed version
|
||||
import postcssUrl from "postcss-url";
|
||||
|
||||
import cssvariables from "postcss-css-variables";
|
||||
import autoprefixer from "autoprefixer";
|
||||
import flexbugsFixes from "postcss-flexbugs-fixes";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const projectDir = path.join(__dirname, "../");
|
||||
const snowpackOutPath = path.join(projectDir, "snowpack-build-output");
|
||||
const cssSrcDir = path.join(projectDir, "src/platform/web/ui/css/");
|
||||
const snowpackConfig = await loadConfiguration({buildOptions: {out: snowpackOutPath}}, "snowpack.config.js");
|
||||
const snowpackOutDir = snowpackConfig.buildOptions.out.substring(projectDir.length);
|
||||
const srcDir = path.join(projectDir, `${snowpackOutDir}/src/`);
|
||||
const isPathInSrcDir = path => path.startsWith(srcDir);
|
||||
|
||||
const parameters = new commander.Command();
|
||||
parameters
|
||||
.option("--modern-only", "don't make a legacy build")
|
||||
.option("--override-imports <json file>", "pass in a file to override import paths, see doc/SKINNING.md")
|
||||
.option("--override-css <main css file>", "pass in an alternative main css file")
|
||||
parameters.parse(process.argv);
|
||||
|
||||
/**
|
||||
* We use Snowpack to handle the translation of TypeScript
|
||||
* into JavaScript. We thus can't bundle files straight from
|
||||
* the src directory, since some of them are TypeScript, and since
|
||||
* they may import Node modules. We thus bundle files after they
|
||||
* have been processed by Snowpack. This function returns paths
|
||||
* to the files that have already been pre-processed in this manner.
|
||||
*/
|
||||
function srcPath(src) {
|
||||
return path.join(snowpackOutDir, 'src', src);
|
||||
}
|
||||
|
||||
async function build({modernOnly, overrideImports, overrideCss}) {
|
||||
await snowpackBuild({config: snowpackConfig});
|
||||
// get version number
|
||||
const version = JSON.parse(await fs.readFile(path.join(projectDir, "package.json"), "utf8")).version;
|
||||
let importOverridesMap;
|
||||
if (overrideImports) {
|
||||
importOverridesMap = await readImportOverrides(overrideImports);
|
||||
}
|
||||
const devHtml = await fs.readFile(path.join(snowpackOutPath, "index.html"), "utf8");
|
||||
const doc = cheerio.load(devHtml);
|
||||
const themes = [];
|
||||
findThemes(doc, themeName => {
|
||||
themes.push(themeName);
|
||||
});
|
||||
// clear target dir
|
||||
const targetDir = path.join(projectDir, "target/");
|
||||
await removeDirIfExists(targetDir);
|
||||
await createDirs(targetDir, themes);
|
||||
const assets = new AssetMap(targetDir);
|
||||
// copy olm assets
|
||||
const olmAssets = await copyFolder(path.join(projectDir, "lib/olm/"), assets.directory);
|
||||
assets.addSubMap(olmAssets);
|
||||
await assets.write(`hydrogen.js`, await buildJs(srcPath("main.js"), [srcPath("platform/web/Platform.js")], importOverridesMap));
|
||||
if (!modernOnly) {
|
||||
await assets.write(`hydrogen-legacy.js`, await buildJsLegacy(srcPath("main.js"), [
|
||||
srcPath('platform/web/legacy-polyfill.js'),
|
||||
srcPath('platform/web/LegacyPlatform.js')
|
||||
], importOverridesMap));
|
||||
await assets.write(`worker.js`, await buildJsLegacy(srcPath("platform/web/worker/main.js"), [srcPath('platform/web/worker/polyfill.js')]));
|
||||
}
|
||||
// copy over non-theme assets
|
||||
const baseConfig = JSON.parse(await fs.readFile(path.join(projectDir, "assets/config.json"), {encoding: "utf8"}));
|
||||
const downloadSandbox = "download-sandbox.html";
|
||||
let downloadSandboxHtml = await fs.readFile(path.join(projectDir, `assets/${downloadSandbox}`));
|
||||
await assets.write(downloadSandbox, downloadSandboxHtml);
|
||||
// creates the directories where the theme css bundles are placed in,
|
||||
// and writes to assets, so the build bundles can translate them, so do it first
|
||||
await copyThemeAssets(themes, assets);
|
||||
await buildCssBundles(buildCssLegacy, themes, assets, overrideCss);
|
||||
await buildManifest(assets);
|
||||
// all assets have been added, create a hash from all assets name to cache unhashed files like index.html
|
||||
assets.addToHashForAll("index.html", devHtml);
|
||||
let swSource = await fs.readFile(path.join(snowpackOutPath, "sw.js"), "utf8");
|
||||
assets.addToHashForAll("sw.js", swSource);
|
||||
|
||||
const globalHash = assets.hashForAll();
|
||||
|
||||
await buildServiceWorker(swSource, version, globalHash, assets);
|
||||
await buildHtml(doc, version, baseConfig, globalHash, modernOnly, assets);
|
||||
await removeDirIfExists(snowpackOutPath);
|
||||
console.log(`built hydrogen ${version} (${globalHash}) successfully with ${assets.size} files`);
|
||||
}
|
||||
|
||||
async function findThemes(doc, callback) {
|
||||
doc("link[rel~=stylesheet][title]").each((i, el) => {
|
||||
const theme = doc(el);
|
||||
const href = theme.attr("href");
|
||||
const themesPrefix = "/themes/";
|
||||
const prefixIdx = href.indexOf(themesPrefix);
|
||||
if (prefixIdx !== -1) {
|
||||
const themeNameStart = prefixIdx + themesPrefix.length;
|
||||
const themeNameEnd = href.indexOf("/", themeNameStart);
|
||||
const themeName = href.substr(themeNameStart, themeNameEnd - themeNameStart);
|
||||
callback(themeName, theme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createDirs(targetDir, themes) {
|
||||
await fs.mkdir(targetDir);
|
||||
const themeDir = path.join(targetDir, "themes");
|
||||
await fs.mkdir(themeDir);
|
||||
for (const theme of themes) {
|
||||
await fs.mkdir(path.join(themeDir, theme));
|
||||
}
|
||||
}
|
||||
|
||||
async function copyThemeAssets(themes, assets) {
|
||||
for (const theme of themes) {
|
||||
const themeDstFolder = path.join(assets.directory, `themes/${theme}`);
|
||||
const themeSrcFolder = path.join(cssSrcDir, `themes/${theme}`);
|
||||
const themeAssets = await copyFolder(themeSrcFolder, themeDstFolder, file => {
|
||||
return !file.endsWith(".css");
|
||||
});
|
||||
assets.addSubMap(themeAssets);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
async function buildHtml(doc, version, baseConfig, globalHash, modernOnly, assets) {
|
||||
// transform html file
|
||||
// change path to main.css to css bundle
|
||||
doc("link[rel=stylesheet]:not([title])").attr("href", assets.resolve(`hydrogen.css`));
|
||||
// adjust file name of icon on iOS
|
||||
doc("link[rel=apple-touch-icon]").attr("href", assets.resolve(`icon-maskable.png`));
|
||||
// change paths to all theme stylesheets
|
||||
findThemes(doc, (themeName, theme) => {
|
||||
theme.attr("href", assets.resolve(`themes/${themeName}/bundle.css`));
|
||||
});
|
||||
const configJSON = JSON.stringify(Object.assign({}, baseConfig, {
|
||||
worker: assets.has("worker.js") ? assets.resolve(`worker.js`) : null,
|
||||
downloadSandbox: assets.resolve("download-sandbox.html"),
|
||||
serviceWorker: "sw.js",
|
||||
olm: {
|
||||
wasm: assets.resolve("olm.wasm"),
|
||||
legacyBundle: assets.resolve("olm_legacy.js"),
|
||||
wasmBundle: assets.resolve("olm.js"),
|
||||
}
|
||||
}));
|
||||
const modernScript = `import {main, Platform} from "./${assets.resolve(`hydrogen.js`)}"; main(new Platform(document.body, ${configJSON}));`;
|
||||
const mainScripts = [
|
||||
`<script type="module">${wrapWithLicenseComments(modernScript)}</script>`
|
||||
];
|
||||
if (!modernOnly) {
|
||||
const legacyScript = `hydrogen.main(new hydrogen.Platform(document.body, ${configJSON}));`;
|
||||
mainScripts.push(
|
||||
`<script type="text/javascript" nomodule src="${assets.resolve(`hydrogen-legacy.js`)}"></script>`,
|
||||
`<script type="text/javascript" nomodule>${wrapWithLicenseComments(legacyScript)}</script>`
|
||||
);
|
||||
}
|
||||
doc("script#main").replaceWith(mainScripts.join(""));
|
||||
|
||||
const versionScript = doc("script#version");
|
||||
versionScript.attr("type", "text/javascript");
|
||||
let vSource = versionScript.contents().text();
|
||||
vSource = vSource.replace(`"%%VERSION%%"`, `"${version}"`);
|
||||
vSource = vSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`);
|
||||
versionScript.text(wrapWithLicenseComments(vSource));
|
||||
doc("head").append(`<link rel="manifest" href="${assets.resolve("manifest.json")}">`);
|
||||
await assets.writeUnhashed("index.html", doc.html());
|
||||
}
|
||||
|
||||
async function buildJs(mainFile, extraFiles, importOverrides) {
|
||||
// create js bundle
|
||||
const plugins = [multi(), removeJsComments({comments: "none"})];
|
||||
if (importOverrides) {
|
||||
plugins.push(overridesAsRollupPlugin(importOverrides));
|
||||
}
|
||||
const bundle = await rollup({
|
||||
// for fake-indexeddb, so usage for tests only doesn't put it in bundle
|
||||
treeshake: {moduleSideEffects: isPathInSrcDir},
|
||||
input: extraFiles.concat(mainFile),
|
||||
plugins
|
||||
});
|
||||
const {output} = await bundle.generate({
|
||||
format: 'es',
|
||||
// TODO: can remove this?
|
||||
name: `hydrogen`
|
||||
});
|
||||
const code = output[0].code;
|
||||
return wrapWithLicenseComments(code);
|
||||
}
|
||||
|
||||
async function buildJsLegacy(mainFile, extraFiles, importOverrides) {
|
||||
// compile down to whatever IE 11 needs
|
||||
const babelPlugin = babel.babel({
|
||||
babelHelpers: 'bundled',
|
||||
exclude: 'node_modules/**',
|
||||
presets: [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
useBuiltIns: "entry",
|
||||
corejs: "3.4",
|
||||
targets: "IE 11",
|
||||
// we provide our own promise polyfill (es6-promise)
|
||||
// with support for synchronous flushing of
|
||||
// the queue for idb where needed
|
||||
exclude: ["es.promise", "es.promise.all-settled", "es.promise.finally"]
|
||||
}
|
||||
]
|
||||
]
|
||||
});
|
||||
const plugins = [multi(), commonjs()];
|
||||
if (importOverrides) {
|
||||
plugins.push(overridesAsRollupPlugin(importOverrides));
|
||||
}
|
||||
plugins.push(nodeResolve(), babelPlugin);
|
||||
// create js bundle
|
||||
const rollupConfig = {
|
||||
// for fake-indexeddb, so usage for tests only doesn't put it in bundle
|
||||
treeshake: {moduleSideEffects: isPathInSrcDir},
|
||||
// important the extraFiles come first,
|
||||
// so polyfills are available in the global scope
|
||||
// if needed for the mainfile
|
||||
input: extraFiles.concat(mainFile),
|
||||
plugins
|
||||
};
|
||||
const bundle = await rollup(rollupConfig);
|
||||
const {output} = await bundle.generate({
|
||||
format: 'iife',
|
||||
name: `hydrogen`
|
||||
});
|
||||
const code = output[0].code;
|
||||
return wrapWithLicenseComments(code);
|
||||
}
|
||||
|
||||
function wrapWithLicenseComments(code) {
|
||||
// Add proper license comments to make GNU LibreJS accept the file
|
||||
const start = '// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0';
|
||||
const end = '// @license-end';
|
||||
return `${start}\n${code}\n${end}`;
|
||||
}
|
||||
|
||||
const NON_PRECACHED_JS = [
|
||||
"hydrogen-legacy.js",
|
||||
"olm_legacy.js",
|
||||
"worker.js"
|
||||
];
|
||||
|
||||
function isPreCached(asset) {
|
||||
return asset.endsWith(".svg") ||
|
||||
asset.endsWith(".png") ||
|
||||
asset.endsWith(".css") ||
|
||||
asset.endsWith(".wasm") ||
|
||||
asset.endsWith(".html") ||
|
||||
// most environments don't need the worker
|
||||
asset.endsWith(".js") && !NON_PRECACHED_JS.includes(asset);
|
||||
}
|
||||
|
||||
async function buildManifest(assets) {
|
||||
const webManifest = JSON.parse(await fs.readFile(path.join(projectDir, "assets/manifest.json"), "utf8"));
|
||||
// copy manifest icons
|
||||
for (const icon of webManifest.icons) {
|
||||
let iconData = await fs.readFile(path.join(projectDir, icon.src));
|
||||
const iconTargetPath = path.basename(icon.src);
|
||||
icon.src = await assets.write(iconTargetPath, iconData);
|
||||
}
|
||||
await assets.write("manifest.json", JSON.stringify(webManifest));
|
||||
}
|
||||
|
||||
async function buildServiceWorker(swSource, version, globalHash, assets) {
|
||||
const unhashedPreCachedAssets = ["index.html"];
|
||||
const hashedPreCachedAssets = [];
|
||||
const hashedCachedOnRequestAssets = [];
|
||||
|
||||
for (const [unresolved, resolved] of assets) {
|
||||
if (unresolved === resolved) {
|
||||
unhashedPreCachedAssets.push(resolved);
|
||||
} else if (isPreCached(unresolved)) {
|
||||
hashedPreCachedAssets.push(resolved);
|
||||
} else {
|
||||
hashedCachedOnRequestAssets.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
const replaceArrayInSource = (name, value) => {
|
||||
const newSource = swSource.replace(`${name} = []`, `${name} = ${JSON.stringify(value)}`);
|
||||
if (newSource === swSource) {
|
||||
throw new Error(`${name} was not found in the service worker source`);
|
||||
}
|
||||
return newSource;
|
||||
};
|
||||
const replaceStringInSource = (name, value) => {
|
||||
const newSource = swSource.replace(new RegExp(`${name}\\s=\\s"[^"]*"`), `${name} = ${JSON.stringify(value)}`);
|
||||
if (newSource === swSource) {
|
||||
throw new Error(`${name} was not found in the service worker source`);
|
||||
}
|
||||
return newSource;
|
||||
};
|
||||
|
||||
// write service worker
|
||||
swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`);
|
||||
swSource = swSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`);
|
||||
swSource = replaceArrayInSource("UNHASHED_PRECACHED_ASSETS", unhashedPreCachedAssets);
|
||||
swSource = replaceArrayInSource("HASHED_PRECACHED_ASSETS", hashedPreCachedAssets);
|
||||
swSource = replaceArrayInSource("HASHED_CACHED_ON_REQUEST_ASSETS", hashedCachedOnRequestAssets);
|
||||
swSource = replaceStringInSource("NOTIFICATION_BADGE_ICON", assets.resolve("icon.png"));
|
||||
|
||||
// service worker should not have a hashed name as it is polled by the browser for updates
|
||||
await assets.writeUnhashed("sw.js", swSource);
|
||||
}
|
||||
|
||||
async function buildCssBundles(buildFn, themes, assets, mainCssFile = null) {
|
||||
if (!mainCssFile) {
|
||||
mainCssFile = path.join(cssSrcDir, "main.css");
|
||||
}
|
||||
const bundleCss = await buildFn(mainCssFile);
|
||||
await assets.write(`hydrogen.css`, bundleCss);
|
||||
for (const theme of themes) {
|
||||
const themeRelPath = `themes/${theme}/`;
|
||||
const themeRoot = path.join(cssSrcDir, themeRelPath);
|
||||
const assetUrlMapper = ({absolutePath}) => {
|
||||
if (!absolutePath.startsWith(themeRoot)) {
|
||||
throw new Error("resource is out of theme directory: " + absolutePath);
|
||||
}
|
||||
const relPath = absolutePath.substr(themeRoot.length);
|
||||
const hashedDstPath = assets.resolve(path.join(themeRelPath, relPath));
|
||||
if (hashedDstPath) {
|
||||
return hashedDstPath.substr(themeRelPath.length);
|
||||
}
|
||||
};
|
||||
const themeCss = await buildFn(path.join(themeRoot, `theme.css`), assetUrlMapper);
|
||||
await assets.write(path.join(themeRelPath, `bundle.css`), themeCss);
|
||||
}
|
||||
}
|
||||
|
||||
// async function buildCss(entryPath, urlMapper = null) {
|
||||
// const preCss = await fs.readFile(entryPath, "utf8");
|
||||
// const options = [postcssImport];
|
||||
// if (urlMapper) {
|
||||
// options.push(postcssUrl({url: urlMapper}));
|
||||
// }
|
||||
// const cssBundler = postcss(options);
|
||||
// const result = await cssBundler.process(preCss, {from: entryPath});
|
||||
// return result.css;
|
||||
// }
|
||||
|
||||
async function buildCssLegacy(entryPath, urlMapper = null) {
|
||||
const preCss = await fs.readFile(entryPath, "utf8");
|
||||
const options = [
|
||||
postcssImport,
|
||||
cssvariables({
|
||||
preserve: (declaration) => {
|
||||
return declaration.value.indexOf("var(--ios-") == 0;
|
||||
}
|
||||
}),
|
||||
autoprefixer({overrideBrowserslist: ["IE 11"], grid: "no-autoplace"}),
|
||||
flexbugsFixes()
|
||||
];
|
||||
if (urlMapper) {
|
||||
options.push(postcssUrl({url: urlMapper}));
|
||||
}
|
||||
const cssBundler = postcss(options);
|
||||
const result = await cssBundler.process(preCss, {from: entryPath});
|
||||
return result.css;
|
||||
}
|
||||
|
||||
async function removeDirIfExists(targetDir) {
|
||||
try {
|
||||
await fs.rmdir(targetDir, {recursive: true});
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFolder(srcRoot, dstRoot, filter, assets = null) {
|
||||
assets = assets || new AssetMap(dstRoot);
|
||||
const dirEnts = await fs.readdir(srcRoot, {withFileTypes: true});
|
||||
for (const dirEnt of dirEnts) {
|
||||
const dstPath = path.join(dstRoot, dirEnt.name);
|
||||
const srcPath = path.join(srcRoot, dirEnt.name);
|
||||
if (dirEnt.isDirectory()) {
|
||||
await fs.mkdir(dstPath);
|
||||
await copyFolder(srcPath, dstPath, filter, assets);
|
||||
} else if ((dirEnt.isFile() || dirEnt.isSymbolicLink()) && (!filter || filter(srcPath))) {
|
||||
const content = await fs.readFile(srcPath);
|
||||
await assets.write(dstPath, content);
|
||||
}
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
function contentHash(str) {
|
||||
var hasher = new xxhash.h32(0);
|
||||
hasher.update(str);
|
||||
return hasher.digest();
|
||||
}
|
||||
|
||||
class AssetMap {
|
||||
constructor(targetDir) {
|
||||
// remove last / if any, so substr in create works well
|
||||
this._targetDir = path.resolve(targetDir);
|
||||
this._assets = new Map();
|
||||
// hashes for unhashed resources so changes in these resources also contribute to the hashForAll
|
||||
this._unhashedHashes = [];
|
||||
}
|
||||
|
||||
_toRelPath(resourcePath) {
|
||||
let relPath = resourcePath;
|
||||
if (path.isAbsolute(resourcePath)) {
|
||||
if (!resourcePath.startsWith(this._targetDir)) {
|
||||
throw new Error(`absolute path ${resourcePath} that is not within target dir ${this._targetDir}`);
|
||||
}
|
||||
relPath = resourcePath.substr(this._targetDir.length + 1); // + 1 for the /
|
||||
}
|
||||
return relPath;
|
||||
}
|
||||
|
||||
_create(resourcePath, content) {
|
||||
const relPath = this._toRelPath(resourcePath);
|
||||
const hash = contentHash(Buffer.from(content));
|
||||
const dir = path.dirname(relPath);
|
||||
const extname = path.extname(relPath);
|
||||
const basename = path.basename(relPath, extname);
|
||||
const dstRelPath = path.join(dir, `${basename}-${hash}${extname}`);
|
||||
this._assets.set(relPath, dstRelPath);
|
||||
return dstRelPath;
|
||||
}
|
||||
|
||||
async write(resourcePath, content) {
|
||||
const relPath = this._create(resourcePath, content);
|
||||
const fullPath = path.join(this.directory, relPath);
|
||||
if (typeof content === "string") {
|
||||
await fs.writeFile(fullPath, content, "utf8");
|
||||
} else {
|
||||
await fs.writeFile(fullPath, content);
|
||||
}
|
||||
return relPath;
|
||||
}
|
||||
|
||||
async writeUnhashed(resourcePath, content) {
|
||||
const relPath = this._toRelPath(resourcePath);
|
||||
this._assets.set(relPath, relPath);
|
||||
const fullPath = path.join(this.directory, relPath);
|
||||
if (typeof content === "string") {
|
||||
await fs.writeFile(fullPath, content, "utf8");
|
||||
} else {
|
||||
await fs.writeFile(fullPath, content);
|
||||
}
|
||||
return relPath;
|
||||
}
|
||||
|
||||
get directory() {
|
||||
return this._targetDir;
|
||||
}
|
||||
|
||||
resolve(resourcePath) {
|
||||
const relPath = this._toRelPath(resourcePath);
|
||||
const result = this._assets.get(relPath);
|
||||
if (!result) {
|
||||
throw new Error(`unknown path: ${relPath}, only know ${Array.from(this._assets.keys()).join(", ")}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
addSubMap(assetMap) {
|
||||
if (!assetMap.directory.startsWith(this.directory)) {
|
||||
throw new Error(`map directory doesn't start with this directory: ${assetMap.directory} ${this.directory}`);
|
||||
}
|
||||
const relSubRoot = assetMap.directory.substr(this.directory.length + 1);
|
||||
for (const [key, value] of assetMap._assets.entries()) {
|
||||
this._assets.set(path.join(relSubRoot, key), path.join(relSubRoot, value));
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this._assets.entries();
|
||||
}
|
||||
|
||||
isUnhashed(relPath) {
|
||||
const resolvedPath = this._assets.get(relPath);
|
||||
if (!resolvedPath) {
|
||||
throw new Error("Unknown asset: " + relPath);
|
||||
}
|
||||
return relPath === resolvedPath;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._assets.size;
|
||||
}
|
||||
|
||||
has(relPath) {
|
||||
return this._assets.has(relPath);
|
||||
}
|
||||
|
||||
hashForAll() {
|
||||
const globalHashAssets = Array.from(this).map(([, resolved]) => resolved);
|
||||
globalHashAssets.push(...this._unhashedHashes);
|
||||
globalHashAssets.sort();
|
||||
return contentHash(globalHashAssets.join(","));
|
||||
}
|
||||
|
||||
addToHashForAll(resourcePath, content) {
|
||||
this._unhashedHashes.push(`${resourcePath}-${contentHash(Buffer.from(content))}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readImportOverrides(filename) {
|
||||
const json = await fs.readFile(filename, "utf8");
|
||||
const mapping = new Map(Object.entries(JSON.parse(json)));
|
||||
return {
|
||||
basedir: path.dirname(path.resolve(filename))+path.sep,
|
||||
mapping
|
||||
};
|
||||
}
|
||||
|
||||
function overridesAsRollupPlugin(importOverrides) {
|
||||
const {mapping, basedir} = importOverrides;
|
||||
return {
|
||||
name: "rewrite-imports",
|
||||
resolveId (source, importer) {
|
||||
let file;
|
||||
if (source.startsWith(path.sep)) {
|
||||
file = source;
|
||||
} else {
|
||||
file = path.join(path.dirname(importer), source);
|
||||
}
|
||||
if (file.startsWith(basedir)) {
|
||||
const searchPath = file.substr(basedir.length);
|
||||
const replacingPath = mapping.get(searchPath);
|
||||
if (replacingPath) {
|
||||
console.info(`replacing ${searchPath} with ${replacingPath}`);
|
||||
return path.join(basedir, replacingPath);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
build(parameters).catch(err => console.error(err));
|
165
scripts/ci.sh
Executable file
165
scripts/ci.sh
Executable 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
3
scripts/cleanup.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
# Remove icons created in .tmp
|
||||
rm -rf .tmp
|
|
@ -1,12 +0,0 @@
|
|||
import fsRoot from "fs";
|
||||
const fs = fsRoot.promises;
|
||||
|
||||
export async function removeDirIfExists(targetDir) {
|
||||
try {
|
||||
await fs.rmdir(targetDir, {recursive: true});
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
git checkout gh-pages
|
||||
cp -R target/* .
|
||||
git add $(find . -maxdepth 1 -type f)
|
||||
git add themes
|
||||
git commit -m "update hydrogen"
|
||||
git checkout master
|
|
@ -1,6 +1,7 @@
|
|||
module.exports = class Buffer {
|
||||
static isBuffer(array) {return array instanceof Uint8Array;}
|
||||
static from(arrayBuffer) {return arrayBuffer;}
|
||||
static allocUnsafe(size) {return Buffer.alloc(size);}
|
||||
static alloc(size) {return new Uint8Array(size);}
|
||||
var Buffer = {
|
||||
isBuffer: function(array) {return array instanceof Uint8Array;},
|
||||
from: function(arrayBuffer) {return arrayBuffer;},
|
||||
allocUnsafe: function(size) {return Buffer.alloc(size);},
|
||||
alloc: function(size) {return new Uint8Array(size);}
|
||||
};
|
||||
export default Buffer;
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
// we have our own main file for this module as we need both these symbols to
|
||||
// be exported, and we also don't want to auto behaviour that modifies global vars
|
||||
exports.FDBFactory = require("fake-indexeddb/lib/FDBFactory.js");
|
||||
exports.FDBKeyRange = require("fake-indexeddb/lib/FDBKeyRange.js");
|
|
@ -1 +1,2 @@
|
|||
module.exports.Buffer = require("buffer");
|
||||
import Buffer from "buffer";
|
||||
export {Buffer};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,132 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
const fsRoot = require("fs");
|
||||
const fs = fsRoot.promises;
|
||||
const path = require("path");
|
||||
const { rollup } = require('rollup');
|
||||
const { fileURLToPath } = require('url');
|
||||
const { dirname } = require('path');
|
||||
// needed to translate commonjs modules to esm
|
||||
const commonjs = require('@rollup/plugin-commonjs');
|
||||
const json = require('@rollup/plugin-json');
|
||||
const { nodeResolve } = require('@rollup/plugin-node-resolve');
|
||||
|
||||
const projectDir = path.join(__dirname, "../");
|
||||
|
||||
async function removeDirIfExists(targetDir) {
|
||||
try {
|
||||
await fs.rmdir(targetDir, {recursive: true});
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** function used to resolve common-js require calls below. */
|
||||
function packageIterator(request, start, defaultIterator) {
|
||||
// this is just working for bs58, would need to tune it further for other dependencies
|
||||
if (request === "safe-buffer") {
|
||||
return [path.join(projectDir, "/scripts/package-overrides/safe-buffer")];
|
||||
} else if (request === "buffer/") {
|
||||
return [path.join(projectDir, "/scripts/package-overrides/buffer")];
|
||||
} else {
|
||||
return defaultIterator();
|
||||
}
|
||||
}
|
||||
|
||||
async function commonjsToESM(src, dst) {
|
||||
// create js bundle
|
||||
const bundle = await rollup({
|
||||
treeshake: {moduleSideEffects: false},
|
||||
input: src,
|
||||
plugins: [commonjs(), json(), nodeResolve({
|
||||
browser: true,
|
||||
preferBuiltins: false,
|
||||
customResolveOptions: {packageIterator}
|
||||
})]
|
||||
});
|
||||
const {output} = await bundle.generate({
|
||||
format: 'es'
|
||||
});
|
||||
const code = output[0].code;
|
||||
await fs.writeFile(dst, code, "utf8");
|
||||
}
|
||||
|
||||
async function populateLib() {
|
||||
const libDir = path.join(projectDir, "lib/");
|
||||
await removeDirIfExists(libDir);
|
||||
await fs.mkdir(libDir);
|
||||
const olmSrcDir = path.dirname(require.resolve("@matrix-org/olm"));
|
||||
const olmDstDir = path.join(libDir, "olm/");
|
||||
await fs.mkdir(olmDstDir);
|
||||
for (const file of ["olm.js", "olm.wasm", "olm_legacy.js"]) {
|
||||
await fs.copyFile(path.join(olmSrcDir, file), path.join(olmDstDir, file));
|
||||
}
|
||||
// transpile node-html-parser to esm
|
||||
await fs.mkdir(path.join(libDir, "node-html-parser/"));
|
||||
await commonjsToESM(
|
||||
require.resolve('node-html-parser/dist/index.js'),
|
||||
path.join(libDir, "node-html-parser/index.js")
|
||||
);
|
||||
// transpile another-json to esm
|
||||
await fs.mkdir(path.join(libDir, "another-json/"));
|
||||
await commonjsToESM(
|
||||
require.resolve('another-json/another-json.js'),
|
||||
path.join(libDir, "another-json/index.js")
|
||||
);
|
||||
// transpile bs58 to esm
|
||||
await fs.mkdir(path.join(libDir, "bs58/"));
|
||||
await commonjsToESM(
|
||||
require.resolve('bs58/index.js'),
|
||||
path.join(libDir, "bs58/index.js")
|
||||
);
|
||||
// transpile base64-arraybuffer to esm
|
||||
await fs.mkdir(path.join(libDir, "base64-arraybuffer/"));
|
||||
await commonjsToESM(
|
||||
require.resolve('base64-arraybuffer/lib/base64-arraybuffer.js'),
|
||||
path.join(libDir, "base64-arraybuffer/index.js")
|
||||
);
|
||||
// this probably should no go in here, we can just import "aes-js" from legacy-extras.js
|
||||
// as that file is never loaded from a browser
|
||||
|
||||
// transpile aesjs to esm
|
||||
await fs.mkdir(path.join(libDir, "aes-js/"));
|
||||
await commonjsToESM(
|
||||
require.resolve('aes-js/index.js'),
|
||||
path.join(libDir, "aes-js/index.js")
|
||||
);
|
||||
// es6-promise is already written as an es module,
|
||||
// but it does need to be babelified, and current we don't babelify
|
||||
// anything in node_modules in the build script, so make a bundle that
|
||||
// is conveniently not placed in node_modules rather than symlinking.
|
||||
await fs.mkdir(path.join(libDir, "es6-promise/"));
|
||||
await commonjsToESM(
|
||||
require.resolve('es6-promise/lib/es6-promise/promise.js'),
|
||||
path.join(libDir, "es6-promise/index.js")
|
||||
);
|
||||
// fake-indexeddb, used for tests (but unresolvable bare imports also makes the build complain)
|
||||
// and might want to use it for in-memory storage too, although we probably do ts->es6 with esm
|
||||
// directly rather than ts->es5->es6 as we do now. The bundle is 240K currently.
|
||||
await fs.mkdir(path.join(libDir, "fake-indexeddb/"));
|
||||
await commonjsToESM(
|
||||
path.join(projectDir, "/scripts/package-overrides/fake-indexeddb.js"),
|
||||
path.join(libDir, "fake-indexeddb/index.js")
|
||||
);
|
||||
}
|
||||
|
||||
populateLib();
|
180
scripts/postcss/css-compile-variables.js
Normal file
180
scripts/postcss/css-compile-variables.js
Normal file
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
const valueParser = require("postcss-value-parser");
|
||||
|
||||
/**
|
||||
* This plugin derives new css variables from a given set of base variables.
|
||||
* A derived css variable has the form --base--operation-argument; meaning that the derived
|
||||
* variable has a value that is generated from the base variable "base" by applying "operation"
|
||||
* with given "argument".
|
||||
*
|
||||
* eg: given the base variable --foo-color: #40E0D0, --foo-color--darker-20 is a css variable
|
||||
* derived from foo-color by making it 20% more darker.
|
||||
*
|
||||
* All derived variables are added to the :root section.
|
||||
*
|
||||
* The actual derivation is done outside the plugin in a callback.
|
||||
*/
|
||||
|
||||
function getValueFromAlias(alias, {aliasMap, baseVariables, resolvedMap}) {
|
||||
const derivedVariable = aliasMap.get(alias);
|
||||
return baseVariables.get(derivedVariable) ?? resolvedMap.get(derivedVariable);
|
||||
}
|
||||
|
||||
function parseDeclarationValue(value) {
|
||||
const parsed = valueParser(value);
|
||||
const variables = [];
|
||||
parsed.walk(node => {
|
||||
if (node.type !== "function") {
|
||||
return;
|
||||
}
|
||||
switch (node.value) {
|
||||
case "var": {
|
||||
const variable = node.nodes[0];
|
||||
variables.push(variable.value);
|
||||
break;
|
||||
}
|
||||
case "url": {
|
||||
const url = node.nodes[0].value;
|
||||
// resolve url with some absolute url so that we get the query params without using regex
|
||||
const params = new URL(url, "file://foo/bar/").searchParams;
|
||||
const primary = params.get("primary");
|
||||
const secondary = params.get("secondary");
|
||||
if (primary) { variables.push(primary); }
|
||||
if (secondary) { variables.push(secondary); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return variables;
|
||||
}
|
||||
|
||||
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, maps);
|
||||
if (!value) {
|
||||
throw new Error(`Cannot derive from ${baseVariable} because it is neither defined in config nor is it an alias!`);
|
||||
}
|
||||
const derivedValue = derive(value, operation, argument, isDark);
|
||||
resolvedMap.set(wholeVariable, derivedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extract(decl, {aliasMap, baseVariables}) {
|
||||
if (decl.variable) {
|
||||
// see if right side is of form "var(--foo)"
|
||||
const wholeVariable = decl.value.match(/var\(--(.+)\)/)?.[1];
|
||||
// remove -- from the prop
|
||||
const prop = decl.prop.substring(2);
|
||||
if (wholeVariable) {
|
||||
aliasMap.set(prop, wholeVariable);
|
||||
// Since this is an alias, we shouldn't store it in baseVariables
|
||||
return;
|
||||
}
|
||||
baseVariables.set(prop, decl.value);
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
const declaration = new Declaration({prop: `--${key}`, value});
|
||||
newRule.append(declaration);
|
||||
});
|
||||
root.append(newRule);
|
||||
}
|
||||
|
||||
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}`))
|
||||
];
|
||||
const sharedObject = map.get(location);
|
||||
const output = { "derived-variables": derivedVariables };
|
||||
if (sharedObject) {
|
||||
Object.assign(sharedObject, output);
|
||||
}
|
||||
else {
|
||||
map.set(location, output);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @callback derive
|
||||
* @param {string} value - The base value on which an operation is applied
|
||||
* @param {string} operation - The operation to be applied (eg: darker, lighter...)
|
||||
* @param {string} argument - The argument for this operation
|
||||
* @param {boolean} isDark - Indicates whether this theme is dark
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param {Object} opts - Options for the plugin
|
||||
* @param {derive} opts.derive - The callback which contains the logic for resolving derived variables
|
||||
* @param {Map} opts.compiledVariables - A map that stores derived variables so that manifest source sections can be produced
|
||||
*/
|
||||
module.exports = (opts = {}) => {
|
||||
const aliasMap = new Map();
|
||||
const resolvedMap = new Map();
|
||||
const baseVariables = new Map();
|
||||
const maps = { aliasMap, resolvedMap, baseVariables };
|
||||
|
||||
return {
|
||||
postcssPlugin: "postcss-compile-variables",
|
||||
|
||||
Once(root, {Rule, Declaration, result}) {
|
||||
const cssFileLocation = root.source.input.from;
|
||||
if (cssFileLocation.includes("type=runtime")) {
|
||||
// If this is a runtime theme, don't derive variables.
|
||||
return;
|
||||
}
|
||||
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, maps));
|
||||
root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive, maps, isDark));
|
||||
addResolvedVariablesToRootSelector(root, {Rule, Declaration}, maps);
|
||||
if (opts.compiledVariables){
|
||||
populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation, maps);
|
||||
}
|
||||
// Also produce a mapping from alias to completely resolved color
|
||||
const resolvedAliasMap = new Map();
|
||||
aliasMap.forEach((value, key) => {
|
||||
resolvedAliasMap.set(key, resolvedMap.get(value));
|
||||
});
|
||||
// Publish the base-variables, derived-variables and resolved aliases to the other postcss-plugins
|
||||
const combinedMap = new Map([...baseVariables, ...resolvedMap, ...resolvedAliasMap]);
|
||||
result.messages.push({
|
||||
type: "resolved-variable-map",
|
||||
plugin: "postcss-compile-variables",
|
||||
colorMap: combinedMap,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
module.exports.postcss = true;
|
92
scripts/postcss/css-url-processor.js
Normal file
92
scripts/postcss/css-url-processor.js
Normal file
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
const valueParser = require("postcss-value-parser");
|
||||
const resolve = require("path").resolve;
|
||||
|
||||
function colorsFromURL(url, colorMap) {
|
||||
const params = new URL(`file://${url}`).searchParams;
|
||||
const primary = params.get("primary");
|
||||
if (!primary) {
|
||||
return null;
|
||||
}
|
||||
const secondary = params.get("secondary");
|
||||
const primaryColor = colorMap.get(primary);
|
||||
const secondaryColor = colorMap.get(secondary);
|
||||
if (!primaryColor) {
|
||||
throw new Error(`Variable ${primary} not found in resolved color variables!`);
|
||||
}
|
||||
if (secondary && !secondaryColor) {
|
||||
throw new Error(`Variable ${secondary} not found in resolved color variables!`);
|
||||
}
|
||||
return [primaryColor, secondaryColor];
|
||||
}
|
||||
|
||||
function processURL(decl, replacer, colorMap, cssPath) {
|
||||
const value = decl.value;
|
||||
const parsed = valueParser(value);
|
||||
parsed.walk(node => {
|
||||
if (node.type !== "function" || node.value !== "url") {
|
||||
return;
|
||||
}
|
||||
const urlStringNode = node.nodes[0];
|
||||
const oldURL = urlStringNode.value;
|
||||
const oldURLAbsolute = resolve(cssPath, oldURL);
|
||||
const colors = colorsFromURL(oldURLAbsolute, colorMap);
|
||||
if (!colors) {
|
||||
// If no primary color is provided via url params, then this url need not be handled.
|
||||
return;
|
||||
}
|
||||
const newURL = replacer(oldURLAbsolute.replace(/\?.+/, ""), ...colors);
|
||||
if (!newURL) {
|
||||
throw new Error("Replacer failed to produce a replacement URL!");
|
||||
}
|
||||
urlStringNode.value = newURL;
|
||||
});
|
||||
decl.assign({prop: decl.prop, value: parsed.toString()})
|
||||
}
|
||||
|
||||
/* *
|
||||
* @type {import('postcss').PluginCreator}
|
||||
*/
|
||||
module.exports = (opts = {}) => {
|
||||
return {
|
||||
postcssPlugin: "postcss-url-to-variable",
|
||||
|
||||
Once(root, {result}) {
|
||||
const cssFileLocation = root.source.input.from;
|
||||
if (cssFileLocation.includes("type=runtime")) {
|
||||
// If this is a runtime theme, don't process urls.
|
||||
return;
|
||||
}
|
||||
/*
|
||||
postcss-compile-variables should have sent the list of resolved colours down via results
|
||||
*/
|
||||
const {colorMap} = result.messages.find(m => m.type === "resolved-variable-map");
|
||||
if (!colorMap) {
|
||||
throw new Error("Postcss results do not contain resolved colors!");
|
||||
}
|
||||
/*
|
||||
Go through each declaration and if it contains an URL, replace the url with the result
|
||||
of running replacer(url)
|
||||
*/
|
||||
const cssPath = root.source?.input.file.replace(/[^/]*$/, "");
|
||||
root.walkDecls(decl => processURL(decl, opts.replacer, colorMap, cssPath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
module.exports.postcss = true;
|
97
scripts/postcss/css-url-to-variables.js
Normal file
97
scripts/postcss/css-url-to-variables.js
Normal file
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
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.
|
||||
*/
|
||||
const idToPrepend = "icon-url";
|
||||
|
||||
function findAndReplaceUrl(decl, urlVariables, counter) {
|
||||
const value = decl.value;
|
||||
const parsed = valueParser(value);
|
||||
parsed.walk(node => {
|
||||
if (node.type !== "function" || node.value !== "url") {
|
||||
return;
|
||||
}
|
||||
const url = node.nodes[0].value;
|
||||
if (!url.match(/\.svg\?primary=.+/)) {
|
||||
return;
|
||||
}
|
||||
const count = counter.next().value;
|
||||
const variableName = `${idToPrepend}-${count}`;
|
||||
urlVariables.set(variableName, url);
|
||||
node.value = "var";
|
||||
node.nodes = [{ type: "word", value: `--${variableName}` }];
|
||||
});
|
||||
decl.assign({prop: decl.prop, value: parsed.toString()})
|
||||
}
|
||||
|
||||
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) => {
|
||||
const declaration = new Declaration({ prop: `--${key}`, value: `url("${value}")`});
|
||||
newRule.append(declaration);
|
||||
});
|
||||
root.append(newRule);
|
||||
}
|
||||
|
||||
function populateMapWithIcons(map, cssFileLocation, urlVariables) {
|
||||
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
|
||||
const sharedObject = map.get(location);
|
||||
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 = {}) => {
|
||||
return {
|
||||
postcssPlugin: "postcss-url-to-variable",
|
||||
|
||||
Once(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, urlVariables);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
module.exports.postcss = true;
|
||||
|
51
scripts/postcss/svg-builder.mjs
Normal file
51
scripts/postcss/svg-builder.mjs
Normal file
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {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 h32(0);
|
||||
hasher.update(content);
|
||||
return hasher.digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new svg with the colors replaced and returns its location.
|
||||
* @param {string} svgLocation The location of the input svg file
|
||||
* @param {string} primaryColor Primary color for the new svg
|
||||
* @param {string} secondaryColor Secondary color for the new svg
|
||||
*/
|
||||
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 = resolve(__dirname, "./.tmp");
|
||||
try {
|
||||
mkdirSync(outputPath);
|
||||
}
|
||||
catch (e) {
|
||||
if (e.code !== "EEXIST") {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const outputFile = `${outputPath}/${outputName}`;
|
||||
writeFileSync(outputFile, coloredSVGCode);
|
||||
return outputFile;
|
||||
}
|
|
@ -14,16 +14,17 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {LoginMethod} from "./LoginMethod.js";
|
||||
import {makeTxnId} from "../common.js";
|
||||
const postcss = require("postcss");
|
||||
|
||||
export class TokenLoginMethod extends LoginMethod {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._loginToken = options.loginToken;
|
||||
}
|
||||
|
||||
async login(hsApi, deviceName, log) {
|
||||
return await hsApi.tokenLogin(this._loginToken, makeTxnId(), deviceName, {log}).response();
|
||||
}
|
||||
module.exports.createTestRunner = function (plugin) {
|
||||
return async function run(input, output, opts = {}, assert) {
|
||||
let result = await postcss([plugin(opts)]).process(input, { from: undefined, });
|
||||
assert.strictEqual(
|
||||
result.css.replaceAll(/\s/g, ""),
|
||||
output.replaceAll(/\s/g, "")
|
||||
);
|
||||
assert.strictEqual(result.warnings().length, 0);
|
||||
};
|
||||
}
|
||||
|
||||
|
156
scripts/postcss/tests/css-compile-variables.test.js
Normal file
156
scripts/postcss/tests/css-compile-variables.test.js
Normal file
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
const offColor = require("off-color").offColor;
|
||||
const postcss = require("postcss");
|
||||
const plugin = require("../css-compile-variables");
|
||||
const derive = require("../color").derive;
|
||||
const run = require("./common").createTestRunner(plugin);
|
||||
|
||||
module.exports.tests = function tests() {
|
||||
return {
|
||||
"derived variables are resolved": async (assert) => {
|
||||
const inputCSS = `
|
||||
:root {
|
||||
--foo-color: #ff0;
|
||||
}
|
||||
div {
|
||||
background-color: var(--foo-color--lighter-50);
|
||||
}`;
|
||||
const transformedColor = offColor("#ff0").lighten(0.5);
|
||||
const outputCSS =
|
||||
inputCSS +
|
||||
`
|
||||
:root {
|
||||
--foo-color--lighter-50: ${transformedColor.hex()};
|
||||
}
|
||||
`;
|
||||
await run( inputCSS, outputCSS, {derive}, assert);
|
||||
},
|
||||
|
||||
"derived variables work with alias": async (assert) => {
|
||||
const inputCSS = `
|
||||
:root {
|
||||
--icon-color: #fff;
|
||||
}
|
||||
div {
|
||||
background: var(--icon-color--darker-20);
|
||||
--my-alias: var(--icon-color--darker-20);
|
||||
color: var(--my-alias--lighter-15);
|
||||
}`;
|
||||
const colorDarker = offColor("#fff").darken(0.2).hex();
|
||||
const aliasLighter = offColor(colorDarker).lighten(0.15).hex();
|
||||
const outputCSS = inputCSS + `:root {
|
||||
--icon-color--darker-20: ${colorDarker};
|
||||
--my-alias--lighter-15: ${aliasLighter};
|
||||
}
|
||||
`;
|
||||
await run(inputCSS, outputCSS, {derive}, assert);
|
||||
},
|
||||
|
||||
"derived variable throws if base not present in config": async (assert) => {
|
||||
const css = `:root {
|
||||
color: var(--icon-color--darker-20);
|
||||
}`;
|
||||
assert.rejects(async () => await postcss([plugin({ variables: {} })]).process(css, { from: undefined, }));
|
||||
},
|
||||
|
||||
"multiple derived variable in single declaration is parsed correctly": async (assert) => {
|
||||
const inputCSS = `
|
||||
:root {
|
||||
--foo-color: #ff0;
|
||||
}
|
||||
div {
|
||||
background-color: linear-gradient(var(--foo-color--lighter-50), var(--foo-color--darker-20));
|
||||
}`;
|
||||
const transformedColor1 = offColor("#ff0").lighten(0.5);
|
||||
const transformedColor2 = offColor("#ff0").darken(0.2);
|
||||
const outputCSS =
|
||||
inputCSS +
|
||||
`
|
||||
:root {
|
||||
--foo-color--lighter-50: ${transformedColor1.hex()};
|
||||
--foo-color--darker-20: ${transformedColor2.hex()};
|
||||
}
|
||||
`;
|
||||
await run( inputCSS, outputCSS, {derive}, assert);
|
||||
},
|
||||
|
||||
"multiple aliased-derived variable in single declaration is parsed correctly": async (assert) => {
|
||||
const inputCSS = `
|
||||
:root {
|
||||
--foo-color: #ff0;
|
||||
}
|
||||
div {
|
||||
--my-alias: var(--foo-color);
|
||||
background-color: linear-gradient(var(--my-alias--lighter-50), var(--my-alias--darker-20));
|
||||
}`;
|
||||
const transformedColor1 = offColor("#ff0").lighten(0.5);
|
||||
const transformedColor2 = offColor("#ff0").darken(0.2);
|
||||
const outputCSS =
|
||||
inputCSS +
|
||||
`
|
||||
:root {
|
||||
--my-alias--lighter-50: ${transformedColor1.hex()};
|
||||
--my-alias--darker-20: ${transformedColor2.hex()};
|
||||
}
|
||||
`;
|
||||
await run( inputCSS, outputCSS, {derive}, assert);
|
||||
},
|
||||
|
||||
"compiledVariables map is populated": async (assert) => {
|
||||
const compiledVariables = new Map();
|
||||
const inputCSS = `
|
||||
:root {
|
||||
--icon-color: #fff;
|
||||
}
|
||||
div {
|
||||
background: var(--icon-color--darker-20);
|
||||
--my-alias: var(--icon-color--darker-20);
|
||||
color: var(--my-alias--lighter-15);
|
||||
}`;
|
||||
await postcss([plugin({ derive, compiledVariables })]).process(inputCSS, { from: "/foo/bar/test.css", });
|
||||
const actualArray = compiledVariables.get("/foo/bar")["derived-variables"];
|
||||
const expectedArray = ["icon-color--darker-20", "my-alias=icon-color--darker-20", "my-alias--lighter-15"];
|
||||
assert.deepStrictEqual(actualArray.sort(), expectedArray.sort());
|
||||
},
|
||||
|
||||
"derived variable are supported in urls": async (assert) => {
|
||||
const inputCSS = `
|
||||
:root {
|
||||
--foo-color: #ff0;
|
||||
}
|
||||
div {
|
||||
background-color: var(--foo-color--lighter-50);
|
||||
background: url("./foo/bar/icon.svg?primary=foo-color--darker-5");
|
||||
}
|
||||
a {
|
||||
background: url("foo/bar/icon.svg");
|
||||
}`;
|
||||
const transformedColorLighter = offColor("#ff0").lighten(0.5);
|
||||
const transformedColorDarker = offColor("#ff0").darken(0.05);
|
||||
const outputCSS =
|
||||
inputCSS +
|
||||
`
|
||||
:root {
|
||||
--foo-color--lighter-50: ${transformedColorLighter.hex()};
|
||||
--foo-color--darker-5: ${transformedColorDarker.hex()};
|
||||
}
|
||||
`;
|
||||
await run( inputCSS, outputCSS, {derive}, assert);
|
||||
}
|
||||
};
|
||||
};
|
71
scripts/postcss/tests/css-url-to-variables.test.js
Normal file
71
scripts/postcss/tests/css-url-to-variables.test.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
const plugin = require("../css-url-to-variables");
|
||||
const run = require("./common").createTestRunner(plugin);
|
||||
const postcss = require("postcss");
|
||||
|
||||
module.exports.tests = function tests() {
|
||||
return {
|
||||
"url is replaced with variable": async (assert) => {
|
||||
const inputCSS = `div {
|
||||
background: no-repeat center/80% url("../img/image.svg?primary=main-color--darker-20");
|
||||
}
|
||||
button {
|
||||
background: url("/home/foo/bar/cool.svg?primary=blue&secondary=green");
|
||||
}`;
|
||||
const outputCSS =
|
||||
`div {
|
||||
background: no-repeat center/80% var(--icon-url-0);
|
||||
}
|
||||
button {
|
||||
background: var(--icon-url-1);
|
||||
}`+
|
||||
`
|
||||
:root {
|
||||
--icon-url-0: url("../img/image.svg?primary=main-color--darker-20");
|
||||
--icon-url-1: url("/home/foo/bar/cool.svg?primary=blue&secondary=green");
|
||||
}
|
||||
`;
|
||||
await run(inputCSS, outputCSS, { }, assert);
|
||||
},
|
||||
"non svg urls without query params are not replaced": async (assert) => {
|
||||
const inputCSS = `div {
|
||||
background: no-repeat url("./img/foo/bar/image.png");
|
||||
}`;
|
||||
await run(inputCSS, inputCSS, {}, assert);
|
||||
},
|
||||
"map is populated with icons": async (assert) => {
|
||||
const compiledVariables = new Map();
|
||||
compiledVariables.set("/foo/bar", { "derived-variables": ["background-color--darker-20", "accent-color--lighter-15"] });
|
||||
const inputCSS = `div {
|
||||
background: no-repeat center/80% url("../img/image.svg?primary=main-color--darker-20");
|
||||
}
|
||||
button {
|
||||
background: url("/home/foo/bar/cool.svg?primary=blue&secondary=green");
|
||||
}`;
|
||||
const expectedObject = {
|
||||
"icon-url-0": "../img/image.svg?primary=main-color--darker-20",
|
||||
"icon-url-1": "/home/foo/bar/cool.svg?primary=blue&secondary=green",
|
||||
};
|
||||
await postcss([plugin({compiledVariables})]).process(inputCSS, { from: "/foo/bar/test.css", });
|
||||
const sharedVariable = compiledVariables.get("/foo/bar");
|
||||
assert.deepEqual(["background-color--darker-20", "accent-color--lighter-15"], sharedVariable["derived-variables"]);
|
||||
assert.deepEqual(expectedObject, sharedVariable["icon"]);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
set -e
|
||||
if [ -z "$1" ]; then
|
||||
echo "provide a new version, current version is $(jq '.version' package.json)"
|
||||
exit 1
|
||||
|
|
19
scripts/sdk/base-manifest.json
Normal file
19
scripts/sdk/base-manifest.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "hydrogen-view-sdk",
|
||||
"description": "Embeddable matrix client library, including view components",
|
||||
"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/*"
|
||||
}
|
||||
}
|
25
scripts/sdk/build.sh
Executable file
25
scripts/sdk/build.sh
Executable file
|
@ -0,0 +1,25 @@
|
|||
#!/bin/bash
|
||||
# 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
|
||||
|
||||
# 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
|
||||
./scripts/sdk/create-manifest.js ./target/package.json
|
||||
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/asset-build
|
||||
rm index.html
|
||||
popd
|
||||
pushd target/asset-build/assets
|
||||
# Remove all `*.wasm` and `*.js` files except for `main.js`
|
||||
rm !(main).js *.wasm
|
||||
popd
|
23
scripts/sdk/create-manifest.js
Executable file
23
scripts/sdk/create-manifest.js
Executable file
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const appManifest = require("../../package.json");
|
||||
const baseSDKManifest = require("./base-manifest.json");
|
||||
/*
|
||||
Need to leave typescript type definitions out until the
|
||||
typescript conversion is complete and all imports in the d.ts files
|
||||
exists.
|
||||
```
|
||||
"types": "types/lib.d.ts"
|
||||
```
|
||||
*/
|
||||
const mergeOptions = require('merge-options');
|
||||
|
||||
const manifestExtension = {
|
||||
devDependencies: undefined,
|
||||
scripts: undefined,
|
||||
};
|
||||
|
||||
const manifest = mergeOptions(appManifest, baseSDKManifest, manifestExtension);
|
||||
const json = JSON.stringify(manifest, undefined, 2);
|
||||
const outFile = process.argv[2];
|
||||
fs.writeFileSync(outFile, json, {encoding: "utf8"});
|
3
scripts/sdk/test/.gitignore
vendored
Normal file
3
scripts/sdk/test/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
dist
|
||||
yarn.lock
|
2
scripts/sdk/test/deps.d.ts
vendored
Normal file
2
scripts/sdk/test/deps.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
// Keep TypeScripts from complaining about hydrogen-view-sdk not having types yet
|
||||
declare module "hydrogen-view-sdk";
|
21
scripts/sdk/test/esm-entry.ts
Normal file
21
scripts/sdk/test/esm-entry.ts
Normal 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 ✅');
|
12
scripts/sdk/test/index.html
Normal file
12
scripts/sdk/test/index.html
Normal 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>
|
8
scripts/sdk/test/package.json
Normal file
8
scripts/sdk/test/package.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "test-sdk",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"hydrogen-view-sdk": "link:../../../target"
|
||||
}
|
||||
}
|
13
scripts/sdk/test/test-sdk-in-commonjs-env.js
Normal file
13
scripts/sdk/test/test-sdk-in-commonjs-env.js
Normal 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 ✅');
|
19
scripts/sdk/test/test-sdk-in-esm-vite-build-env.js
Normal file
19
scripts/sdk/test/test-sdk-in-esm-vite-build-env.js
Normal 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();
|
36
scripts/sdk/transform-paths.js
Executable file
36
scripts/sdk/transform-paths.js
Executable file
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
This script transforms the string literals in the sdk path files to adjust paths
|
||||
from what they are at development time to what they will be in the sdk package.
|
||||
|
||||
It does this by looking in all string literals in the paths file and looking for file names
|
||||
that we expect and need replacing (as they are bundled with the sdk).
|
||||
|
||||
Usage: ./transform-paths.js <input file> <output file>
|
||||
*/
|
||||
|
||||
const acorn = require("acorn");
|
||||
const walk = require("acorn-walk")
|
||||
const escodegen = require("escodegen");
|
||||
const fs = require("fs");
|
||||
|
||||
const code = fs.readFileSync(process.argv[2], {encoding: "utf8"});
|
||||
const ast = acorn.parse(code, {ecmaVersion: "13", sourceType: "module"});
|
||||
|
||||
function changePrefix(value, file, newPrefix = "") {
|
||||
const idx = value.indexOf(file);
|
||||
if (idx !== -1) {
|
||||
return newPrefix + value.substr(idx);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
walk.simple(ast, {
|
||||
Literal(node) {
|
||||
node.value = changePrefix(node.value, "download-sandbox.html", "../");
|
||||
node.value = changePrefix(node.value, "main.js", "../");
|
||||
}
|
||||
});
|
||||
const transformedCode = escodegen.generate(ast);
|
||||
fs.writeFileSync(process.argv[3], transformedCode, {encoding: "utf8"})
|
|
@ -1,43 +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.
|
||||
*/
|
||||
|
||||
const finalhandler = require('finalhandler')
|
||||
const http = require('http')
|
||||
const serveStatic = require('serve-static')
|
||||
const path = require('path');
|
||||
|
||||
// Serve up parent directory with cache disabled
|
||||
const serve = serveStatic(
|
||||
path.resolve(__dirname, "../"),
|
||||
{
|
||||
etag: false,
|
||||
setHeaders: res => {
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.setHeader("Expires", "Wed, 21 Oct 2015 07:28:00 GMT");
|
||||
},
|
||||
index: ['index.html', 'index.htm']
|
||||
}
|
||||
);
|
||||
|
||||
// Create server
|
||||
const server = http.createServer(function onRequest (req, res) {
|
||||
console.log(req.method, req.url);
|
||||
serve(req, res, finalhandler(req, res))
|
||||
});
|
||||
|
||||
// Listen
|
||||
server.listen(3000);
|
5
scripts/test-derived-theme/test-theme.sh
Executable file
5
scripts/test-derived-theme/test-theme.sh
Executable 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
|
51
scripts/test-derived-theme/theme.json
Normal file
51
scripts/test-derived-theme/theme.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
// Snowpack Configuration File
|
||||
// See all supported options: https://www.snowpack.dev/reference/configuration
|
||||
|
||||
/** @type {import("snowpack").SnowpackUserConfig } */
|
||||
module.exports = {
|
||||
mount: {
|
||||
// More specific paths before less specific paths (if they overlap)
|
||||
"src/platform/web/docroot": "/",
|
||||
"src": "/src",
|
||||
"lib": {url: "/lib", static: true },
|
||||
"assets": "/assets",
|
||||
/* ... */
|
||||
},
|
||||
exclude: [
|
||||
/* Avoid scanning scripts which use dev-dependencies and pull in babel, rollup, etc. */
|
||||
'**/node_modules/**/*',
|
||||
'**/scripts/**',
|
||||
'**/target/**',
|
||||
'**/prototypes/**',
|
||||
'**/src/platform/web/legacy-polyfill.js',
|
||||
'**/src/platform/web/worker/polyfill.js'
|
||||
],
|
||||
plugins: [
|
||||
/* ... */
|
||||
],
|
||||
packageOptions: {
|
||||
/* ... */
|
||||
},
|
||||
devOptions: {
|
||||
open: "none",
|
||||
hmr: false,
|
||||
/* ... */
|
||||
},
|
||||
buildOptions: {
|
||||
/* ... */
|
||||
},
|
||||
};
|
136
src/domain/AccountSetupViewModel.js
Normal file
136
src/domain/AccountSetupViewModel.js
Normal file
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "./ViewModel";
|
||||
import {KeyType} from "../matrix/ssss/index";
|
||||
import {Status} from "./session/settings/KeyBackupViewModel.js";
|
||||
|
||||
export class AccountSetupViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._accountSetup = options.accountSetup;
|
||||
this._dehydratedDevice = undefined;
|
||||
this._decryptDehydratedDeviceViewModel = undefined;
|
||||
if (this._accountSetup.encryptedDehydratedDevice) {
|
||||
this._decryptDehydratedDeviceViewModel = new DecryptDehydratedDeviceViewModel(this, dehydratedDevice => {
|
||||
this._dehydratedDevice = dehydratedDevice;
|
||||
this._decryptDehydratedDeviceViewModel = undefined;
|
||||
this.emitChange("deviceDecrypted");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get decryptDehydratedDeviceViewModel() {
|
||||
return this._decryptDehydratedDeviceViewModel;
|
||||
}
|
||||
|
||||
get deviceDecrypted() {
|
||||
return !!this._dehydratedDevice;
|
||||
}
|
||||
|
||||
get dehydratedDeviceId() {
|
||||
return this._accountSetup.encryptedDehydratedDevice.deviceId;
|
||||
}
|
||||
|
||||
finish() {
|
||||
this._accountSetup.finish(this._dehydratedDevice);
|
||||
}
|
||||
}
|
||||
|
||||
// this vm adopts the same shape as KeyBackupViewModel so the same view can be reused.
|
||||
class DecryptDehydratedDeviceViewModel extends ViewModel {
|
||||
constructor(accountSetupViewModel, decryptedCallback) {
|
||||
super(accountSetupViewModel.options);
|
||||
this._accountSetupViewModel = accountSetupViewModel;
|
||||
this._isBusy = false;
|
||||
this._status = Status.SetupKey;
|
||||
this._error = undefined;
|
||||
this._decryptedCallback = decryptedCallback;
|
||||
}
|
||||
|
||||
get decryptAction() {
|
||||
return this.i18n`Restore`;
|
||||
}
|
||||
|
||||
get purpose() {
|
||||
return this.i18n`claim your dehydrated device`;
|
||||
}
|
||||
|
||||
get offerDehydratedDeviceSetup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
get dehydratedDeviceId() {
|
||||
return this._accountSetupViewModel._dehydratedDevice?.deviceId;
|
||||
}
|
||||
|
||||
get isBusy() {
|
||||
return this._isBusy;
|
||||
}
|
||||
|
||||
get backupVersion() { return 0; }
|
||||
|
||||
get status() {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this._error?.message;
|
||||
}
|
||||
|
||||
showPhraseSetup() {
|
||||
if (this._status === Status.SetupKey) {
|
||||
this._status = Status.SetupPhrase;
|
||||
this.emitChange("status");
|
||||
}
|
||||
}
|
||||
|
||||
showKeySetup() {
|
||||
if (this._status === Status.SetupPhrase) {
|
||||
this._status = Status.SetupKey;
|
||||
this.emitChange("status");
|
||||
}
|
||||
}
|
||||
|
||||
async _enterCredentials(keyType, credential) {
|
||||
if (credential) {
|
||||
try {
|
||||
this._isBusy = true;
|
||||
this.emitChange("isBusy");
|
||||
const {encryptedDehydratedDevice} = this._accountSetupViewModel._accountSetup;
|
||||
const dehydratedDevice = await encryptedDehydratedDevice.decrypt(keyType, credential);
|
||||
this._decryptedCallback(dehydratedDevice);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._error = err;
|
||||
this.emitChange("error");
|
||||
} finally {
|
||||
this._isBusy = false;
|
||||
this.emitChange("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enterSecurityPhrase(passphrase) {
|
||||
this._enterCredentials(KeyType.Passphrase, passphrase);
|
||||
}
|
||||
|
||||
enterSecurityKey(securityKey) {
|
||||
this._enterCredentials(KeyType.RecoveryKey, securityKey);
|
||||
}
|
||||
|
||||
disable() {}
|
||||
}
|
71
src/domain/LogoutViewModel.ts
Normal file
71
src/domain/LogoutViewModel.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {Options as BaseOptions, ViewModel} from "./ViewModel";
|
||||
import {Client} from "../matrix/Client.js";
|
||||
import {SegmentType} from "./navigation/index";
|
||||
|
||||
type Options = { sessionId: string; } & BaseOptions;
|
||||
|
||||
export class LogoutViewModel extends ViewModel<SegmentType, Options> {
|
||||
private _sessionId: string;
|
||||
private _busy: boolean;
|
||||
private _showConfirm: boolean;
|
||||
private _error?: Error;
|
||||
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
this._sessionId = options.sessionId;
|
||||
this._busy = false;
|
||||
this._showConfirm = true;
|
||||
this._error = undefined;
|
||||
}
|
||||
|
||||
get showConfirm(): boolean {
|
||||
return this._showConfirm;
|
||||
}
|
||||
|
||||
get busy(): boolean {
|
||||
return this._busy;
|
||||
}
|
||||
|
||||
get cancelUrl(): string | undefined {
|
||||
return this.urlCreator.urlForSegment("session", true);
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
this._busy = true;
|
||||
this._showConfirm = false;
|
||||
this.emitChange("busy");
|
||||
try {
|
||||
const client = new Client(this.platform);
|
||||
await client.startLogout(this._sessionId);
|
||||
this.navigation.push("session", true);
|
||||
} catch (err) {
|
||||
this._error = err;
|
||||
this._busy = false;
|
||||
this.emitChange("busy");
|
||||
}
|
||||
}
|
||||
|
||||
get status(): string {
|
||||
if (this._error) {
|
||||
return this.i18n`Could not log out of device: ${this._error.message}`;
|
||||
} else {
|
||||
return this.i18n`Logging out… Please don't close the app.`;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,22 +14,24 @@ See the License for the specific language governing permissions and
|
|||
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.js";
|
||||
import {ViewModel} from "./ViewModel";
|
||||
|
||||
export class RootViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._createSessionContainer = options.createSessionContainer;
|
||||
this._error = null;
|
||||
this._sessionPickerViewModel = null;
|
||||
this._sessionLoadViewModel = null;
|
||||
this._loginViewModel = null;
|
||||
this._logoutViewModel = null;
|
||||
this._sessionViewModel = null;
|
||||
this._pendingSessionContainer = null;
|
||||
this._pendingClient = null;
|
||||
}
|
||||
|
||||
async load() {
|
||||
|
@ -40,29 +42,34 @@ export class RootViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
async _applyNavigation(shouldRestoreLastUrl) {
|
||||
const isLogin = this.navigation.path.get("login")
|
||||
const isLogin = this.navigation.path.get("login");
|
||||
const logoutSessionId = this.navigation.path.get("logout")?.value;
|
||||
const sessionId = this.navigation.path.get("session")?.value;
|
||||
const loginToken = this.navigation.path.get("sso")?.value;
|
||||
if (isLogin) {
|
||||
if (this.activeSection !== "login") {
|
||||
this._showLogin();
|
||||
}
|
||||
} else if (logoutSessionId) {
|
||||
if (this.activeSection !== "logout") {
|
||||
this._showLogout(logoutSessionId);
|
||||
}
|
||||
} else if (sessionId === true) {
|
||||
if (this.activeSection !== "picker") {
|
||||
this._showPicker();
|
||||
}
|
||||
} else if (sessionId) {
|
||||
if (!this._sessionViewModel || this._sessionViewModel.id !== sessionId) {
|
||||
// see _showLogin for where _pendingSessionContainer comes from
|
||||
if (this._pendingSessionContainer && this._pendingSessionContainer.sessionId === sessionId) {
|
||||
const sessionContainer = this._pendingSessionContainer;
|
||||
this._pendingSessionContainer = null;
|
||||
this._showSession(sessionContainer);
|
||||
// see _showLogin for where _pendingClient comes from
|
||||
if (this._pendingClient && this._pendingClient.sessionId === sessionId) {
|
||||
const client = this._pendingClient;
|
||||
this._pendingClient = null;
|
||||
this._showSession(client);
|
||||
} else {
|
||||
// this should never happen, but we want to be sure not to leak it
|
||||
if (this._pendingSessionContainer) {
|
||||
this._pendingSessionContainer.dispose();
|
||||
this._pendingSessionContainer = null;
|
||||
if (this._pendingClient) {
|
||||
this._pendingClient.dispose();
|
||||
this._pendingClient = null;
|
||||
}
|
||||
this._showSessionLoader(sessionId);
|
||||
}
|
||||
|
@ -106,8 +113,7 @@ export class RootViewModel extends ViewModel {
|
|||
this._setSection(() => {
|
||||
this._loginViewModel = new LoginViewModel(this.childOptions({
|
||||
defaultHomeserver: this.platform.config["defaultHomeServer"],
|
||||
createSessionContainer: this._createSessionContainer,
|
||||
ready: sessionContainer => {
|
||||
ready: client => {
|
||||
// we don't want to load the session container again,
|
||||
// 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
|
||||
|
@ -116,28 +122,34 @@ export class RootViewModel extends ViewModel {
|
|||
// 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.
|
||||
this._pendingSessionContainer = sessionContainer;
|
||||
this.navigation.push("session", sessionContainer.sessionId);
|
||||
this._pendingClient = client;
|
||||
this.navigation.push("session", client.sessionId);
|
||||
},
|
||||
loginToken
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
_showSession(sessionContainer) {
|
||||
_showLogout(sessionId) {
|
||||
this._setSection(() => {
|
||||
this._sessionViewModel = new SessionViewModel(this.childOptions({sessionContainer}));
|
||||
this._logoutViewModel = new LogoutViewModel(this.childOptions({sessionId}));
|
||||
});
|
||||
}
|
||||
|
||||
_showSession(client) {
|
||||
this._setSection(() => {
|
||||
this._sessionViewModel = new SessionViewModel(this.childOptions({client}));
|
||||
this._sessionViewModel.start();
|
||||
});
|
||||
}
|
||||
|
||||
_showSessionLoader(sessionId) {
|
||||
const sessionContainer = this._createSessionContainer();
|
||||
sessionContainer.startWithExistingSession(sessionId);
|
||||
const client = new Client(this.platform);
|
||||
client.startWithExistingSession(sessionId);
|
||||
this._setSection(() => {
|
||||
this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({
|
||||
sessionContainer,
|
||||
ready: sessionContainer => this._showSession(sessionContainer)
|
||||
client,
|
||||
ready: client => this._showSession(client)
|
||||
}));
|
||||
this._sessionLoadViewModel.start();
|
||||
});
|
||||
|
@ -150,6 +162,8 @@ export class RootViewModel extends ViewModel {
|
|||
return "session";
|
||||
} else if (this._loginViewModel) {
|
||||
return "login";
|
||||
} else if (this._logoutViewModel) {
|
||||
return "logout";
|
||||
} else if (this._sessionPickerViewModel) {
|
||||
return "picker";
|
||||
} else if (this._sessionLoadViewModel) {
|
||||
|
@ -165,12 +179,14 @@ export class RootViewModel extends ViewModel {
|
|||
this._sessionPickerViewModel = this.disposeTracked(this._sessionPickerViewModel);
|
||||
this._sessionLoadViewModel = this.disposeTracked(this._sessionLoadViewModel);
|
||||
this._loginViewModel = this.disposeTracked(this._loginViewModel);
|
||||
this._logoutViewModel = this.disposeTracked(this._logoutViewModel);
|
||||
this._sessionViewModel = this.disposeTracked(this._sessionViewModel);
|
||||
// now set it again
|
||||
setter();
|
||||
this._sessionPickerViewModel && this.track(this._sessionPickerViewModel);
|
||||
this._sessionLoadViewModel && this.track(this._sessionLoadViewModel);
|
||||
this._loginViewModel && this.track(this._loginViewModel);
|
||||
this._logoutViewModel && this.track(this._logoutViewModel);
|
||||
this._sessionViewModel && this.track(this._sessionViewModel);
|
||||
this.emitChange("activeSection");
|
||||
}
|
||||
|
@ -178,6 +194,7 @@ export class RootViewModel extends ViewModel {
|
|||
get error() { return this._error; }
|
||||
get sessionViewModel() { return this._sessionViewModel; }
|
||||
get loginViewModel() { return this._loginViewModel; }
|
||||
get logoutViewModel() { return this._logoutViewModel; }
|
||||
get sessionPickerViewModel() { return this._sessionPickerViewModel; }
|
||||
get sessionLoadViewModel() { return this._sessionLoadViewModel; }
|
||||
}
|
|
@ -14,21 +14,24 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {LoadStatus} from "../matrix/SessionContainer.js";
|
||||
import {AccountSetupViewModel} from "./AccountSetupViewModel.js";
|
||||
import {LoadStatus} from "../matrix/Client.js";
|
||||
import {SyncStatus} from "../matrix/Sync.js";
|
||||
import {ViewModel} from "./ViewModel.js";
|
||||
import {ViewModel} from "./ViewModel";
|
||||
|
||||
export class SessionLoadViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {sessionContainer, ready, homeserver, deleteSessionOnCancel} = options;
|
||||
this._sessionContainer = sessionContainer;
|
||||
const {client, ready, homeserver, deleteSessionOnCancel} = options;
|
||||
this._client = client;
|
||||
this._ready = ready;
|
||||
this._homeserver = homeserver;
|
||||
this._deleteSessionOnCancel = deleteSessionOnCancel;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this.backUrl = this.urlCreator.urlForSegment("session", true);
|
||||
this._accountSetupViewModel = undefined;
|
||||
|
||||
}
|
||||
|
||||
async start() {
|
||||
|
@ -38,11 +41,16 @@ export class SessionLoadViewModel extends ViewModel {
|
|||
try {
|
||||
this._loading = true;
|
||||
this.emitChange("loading");
|
||||
this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => {
|
||||
this._waitHandle = this._client.loadStatus.waitFor(s => {
|
||||
if (s === LoadStatus.AccountSetup) {
|
||||
this._accountSetupViewModel = new AccountSetupViewModel(this.childOptions({accountSetup: this._client.accountSetup}));
|
||||
} else {
|
||||
this._accountSetupViewModel = undefined;
|
||||
}
|
||||
this.emitChange("loadLabel");
|
||||
// wait for initial sync, but not catchup sync
|
||||
const isCatchupSync = s === LoadStatus.FirstSync &&
|
||||
this._sessionContainer.sync.status.get() === SyncStatus.CatchupSync;
|
||||
this._client.sync.status.get() === SyncStatus.CatchupSync;
|
||||
return isCatchupSync ||
|
||||
s === LoadStatus.LoginFailed ||
|
||||
s === LoadStatus.Error ||
|
||||
|
@ -59,15 +67,15 @@ export class SessionLoadViewModel extends ViewModel {
|
|||
// much like we will once you are in the app. Probably a good idea
|
||||
|
||||
// did it finish or get stuck at LoginFailed or Error?
|
||||
const loadStatus = this._sessionContainer.loadStatus.get();
|
||||
const loadError = this._sessionContainer.loadError;
|
||||
const loadStatus = this._client.loadStatus.get();
|
||||
const loadError = this._client.loadError;
|
||||
if (loadStatus === LoadStatus.FirstSync || loadStatus === LoadStatus.Ready) {
|
||||
const sessionContainer = this._sessionContainer;
|
||||
const client = this._client;
|
||||
// session container is ready,
|
||||
// don't dispose it anymore when
|
||||
// we get disposed
|
||||
this._sessionContainer = null;
|
||||
this._ready(sessionContainer);
|
||||
this._client = null;
|
||||
this._ready(client);
|
||||
}
|
||||
if (loadError) {
|
||||
console.error("session load error", loadError);
|
||||
|
@ -77,16 +85,16 @@ export class SessionLoadViewModel extends ViewModel {
|
|||
console.error("error thrown during session load", err.stack);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
// loadLabel in case of sc.loadError also gets updated through this
|
||||
// loadLabel in case of client.loadError also gets updated through this
|
||||
this.emitChange("loading");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dispose() {
|
||||
if (this._sessionContainer) {
|
||||
this._sessionContainer.dispose();
|
||||
this._sessionContainer = null;
|
||||
if (this._client) {
|
||||
this._client.dispose();
|
||||
this._client = null;
|
||||
}
|
||||
if (this._waitHandle) {
|
||||
// rejects with AbortError
|
||||
|
@ -97,20 +105,27 @@ export class SessionLoadViewModel extends ViewModel {
|
|||
|
||||
// to show a spinner or not
|
||||
get loading() {
|
||||
const client = this._client;
|
||||
if (client && client.loadStatus.get() === LoadStatus.AccountSetup) {
|
||||
return false;
|
||||
}
|
||||
return this._loading;
|
||||
}
|
||||
|
||||
get loadLabel() {
|
||||
const sc = this._sessionContainer;
|
||||
const error = this._error || (sc && sc.loadError);
|
||||
|
||||
if (error || (sc && sc.loadStatus.get() === LoadStatus.Error)) {
|
||||
const client = this._client;
|
||||
const error = this._getError();
|
||||
if (error || (client && client.loadStatus.get() === LoadStatus.Error)) {
|
||||
return `Something went wrong: ${error && error.message}.`;
|
||||
}
|
||||
|
||||
// Statuses related to login are handled by respective login view models
|
||||
if (sc) {
|
||||
switch (sc.loadStatus.get()) {
|
||||
if (client) {
|
||||
switch (client.loadStatus.get()) {
|
||||
case LoadStatus.QueryAccount:
|
||||
return `Querying account encryption setup…`;
|
||||
case LoadStatus.AccountSetup:
|
||||
return ""; // we'll show a header ing AccountSetupView
|
||||
case LoadStatus.SessionSetup:
|
||||
return `Setting up your encryption keys…`;
|
||||
case LoadStatus.Loading:
|
||||
|
@ -118,10 +133,32 @@ export class SessionLoadViewModel extends ViewModel {
|
|||
case LoadStatus.FirstSync:
|
||||
return `Getting your conversations from the server…`;
|
||||
default:
|
||||
return this._sessionContainer.loadStatus.get();
|
||||
return this._client.loadStatus.get();
|
||||
}
|
||||
}
|
||||
|
||||
return `Preparing…`;
|
||||
}
|
||||
|
||||
_getError() {
|
||||
return this._error || this._client?.loadError;
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return !!this._getError();
|
||||
}
|
||||
|
||||
async exportLogs() {
|
||||
const logExport = await this.logger.export();
|
||||
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this._client.logout();
|
||||
this.navigation.push("session", true);
|
||||
}
|
||||
|
||||
get accountSetupViewModel() {
|
||||
return this._accountSetupViewModel;
|
||||
}
|
||||
}
|
99
src/domain/SessionPickerViewModel.js
Normal file
99
src/domain/SessionPickerViewModel.js
Normal file
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
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 {SortedArray} from "../observable/index.js";
|
||||
import {ViewModel} from "./ViewModel";
|
||||
import {avatarInitials, getIdentifierColorNumber} from "./avatar";
|
||||
|
||||
class SessionItemViewModel extends ViewModel {
|
||||
constructor(options, pickerVM) {
|
||||
super(options);
|
||||
this._pickerVM = pickerVM;
|
||||
this._sessionInfo = options.sessionInfo;
|
||||
this._isDeleting = false;
|
||||
this._isClearing = false;
|
||||
this._error = null;
|
||||
this._exportDataUrl = null;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this._error && this._error.message;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this._sessionInfo.id;
|
||||
}
|
||||
|
||||
get openUrl() {
|
||||
return this.urlCreator.urlForSegment("session", this.id);
|
||||
}
|
||||
|
||||
get label() {
|
||||
const {userId, comment} = this._sessionInfo;
|
||||
if (comment) {
|
||||
return `${userId} (${comment})`;
|
||||
} else {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
get sessionInfo() {
|
||||
return this._sessionInfo;
|
||||
}
|
||||
|
||||
get exportDataUrl() {
|
||||
return this._exportDataUrl;
|
||||
}
|
||||
|
||||
get avatarColorNumber() {
|
||||
return getIdentifierColorNumber(this._sessionInfo.userId);
|
||||
}
|
||||
|
||||
get avatarInitials() {
|
||||
return avatarInitials(this._sessionInfo.userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class SessionPickerViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._sessions = new SortedArray((s1, s2) => s1.id.localeCompare(s2.id));
|
||||
this._loadViewModel = null;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
// this loads all the sessions
|
||||
async load() {
|
||||
const sessions = await this.platform.sessionInfoStorage.getAll();
|
||||
this._sessions.setManyUnsorted(sessions.map(s => {
|
||||
return new SessionItemViewModel(this.childOptions({sessionInfo: s}), this);
|
||||
}));
|
||||
}
|
||||
|
||||
// for the loading of 1 picked session
|
||||
get loadViewModel() {
|
||||
return this._loadViewModel;
|
||||
}
|
||||
|
||||
get sessions() {
|
||||
return this._sessions;
|
||||
}
|
||||
|
||||
get cancelUrl() {
|
||||
return this.urlCreator.urlForSegment("login");
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue