forked from mystiq/hydrogen-web
Compare commits
934 commits
bwindels/t
...
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 | ||
|
3c28ee1adf | ||
|
2c4610c132 | ||
|
239d16747d | ||
|
764541d3ca | ||
|
ca1831fef6 | ||
|
2f4c0623d0 | ||
|
ed88184757 | ||
|
d0f7570f5e | ||
|
acafae7d3a | ||
|
a59bf7c002 | ||
|
5c1813888c | ||
|
73733ce145 | ||
|
bf6dfcfcad | ||
|
f605608098 | ||
|
31a8227e53 | ||
|
62dcb61536 | ||
|
fda211e7b3 | ||
|
63b6564f70 | ||
|
93bbeee400 | ||
|
66fa8d84a7 | ||
|
091b55a265 | ||
|
ec8f6e8e0a | ||
|
7ad73bb453 | ||
|
3fecce6fe6 | ||
|
9d161a0bcf | ||
|
8cc04e4c25 | ||
|
0a09a50ab9 | ||
|
c6484f1eac | ||
|
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 |
283 changed files with 9830 additions and 2030 deletions
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
|
||||
|
||||
|
|
|
@ -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?
|
||||
|
||||
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 > 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.
|
10
doc/SDK.md
10
doc/SDK.md
|
@ -31,7 +31,8 @@ import {
|
|||
createNavigation,
|
||||
createRouter,
|
||||
RoomViewModel,
|
||||
TimelineView
|
||||
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';
|
||||
|
@ -47,12 +48,13 @@ const assetPaths = {
|
|||
wasmBundle: olmJsPath
|
||||
}
|
||||
};
|
||||
import "hydrogen-view-sdk/style.css";
|
||||
import "hydrogen-view-sdk/assets/theme-element-light.css";
|
||||
// OR import "hydrogen-view-sdk/assets/theme-element-dark.css";
|
||||
|
||||
async function main() {
|
||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||
const config = {};
|
||||
const platform = new Platform(app, assetPaths, config, { development: import.meta.env.DEV });
|
||||
const platform = new Platform({container: app, assetPaths, config, options: { development: import.meta.env.DEV }});
|
||||
const navigation = createNavigation();
|
||||
platform.setNavigation(navigation);
|
||||
const urlRouter = createRouter({
|
||||
|
@ -87,7 +89,7 @@ async function main() {
|
|||
navigation,
|
||||
});
|
||||
await vm.load();
|
||||
const view = new TimelineView(vm.timelineViewModel);
|
||||
const view = new TimelineView(vm.timelineViewModel, viewClassForTile);
|
||||
app.appendChild(view.mount());
|
||||
}
|
||||
}
|
||||
|
|
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.
|
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 |
27
package.json
27
package.json
|
@ -1,18 +1,24 @@
|
|||
{
|
||||
"name": "hydrogen-web",
|
||||
"version": "0.2.22",
|
||||
"version": "0.3.1",
|
||||
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
|
||||
"directories": {
|
||||
"doc": "doc"
|
||||
},
|
||||
"enginesStrict": {
|
||||
"node": ">=15"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint --cache src/",
|
||||
"lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts",
|
||||
"lint-ci": "eslint src/",
|
||||
"test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/",
|
||||
"test:postcss": "impunity --entry-point scripts/postcss/tests/css-compile-variables.test.js scripts/postcss/tests/css-url-to-variables.test.js",
|
||||
"test:sdk": "yarn build:sdk && cd ./scripts/sdk/test/ && yarn --no-lockfile && node test-sdk-in-esm-vite-build-env.js && node test-sdk-in-commonjs-env.js",
|
||||
"start": "vite --port 3000",
|
||||
"build": "vite build",
|
||||
"build:sdk": "./scripts/sdk/build.sh"
|
||||
"build": "vite build && ./scripts/cleanup.sh",
|
||||
"build:sdk": "./scripts/sdk/build.sh",
|
||||
"watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -30,6 +36,7 @@
|
|||
"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",
|
||||
|
@ -41,17 +48,19 @@
|
|||
"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.3.5",
|
||||
"vite": "^2.6.14",
|
||||
"xxhashjs": "^0.2.2",
|
||||
"bs58": "^4.0.1"
|
||||
"typescript": "^4.7.0",
|
||||
"vite": "^2.9.8",
|
||||
"xxhashjs": "^0.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
|
||||
"another-json": "^0.2.0",
|
||||
"base64-arraybuffer": "^0.2.0",
|
||||
"dompurify": "^2.3.0"
|
||||
"dompurify": "^2.3.0",
|
||||
"off-color": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
|
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"
|
||||
},
|
||||
};
|
||||
|
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);
|
||||
},
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ function contentHash(str) {
|
|||
return hasher.digest();
|
||||
}
|
||||
|
||||
function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
|
||||
function injectServiceWorker(swFile, findUnhashedFileNamesFromBundle, placeholdersPerChunk) {
|
||||
const swName = path.basename(swFile);
|
||||
let root;
|
||||
let version;
|
||||
|
@ -31,6 +31,7 @@ function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
|
|||
logger = config.logger;
|
||||
},
|
||||
generateBundle: async function(options, bundle) {
|
||||
const otherUnhashedFiles = findUnhashedFileNamesFromBundle(bundle);
|
||||
const unhashedFilenames = [swName].concat(otherUnhashedFiles);
|
||||
const unhashedFileContentMap = unhashedFilenames.reduce((map, fileName) => {
|
||||
const chunkOrAsset = bundle[fileName];
|
||||
|
|
165
scripts/ci.sh
Executable file
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
|
|
@ -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
|
||||
|
|
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;
|
||||
}
|
30
scripts/postcss/tests/common.js
Normal file
30
scripts/postcss/tests/common.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
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 postcss = require("postcss");
|
||||
|
||||
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
|
||||
|
|
|
@ -1,7 +1,19 @@
|
|||
{
|
||||
"name": "hydrogen-view-sdk",
|
||||
"description": "Embeddable matrix client library, including view components",
|
||||
"version": "0.0.4",
|
||||
"main": "./hydrogen.es.js",
|
||||
"type": "module"
|
||||
"version": "0.1.0",
|
||||
"main": "./lib-build/hydrogen.cjs.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./lib-build/hydrogen.es.js",
|
||||
"require": "./lib-build/hydrogen.cjs.js"
|
||||
},
|
||||
"./paths/vite": "./paths/vite.js",
|
||||
"./style.css": "./asset-build/assets/theme-element-light.css",
|
||||
"./theme-element-light.css": "./asset-build/assets/theme-element-light.css",
|
||||
"./theme-element-dark.css": "./asset-build/assets/theme-element-dark.css",
|
||||
"./main.js": "./asset-build/assets/main.js",
|
||||
"./download-sandbox.html": "./asset-build/assets/download-sandbox.html",
|
||||
"./assets/*": "./asset-build/assets/*"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
#!/bin/bash
|
||||
rm -rf target
|
||||
# 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
|
||||
|
@ -8,15 +16,10 @@ mkdir target/paths
|
|||
# this doesn't work, the ?url imports need to be in the consuming project, so disable for now
|
||||
# ./scripts/sdk/transform-paths.js ./src/platform/web/sdk/paths/vite.js ./target/paths/vite.js
|
||||
cp doc/SDK.md target/README.md
|
||||
pushd target
|
||||
pushd asset-build/assets
|
||||
mv main.*.js ../../main.js
|
||||
mv index.*.css ../../style.css
|
||||
mv download-sandbox.*.html ../../download-sandbox.html
|
||||
rm *.js *.wasm
|
||||
mv ./* ../../
|
||||
pushd target/asset-build
|
||||
rm index.html
|
||||
popd
|
||||
rm -rf asset-build
|
||||
mv lib-build/* .
|
||||
rm -rf lib-build
|
||||
pushd target/asset-build/assets
|
||||
# Remove all `*.wasm` and `*.js` files except for `main.js`
|
||||
rm !(main).js *.wasm
|
||||
popd
|
||||
|
|
|
@ -3,21 +3,7 @@ const fs = require("fs");
|
|||
const appManifest = require("../../package.json");
|
||||
const baseSDKManifest = require("./base-manifest.json");
|
||||
/*
|
||||
need to leave exports out of base-manifest.json because of #vite-bug,
|
||||
with the downside that we can't support environments that support
|
||||
both esm and commonjs modules, so we pick just esm.
|
||||
```
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./hydrogen.es.js",
|
||||
"require": "./hydrogen.cjs.js"
|
||||
},
|
||||
"./paths/vite": "./paths/vite.js",
|
||||
"./style.css": "./style.css"
|
||||
},
|
||||
```
|
||||
|
||||
Also need to leave typescript type definitions out until the
|
||||
Need to leave typescript type definitions out until the
|
||||
typescript conversion is complete and all imports in the d.ts files
|
||||
exists.
|
||||
```
|
||||
|
|
3
scripts/sdk/test/.gitignore
vendored
Normal file
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();
|
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "./ViewModel.js";
|
||||
import {ViewModel} from "./ViewModel";
|
||||
import {KeyType} from "../matrix/ssss/index";
|
||||
import {Status} from "./session/settings/SessionBackupViewModel.js";
|
||||
import {Status} from "./session/settings/KeyBackupViewModel.js";
|
||||
|
||||
export class AccountSetupViewModel extends ViewModel {
|
||||
constructor(accountSetup) {
|
||||
super();
|
||||
this._accountSetup = accountSetup;
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._accountSetup = options.accountSetup;
|
||||
this._dehydratedDevice = undefined;
|
||||
this._decryptDehydratedDeviceViewModel = undefined;
|
||||
if (this._accountSetup.encryptedDehydratedDevice) {
|
||||
|
@ -50,10 +50,10 @@ export class AccountSetupViewModel extends ViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
// this vm adopts the same shape as SessionBackupViewModel so the same view can be reused.
|
||||
// this vm adopts the same shape as KeyBackupViewModel so the same view can be reused.
|
||||
class DecryptDehydratedDeviceViewModel extends ViewModel {
|
||||
constructor(accountSetupViewModel, decryptedCallback) {
|
||||
super();
|
||||
super(accountSetupViewModel.options);
|
||||
this._accountSetupViewModel = accountSetupViewModel;
|
||||
this._isBusy = false;
|
||||
this._status = Status.SetupKey;
|
||||
|
|
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.`;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,9 +17,10 @@ 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) {
|
||||
|
@ -28,6 +29,7 @@ export class RootViewModel extends ViewModel {
|
|||
this._sessionPickerViewModel = null;
|
||||
this._sessionLoadViewModel = null;
|
||||
this._loginViewModel = null;
|
||||
this._logoutViewModel = null;
|
||||
this._sessionViewModel = null;
|
||||
this._pendingClient = null;
|
||||
}
|
||||
|
@ -40,13 +42,18 @@ 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();
|
||||
|
@ -123,6 +130,12 @@ export class RootViewModel extends ViewModel {
|
|||
});
|
||||
}
|
||||
|
||||
_showLogout(sessionId) {
|
||||
this._setSection(() => {
|
||||
this._logoutViewModel = new LogoutViewModel(this.childOptions({sessionId}));
|
||||
});
|
||||
}
|
||||
|
||||
_showSession(client) {
|
||||
this._setSection(() => {
|
||||
this._sessionViewModel = new SessionViewModel(this.childOptions({client}));
|
||||
|
@ -149,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) {
|
||||
|
@ -164,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");
|
||||
}
|
||||
|
@ -177,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; }
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
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) {
|
||||
|
@ -43,7 +43,7 @@ export class SessionLoadViewModel extends ViewModel {
|
|||
this.emitChange("loading");
|
||||
this._waitHandle = this._client.loadStatus.waitFor(s => {
|
||||
if (s === LoadStatus.AccountSetup) {
|
||||
this._accountSetupViewModel = new AccountSetupViewModel(this._client.accountSetup);
|
||||
this._accountSetupViewModel = new AccountSetupViewModel(this.childOptions({accountSetup: this._client.accountSetup}));
|
||||
} else {
|
||||
this._accountSetupViewModel = undefined;
|
||||
}
|
||||
|
|
|
@ -15,8 +15,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {SortedArray} from "../observable/index.js";
|
||||
import {ViewModel} from "./ViewModel.js";
|
||||
import {avatarInitials, getIdentifierColorNumber} from "./avatar.js";
|
||||
import {ViewModel} from "./ViewModel";
|
||||
import {avatarInitials, getIdentifierColorNumber} from "./avatar";
|
||||
|
||||
class SessionItemViewModel extends ViewModel {
|
||||
constructor(options, pickerVM) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -21,54 +22,82 @@ limitations under the License.
|
|||
import {EventEmitter} from "../utils/EventEmitter";
|
||||
import {Disposables} from "../utils/Disposables";
|
||||
|
||||
export class ViewModel extends EventEmitter {
|
||||
constructor(options = {}) {
|
||||
import type {Disposable} from "../utils/Disposables";
|
||||
import type {Platform} from "../platform/web/Platform";
|
||||
import type {Clock} from "../platform/web/dom/Clock";
|
||||
import type {ILogger} from "../logging/types";
|
||||
import type {Navigation} from "./navigation/Navigation";
|
||||
import type {SegmentType} from "./navigation/index";
|
||||
import type {IURLRouter} from "./navigation/URLRouter";
|
||||
|
||||
export type Options<T extends object = SegmentType> = {
|
||||
platform: Platform;
|
||||
logger: ILogger;
|
||||
urlCreator: IURLRouter<T>;
|
||||
navigation: Navigation<T>;
|
||||
emitChange?: (params: any) => void;
|
||||
}
|
||||
|
||||
|
||||
export class ViewModel<N extends object = SegmentType, O extends Options<N> = Options<N>> extends EventEmitter<{change: never}> {
|
||||
private disposables?: Disposables;
|
||||
private _isDisposed = false;
|
||||
private _options: Readonly<O>;
|
||||
|
||||
constructor(options: Readonly<O>) {
|
||||
super();
|
||||
this.disposables = null;
|
||||
this._isDisposed = false;
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
childOptions(explicitOptions) {
|
||||
const {navigation, urlCreator, platform} = this._options;
|
||||
return Object.assign({navigation, urlCreator, platform}, explicitOptions);
|
||||
childOptions<T extends Object>(explicitOptions: T): T & Options<N> {
|
||||
return Object.assign({}, this._options, explicitOptions);
|
||||
}
|
||||
|
||||
get options(): Readonly<O> { return this._options; }
|
||||
|
||||
// makes it easier to pass through dependencies of a sub-view model
|
||||
getOption(name) {
|
||||
getOption<N extends keyof O>(name: N): O[N] {
|
||||
return this._options[name];
|
||||
}
|
||||
|
||||
track(disposable) {
|
||||
observeNavigation<T extends keyof N>(type: T, onChange: (value: N[T], type: T) => void): void {
|
||||
const segmentObservable = this.navigation.observe(type);
|
||||
const unsubscribe = segmentObservable.subscribe((value: N[T]) => {
|
||||
onChange(value, type);
|
||||
});
|
||||
this.track(unsubscribe);
|
||||
}
|
||||
|
||||
track<D extends Disposable>(disposable: D): D {
|
||||
if (!this.disposables) {
|
||||
this.disposables = new Disposables();
|
||||
}
|
||||
return this.disposables.track(disposable);
|
||||
}
|
||||
|
||||
untrack(disposable) {
|
||||
untrack(disposable: Disposable): undefined {
|
||||
if (this.disposables) {
|
||||
return this.disposables.untrack(disposable);
|
||||
}
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
dispose(): void {
|
||||
if (this.disposables) {
|
||||
this.disposables.dispose();
|
||||
}
|
||||
this._isDisposed = true;
|
||||
}
|
||||
|
||||
get isDisposed() {
|
||||
get isDisposed(): boolean {
|
||||
return this._isDisposed;
|
||||
}
|
||||
|
||||
disposeTracked(disposable) {
|
||||
disposeTracked(disposable: Disposable | undefined): undefined {
|
||||
if (this.disposables) {
|
||||
return this.disposables.disposeTracked(disposable);
|
||||
}
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// TODO: this will need to support binding
|
||||
|
@ -76,7 +105,7 @@ export class ViewModel extends EventEmitter {
|
|||
//
|
||||
// translated string should probably always be bindings, unless we're fine with a refresh when changing the language?
|
||||
// we probably are, if we're using routing with a url, we could just refresh.
|
||||
i18n(parts, ...expr) {
|
||||
i18n(parts: TemplateStringsArray, ...expr: any[]): string {
|
||||
// just concat for now
|
||||
let result = "";
|
||||
for (let i = 0; i < parts.length; ++i) {
|
||||
|
@ -88,11 +117,7 @@ export class ViewModel extends EventEmitter {
|
|||
return result;
|
||||
}
|
||||
|
||||
updateOptions(options) {
|
||||
this._options = Object.assign(this._options, options);
|
||||
}
|
||||
|
||||
emitChange(changedProps) {
|
||||
emitChange(changedProps: any): void {
|
||||
if (this._options.emitChange) {
|
||||
this._options.emitChange(changedProps);
|
||||
} else {
|
||||
|
@ -100,27 +125,24 @@ export class ViewModel extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
get platform() {
|
||||
get platform(): Platform {
|
||||
return this._options.platform;
|
||||
}
|
||||
|
||||
get clock() {
|
||||
get clock(): Clock {
|
||||
return this._options.platform.clock;
|
||||
}
|
||||
|
||||
get logger() {
|
||||
get logger(): ILogger {
|
||||
return this.platform.logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* The url router, only meant to be used to create urls with from view models.
|
||||
* @return {URLRouter}
|
||||
*/
|
||||
get urlCreator() {
|
||||
get urlCreator(): IURLRouter<N> {
|
||||
return this._options.urlCreator;
|
||||
}
|
||||
|
||||
get navigation() {
|
||||
return this._options.navigation;
|
||||
get navigation(): Navigation<N> {
|
||||
// typescript needs a little help here
|
||||
return this._options.navigation as unknown as Navigation<N>;
|
||||
}
|
||||
}
|
|
@ -14,7 +14,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
export function avatarInitials(name) {
|
||||
import { Platform } from "../platform/web/Platform";
|
||||
import { MediaRepository } from "../matrix/net/MediaRepository";
|
||||
|
||||
export function avatarInitials(name: string): string {
|
||||
let firstChar = name.charAt(0);
|
||||
if (firstChar === "!" || firstChar === "@" || firstChar === "#") {
|
||||
firstChar = name.charAt(1);
|
||||
|
@ -29,10 +32,10 @@ export function avatarInitials(name) {
|
|||
*
|
||||
* @return {number}
|
||||
*/
|
||||
function hashCode(str) {
|
||||
function hashCode(str: string): number {
|
||||
let hash = 0;
|
||||
let i;
|
||||
let chr;
|
||||
let i: number;
|
||||
let chr: number;
|
||||
if (str.length === 0) {
|
||||
return hash;
|
||||
}
|
||||
|
@ -44,11 +47,11 @@ function hashCode(str) {
|
|||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function getIdentifierColorNumber(id) {
|
||||
export function getIdentifierColorNumber(id: string): number {
|
||||
return (hashCode(id) % 8) + 1;
|
||||
}
|
||||
|
||||
export function getAvatarHttpUrl(avatarUrl, cssSize, platform, mediaRepository) {
|
||||
export function getAvatarHttpUrl(avatarUrl: string, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | null {
|
||||
if (avatarUrl) {
|
||||
const imageSize = cssSize * platform.devicePixelRatio;
|
||||
return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop");
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../ViewModel.js";
|
||||
import {ViewModel} from "../ViewModel";
|
||||
import {LoginFailure} from "../../matrix/Client.js";
|
||||
|
||||
export class CompleteSSOLoginViewModel extends ViewModel {
|
||||
|
|
|
@ -15,101 +15,145 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {Client} from "../../matrix/Client.js";
|
||||
import {ViewModel} from "../ViewModel.js";
|
||||
import {Options as BaseOptions, ViewModel} from "../ViewModel";
|
||||
import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js";
|
||||
import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js";
|
||||
import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js";
|
||||
import {LoadStatus} from "../../matrix/Client.js";
|
||||
import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
|
||||
import {SegmentType} from "../navigation/index";
|
||||
|
||||
export class LoginViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login";
|
||||
|
||||
type Options = {
|
||||
defaultHomeserver: string;
|
||||
ready: ReadyFn;
|
||||
loginToken?: string;
|
||||
} & BaseOptions;
|
||||
|
||||
export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
||||
private _ready: ReadyFn;
|
||||
private _loginToken?: string;
|
||||
private _client: Client;
|
||||
private _loginOptions?: LoginOptions;
|
||||
private _passwordLoginViewModel?: PasswordLoginViewModel;
|
||||
private _startSSOLoginViewModel?: StartSSOLoginViewModel;
|
||||
private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel;
|
||||
private _loadViewModel?: SessionLoadViewModel;
|
||||
private _loadViewModelSubscription?: () => void;
|
||||
private _homeserver: string;
|
||||
private _queriedHomeserver?: string;
|
||||
private _abortHomeserverQueryTimeout?: () => void;
|
||||
private _abortQueryOperation?: () => void;
|
||||
|
||||
private _hideHomeserver: boolean = false;
|
||||
private _isBusy: boolean = false;
|
||||
private _errorMessage: string = "";
|
||||
|
||||
constructor(options: Readonly<Options>) {
|
||||
super(options);
|
||||
const {ready, defaultHomeserver, loginToken} = options;
|
||||
this._ready = ready;
|
||||
this._loginToken = loginToken;
|
||||
this._client = new Client(this.platform);
|
||||
this._loginOptions = null;
|
||||
this._passwordLoginViewModel = null;
|
||||
this._startSSOLoginViewModel = null;
|
||||
this._completeSSOLoginViewModel = null;
|
||||
this._loadViewModel = null;
|
||||
this._loadViewModelSubscription = null;
|
||||
this._homeserver = defaultHomeserver;
|
||||
this._queriedHomeserver = null;
|
||||
this._errorMessage = "";
|
||||
this._hideHomeserver = false;
|
||||
this._isBusy = false;
|
||||
this._abortHomeserverQueryTimeout = null;
|
||||
this._abortQueryOperation = null;
|
||||
this._initViewModels();
|
||||
}
|
||||
|
||||
get passwordLoginViewModel() { return this._passwordLoginViewModel; }
|
||||
get startSSOLoginViewModel() { return this._startSSOLoginViewModel; }
|
||||
get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; }
|
||||
get homeserver() { return this._homeserver; }
|
||||
get resolvedHomeserver() { return this._loginOptions?.homeserver; }
|
||||
get errorMessage() { return this._errorMessage; }
|
||||
get showHomeserver() { return !this._hideHomeserver; }
|
||||
get loadViewModel() {return this._loadViewModel; }
|
||||
get isBusy() { return this._isBusy; }
|
||||
get isFetchingLoginOptions() { return !!this._abortQueryOperation; }
|
||||
get passwordLoginViewModel(): PasswordLoginViewModel {
|
||||
return this._passwordLoginViewModel;
|
||||
}
|
||||
|
||||
goBack() {
|
||||
get startSSOLoginViewModel(): StartSSOLoginViewModel {
|
||||
return this._startSSOLoginViewModel;
|
||||
}
|
||||
|
||||
get completeSSOLoginViewModel(): CompleteSSOLoginViewModel {
|
||||
return this._completeSSOLoginViewModel;
|
||||
}
|
||||
|
||||
get homeserver(): string {
|
||||
return this._homeserver;
|
||||
}
|
||||
|
||||
get resolvedHomeserver(): string | undefined {
|
||||
return this._loginOptions?.homeserver;
|
||||
}
|
||||
|
||||
get errorMessage(): string {
|
||||
return this._errorMessage;
|
||||
}
|
||||
|
||||
get showHomeserver(): boolean {
|
||||
return !this._hideHomeserver;
|
||||
}
|
||||
|
||||
get loadViewModel(): SessionLoadViewModel {
|
||||
return this._loadViewModel;
|
||||
}
|
||||
|
||||
get isBusy(): boolean {
|
||||
return this._isBusy;
|
||||
}
|
||||
|
||||
get isFetchingLoginOptions(): boolean {
|
||||
return !!this._abortQueryOperation;
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.navigation.push("session");
|
||||
}
|
||||
|
||||
async _initViewModels() {
|
||||
private _initViewModels(): void {
|
||||
if (this._loginToken) {
|
||||
this._hideHomeserver = true;
|
||||
this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel(
|
||||
this.childOptions(
|
||||
{
|
||||
client: this._client,
|
||||
attemptLogin: loginMethod => this.attemptLogin(loginMethod),
|
||||
attemptLogin: (loginMethod: TokenLoginMethod) => this.attemptLogin(loginMethod),
|
||||
loginToken: this._loginToken
|
||||
})));
|
||||
this.emitChange("completeSSOLoginViewModel");
|
||||
}
|
||||
else {
|
||||
await this.queryHomeserver();
|
||||
void this.queryHomeserver();
|
||||
}
|
||||
}
|
||||
|
||||
_showPasswordLogin() {
|
||||
private _showPasswordLogin(): void {
|
||||
this._passwordLoginViewModel = this.track(new PasswordLoginViewModel(
|
||||
this.childOptions({
|
||||
loginOptions: this._loginOptions,
|
||||
attemptLogin: loginMethod => this.attemptLogin(loginMethod)
|
||||
attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod)
|
||||
})));
|
||||
this.emitChange("passwordLoginViewModel");
|
||||
}
|
||||
|
||||
_showSSOLogin() {
|
||||
private _showSSOLogin(): void {
|
||||
this._startSSOLoginViewModel = this.track(
|
||||
new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions}))
|
||||
);
|
||||
this.emitChange("startSSOLoginViewModel");
|
||||
}
|
||||
|
||||
_showError(message) {
|
||||
private _showError(message: string): void {
|
||||
this._errorMessage = message;
|
||||
this.emitChange("errorMessage");
|
||||
}
|
||||
|
||||
_setBusy(status) {
|
||||
private _setBusy(status: boolean): void {
|
||||
this._isBusy = status;
|
||||
this._passwordLoginViewModel?.setBusy(status);
|
||||
this._startSSOLoginViewModel?.setBusy(status);
|
||||
this.emitChange("isBusy");
|
||||
}
|
||||
|
||||
async attemptLogin(loginMethod) {
|
||||
async attemptLogin(loginMethod: ILoginMethod): Promise<null> {
|
||||
this._setBusy(true);
|
||||
this._client.startWithLogin(loginMethod, {inspectAccountSetup: true});
|
||||
void this._client.startWithLogin(loginMethod, {inspectAccountSetup: true});
|
||||
const loadStatus = this._client.loadStatus;
|
||||
const handle = loadStatus.waitFor(status => status !== LoadStatus.Login);
|
||||
const handle = loadStatus.waitFor((status: LoadStatus) => status !== LoadStatus.Login);
|
||||
await handle.promise;
|
||||
this._setBusy(false);
|
||||
const status = loadStatus.get();
|
||||
|
@ -119,11 +163,11 @@ export class LoginViewModel extends ViewModel {
|
|||
this._hideHomeserver = true;
|
||||
this.emitChange("hideHomeserver");
|
||||
this._disposeViewModels();
|
||||
this._createLoadViewModel();
|
||||
void this._createLoadViewModel();
|
||||
return null;
|
||||
}
|
||||
|
||||
_createLoadViewModel() {
|
||||
private _createLoadViewModel(): void {
|
||||
this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription);
|
||||
this._loadViewModel = this.disposeTracked(this._loadViewModel);
|
||||
this._loadViewModel = this.track(
|
||||
|
@ -139,7 +183,7 @@ export class LoginViewModel extends ViewModel {
|
|||
})
|
||||
)
|
||||
);
|
||||
this._loadViewModel.start();
|
||||
void this._loadViewModel.start();
|
||||
this.emitChange("loadViewModel");
|
||||
this._loadViewModelSubscription = this.track(
|
||||
this._loadViewModel.disposableOn("change", () => {
|
||||
|
@ -151,22 +195,22 @@ export class LoginViewModel extends ViewModel {
|
|||
);
|
||||
}
|
||||
|
||||
_disposeViewModels() {
|
||||
this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel);
|
||||
private _disposeViewModels(): void {
|
||||
this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel);
|
||||
this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel);
|
||||
this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel);
|
||||
this.emitChange("disposeViewModels");
|
||||
}
|
||||
|
||||
async setHomeserver(newHomeserver) {
|
||||
async setHomeserver(newHomeserver: string): Promise<void> {
|
||||
this._homeserver = newHomeserver;
|
||||
// clear everything set by queryHomeserver
|
||||
this._loginOptions = null;
|
||||
this._queriedHomeserver = null;
|
||||
this._loginOptions = undefined;
|
||||
this._queriedHomeserver = undefined;
|
||||
this._showError("");
|
||||
this._disposeViewModels();
|
||||
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
|
||||
this.emitChange(); // multiple fields changing
|
||||
this.emitChange("loginViewModels"); // multiple fields changing
|
||||
// also clear the timeout if it is still running
|
||||
this.disposeTracked(this._abortHomeserverQueryTimeout);
|
||||
const timeout = this.clock.createTimeout(1000);
|
||||
|
@ -181,10 +225,10 @@ export class LoginViewModel extends ViewModel {
|
|||
}
|
||||
}
|
||||
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout);
|
||||
this.queryHomeserver();
|
||||
void this.queryHomeserver();
|
||||
}
|
||||
|
||||
async queryHomeserver() {
|
||||
async queryHomeserver(): Promise<void> {
|
||||
// don't repeat a query we've just done
|
||||
if (this._homeserver === this._queriedHomeserver || this._homeserver === "") {
|
||||
return;
|
||||
|
@ -210,7 +254,7 @@ export class LoginViewModel extends ViewModel {
|
|||
if (e.name === "AbortError") {
|
||||
return; //aborted, bail out
|
||||
} else {
|
||||
this._loginOptions = null;
|
||||
this._loginOptions = undefined;
|
||||
}
|
||||
} finally {
|
||||
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
|
||||
|
@ -228,12 +272,22 @@ export class LoginViewModel extends ViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
dispose(): void {
|
||||
super.dispose();
|
||||
if (this._client) {
|
||||
// if we move away before we're done with initial sync
|
||||
// delete the session
|
||||
this._client.deleteSession();
|
||||
void this._client.deleteSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ReadyFn = (client: Client) => void;
|
||||
|
||||
// TODO: move to Client.js when its converted to typescript.
|
||||
type LoginOptions = {
|
||||
homeserver: string;
|
||||
password?: (username: string, password: string) => PasswordLoginMethod;
|
||||
sso?: SSOLoginHelper;
|
||||
token?: (loginToken: string) => TokenLoginMethod;
|
||||
};
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../ViewModel.js";
|
||||
import {ViewModel} from "../ViewModel";
|
||||
import {LoginFailure} from "../../matrix/Client.js";
|
||||
|
||||
export class PasswordLoginViewModel extends ViewModel {
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../ViewModel.js";
|
||||
import {ViewModel} from "../ViewModel";
|
||||
|
||||
export class StartSSOLoginViewModel extends ViewModel{
|
||||
constructor(options) {
|
||||
|
|
|
@ -16,27 +16,49 @@ limitations under the License.
|
|||
|
||||
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
|
||||
|
||||
export class Navigation {
|
||||
constructor(allowsChild) {
|
||||
|
||||
type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean;
|
||||
|
||||
/**
|
||||
* OptionalValue is basically stating that if SegmentType[type] = true:
|
||||
* - Allow this type to be optional
|
||||
* - Give it a default value of undefined
|
||||
* - Also allow it to be true
|
||||
* This lets us do:
|
||||
* const s: Segment<SegmentType> = new Segment("create-room");
|
||||
* instead of
|
||||
* const s: Segment<SegmentType> = new Segment("create-room", undefined);
|
||||
*/
|
||||
export type OptionalValue<T> = T extends true? [(undefined | true)?]: [T];
|
||||
|
||||
export class Navigation<T extends object> {
|
||||
private readonly _allowsChild: AllowsChild<T>;
|
||||
private _path: Path<T>;
|
||||
private readonly _observables: Map<keyof T, SegmentObservable<T>> = new Map();
|
||||
private readonly _pathObservable: ObservableValue<Path<T>>;
|
||||
|
||||
constructor(allowsChild: AllowsChild<T>) {
|
||||
this._allowsChild = allowsChild;
|
||||
this._path = new Path([], allowsChild);
|
||||
this._observables = new Map();
|
||||
this._pathObservable = new ObservableValue(this._path);
|
||||
}
|
||||
|
||||
get pathObservable() {
|
||||
get pathObservable(): ObservableValue<Path<T>> {
|
||||
return this._pathObservable;
|
||||
}
|
||||
|
||||
get path() {
|
||||
get path(): Path<T> {
|
||||
return this._path;
|
||||
}
|
||||
|
||||
push(type, value = undefined) {
|
||||
return this.applyPath(this.path.with(new Segment(type, value)));
|
||||
push<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): void {
|
||||
const newPath = this.path.with(new Segment(type, ...value));
|
||||
if (newPath) {
|
||||
this.applyPath(newPath);
|
||||
}
|
||||
}
|
||||
|
||||
applyPath(path) {
|
||||
applyPath(path: Path<T>): void {
|
||||
// Path is not exported, so you can only create a Path through Navigation,
|
||||
// so we assume it respects the allowsChild rules
|
||||
const oldPath = this._path;
|
||||
|
@ -60,7 +82,7 @@ export class Navigation {
|
|||
this._pathObservable.set(this._path);
|
||||
}
|
||||
|
||||
observe(type) {
|
||||
observe(type: keyof T): SegmentObservable<T> {
|
||||
let observable = this._observables.get(type);
|
||||
if (!observable) {
|
||||
observable = new SegmentObservable(this, type);
|
||||
|
@ -69,9 +91,9 @@ export class Navigation {
|
|||
return observable;
|
||||
}
|
||||
|
||||
pathFrom(segments) {
|
||||
let parent;
|
||||
let i;
|
||||
pathFrom(segments: Segment<any>[]): Path<T> {
|
||||
let parent: Segment<any> | undefined;
|
||||
let i: number;
|
||||
for (i = 0; i < segments.length; i += 1) {
|
||||
if (!this._allowsChild(parent, segments[i])) {
|
||||
return new Path(segments.slice(0, i), this._allowsChild);
|
||||
|
@ -81,12 +103,12 @@ export class Navigation {
|
|||
return new Path(segments, this._allowsChild);
|
||||
}
|
||||
|
||||
segment(type, value) {
|
||||
return new Segment(type, value);
|
||||
segment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): Segment<T> {
|
||||
return new Segment(type, ...value);
|
||||
}
|
||||
}
|
||||
|
||||
function segmentValueEqual(a, b) {
|
||||
function segmentValueEqual<T>(a?: T[keyof T], b?: T[keyof T]): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
@ -103,24 +125,29 @@ function segmentValueEqual(a, b) {
|
|||
return false;
|
||||
}
|
||||
|
||||
export class Segment {
|
||||
constructor(type, value) {
|
||||
this.type = type;
|
||||
this.value = value === undefined ? true : value;
|
||||
|
||||
export class Segment<T, K extends keyof T = any> {
|
||||
public value: T[K];
|
||||
|
||||
constructor(public type: K, ...value: OptionalValue<T[K]>) {
|
||||
this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K];
|
||||
}
|
||||
}
|
||||
|
||||
class Path {
|
||||
constructor(segments = [], allowsChild) {
|
||||
class Path<T> {
|
||||
private readonly _segments: Segment<T, any>[];
|
||||
private readonly _allowsChild: AllowsChild<T>;
|
||||
|
||||
constructor(segments: Segment<T>[] = [], allowsChild: AllowsChild<T>) {
|
||||
this._segments = segments;
|
||||
this._allowsChild = allowsChild;
|
||||
}
|
||||
|
||||
clone() {
|
||||
clone(): Path<T> {
|
||||
return new Path(this._segments.slice(), this._allowsChild);
|
||||
}
|
||||
|
||||
with(segment) {
|
||||
with(segment: Segment<T>): Path<T> | undefined {
|
||||
let index = this._segments.length - 1;
|
||||
do {
|
||||
if (this._allowsChild(this._segments[index], segment)) {
|
||||
|
@ -132,10 +159,10 @@ class Path {
|
|||
index -= 1;
|
||||
} while(index >= -1);
|
||||
// allow -1 as well so we check if the segment is allowed as root
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
until(type) {
|
||||
until(type: keyof T): Path<T> {
|
||||
const index = this._segments.findIndex(s => s.type === type);
|
||||
if (index !== -1) {
|
||||
return new Path(this._segments.slice(0, index + 1), this._allowsChild)
|
||||
|
@ -143,11 +170,11 @@ class Path {
|
|||
return new Path([], this._allowsChild);
|
||||
}
|
||||
|
||||
get(type) {
|
||||
get(type: keyof T): Segment<T> | undefined {
|
||||
return this._segments.find(s => s.type === type);
|
||||
}
|
||||
|
||||
replace(segment) {
|
||||
replace(segment: Segment<T>): Path<T> | undefined {
|
||||
const index = this._segments.findIndex(s => s.type === segment.type);
|
||||
if (index !== -1) {
|
||||
const parent = this._segments[index - 1];
|
||||
|
@ -160,10 +187,10 @@ class Path {
|
|||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get segments() {
|
||||
get segments(): Segment<T>[] {
|
||||
return this._segments;
|
||||
}
|
||||
}
|
||||
|
@ -172,43 +199,49 @@ class Path {
|
|||
* custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet.
|
||||
* This ensures that observers of a segment can also read the most recent value of other segments.
|
||||
*/
|
||||
class SegmentObservable extends BaseObservableValue {
|
||||
constructor(navigation, type) {
|
||||
class SegmentObservable<T extends object> extends BaseObservableValue<T[keyof T] | undefined> {
|
||||
private readonly _navigation: Navigation<T>;
|
||||
private _type: keyof T;
|
||||
private _lastSetValue?: T[keyof T];
|
||||
|
||||
constructor(navigation: Navigation<T>, type: keyof T) {
|
||||
super();
|
||||
this._navigation = navigation;
|
||||
this._type = type;
|
||||
this._lastSetValue = navigation.path.get(type)?.value;
|
||||
}
|
||||
|
||||
get() {
|
||||
get(): T[keyof T] | undefined {
|
||||
const path = this._navigation.path;
|
||||
const segment = path.get(this._type);
|
||||
const value = segment?.value;
|
||||
return value;
|
||||
}
|
||||
|
||||
emitIfChanged() {
|
||||
emitIfChanged(): void {
|
||||
const newValue = this.get();
|
||||
if (!segmentValueEqual(newValue, this._lastSetValue)) {
|
||||
if (!segmentValueEqual<T>(newValue, this._lastSetValue)) {
|
||||
this._lastSetValue = newValue;
|
||||
this.emit(newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type {Path};
|
||||
|
||||
export function tests() {
|
||||
|
||||
function createMockNavigation() {
|
||||
return new Navigation((parent, {type}) => {
|
||||
switch (parent?.type) {
|
||||
case undefined:
|
||||
return type === "1" || "2";
|
||||
return type === "1" || type === "2";
|
||||
case "1":
|
||||
return type === "1.1";
|
||||
case "1.1":
|
||||
return type === "1.1.1";
|
||||
case "2":
|
||||
return type === "2.1" || "2.2";
|
||||
return type === "2.1" || type === "2.2";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
@ -216,7 +249,7 @@ export function tests() {
|
|||
}
|
||||
|
||||
function observeTypes(nav, types) {
|
||||
const changes = [];
|
||||
const changes: {type:string, value:any}[] = [];
|
||||
for (const type of types) {
|
||||
nav.observe(type).subscribe(value => {
|
||||
changes.push({type, value});
|
||||
|
@ -225,6 +258,12 @@ export function tests() {
|
|||
return changes;
|
||||
}
|
||||
|
||||
type SegmentType = {
|
||||
"foo": number;
|
||||
"bar": number;
|
||||
"baz": number;
|
||||
}
|
||||
|
||||
return {
|
||||
"applying a path emits an event on the observable": assert => {
|
||||
const nav = createMockNavigation();
|
||||
|
@ -242,18 +281,18 @@ export function tests() {
|
|||
assert.equal(changes[1].value, 8);
|
||||
},
|
||||
"path.get": assert => {
|
||||
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
||||
assert.equal(path.get("foo").value, 5);
|
||||
assert.equal(path.get("bar").value, 6);
|
||||
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
||||
assert.equal(path.get("foo")!.value, 5);
|
||||
assert.equal(path.get("bar")!.value, 6);
|
||||
},
|
||||
"path.replace success": assert => {
|
||||
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
||||
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
||||
const newPath = path.replace(new Segment("foo", 1));
|
||||
assert.equal(newPath.get("foo").value, 1);
|
||||
assert.equal(newPath.get("bar").value, 6);
|
||||
assert.equal(newPath!.get("foo")!.value, 1);
|
||||
assert.equal(newPath!.get("bar")!.value, 6);
|
||||
},
|
||||
"path.replace not found": assert => {
|
||||
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
||||
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
||||
const newPath = path.replace(new Segment("baz", 1));
|
||||
assert.equal(newPath, null);
|
||||
}
|
|
@ -14,28 +14,55 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
export class URLRouter {
|
||||
constructor({history, navigation, parseUrlPath, stringifyPath}) {
|
||||
import type {History} from "../../platform/web/dom/History.js";
|
||||
import type {Navigation, Segment, Path, OptionalValue} from "./Navigation";
|
||||
import type {SubscriptionHandle} from "../../observable/BaseObservable";
|
||||
|
||||
type ParseURLPath<T> = (urlPath: string, currentNavPath: Path<T>, defaultSessionId?: string) => Segment<T>[];
|
||||
type StringifyPath<T> = (path: Path<T>) => string;
|
||||
|
||||
export interface IURLRouter<T> {
|
||||
attach(): void;
|
||||
dispose(): void;
|
||||
pushUrl(url: string): void;
|
||||
tryRestoreLastUrl(): boolean;
|
||||
urlForSegments(segments: Segment<T>[]): string | undefined;
|
||||
urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined;
|
||||
urlUntilSegment(type: keyof T): string;
|
||||
urlForPath(path: Path<T>): string;
|
||||
openRoomActionUrl(roomId: string): string;
|
||||
createSSOCallbackURL(): string;
|
||||
normalizeUrl(): void;
|
||||
}
|
||||
|
||||
export class URLRouter<T extends {session: string | boolean}> implements IURLRouter<T> {
|
||||
private readonly _history: History;
|
||||
private readonly _navigation: Navigation<T>;
|
||||
private readonly _parseUrlPath: ParseURLPath<T>;
|
||||
private readonly _stringifyPath: StringifyPath<T>;
|
||||
private _subscription?: SubscriptionHandle;
|
||||
private _pathSubscription?: SubscriptionHandle;
|
||||
private _isApplyingUrl: boolean = false;
|
||||
private _defaultSessionId?: string;
|
||||
|
||||
constructor(history: History, navigation: Navigation<T>, parseUrlPath: ParseURLPath<T>, stringifyPath: StringifyPath<T>) {
|
||||
this._history = history;
|
||||
this._navigation = navigation;
|
||||
this._parseUrlPath = parseUrlPath;
|
||||
this._stringifyPath = stringifyPath;
|
||||
this._subscription = null;
|
||||
this._pathSubscription = null;
|
||||
this._isApplyingUrl = false;
|
||||
this._defaultSessionId = this._getLastSessionId();
|
||||
}
|
||||
|
||||
_getLastSessionId() {
|
||||
const navPath = this._urlAsNavPath(this._history.getLastUrl() || "");
|
||||
private _getLastSessionId(): string | undefined {
|
||||
const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
|
||||
const sessionId = navPath.get("session")?.value;
|
||||
if (typeof sessionId === "string") {
|
||||
return sessionId;
|
||||
}
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
attach() {
|
||||
attach(): void {
|
||||
this._subscription = this._history.subscribe(url => this._applyUrl(url));
|
||||
// subscribe to path before applying initial url
|
||||
// so redirects in _applyNavPathToHistory are reflected in url bar
|
||||
|
@ -43,12 +70,12 @@ export class URLRouter {
|
|||
this._applyUrl(this._history.get());
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._subscription = this._subscription();
|
||||
this._pathSubscription = this._pathSubscription();
|
||||
dispose(): void {
|
||||
if (this._subscription) { this._subscription = this._subscription(); }
|
||||
if (this._pathSubscription) { this._pathSubscription = this._pathSubscription(); }
|
||||
}
|
||||
|
||||
_applyNavPathToHistory(path) {
|
||||
private _applyNavPathToHistory(path: Path<T>): void {
|
||||
const url = this.urlForPath(path);
|
||||
if (url !== this._history.get()) {
|
||||
if (this._isApplyingUrl) {
|
||||
|
@ -60,7 +87,7 @@ export class URLRouter {
|
|||
}
|
||||
}
|
||||
|
||||
_applyNavPathToNavigation(navPath) {
|
||||
private _applyNavPathToNavigation(navPath: Path<T>): void {
|
||||
// this will cause _applyNavPathToHistory to be called,
|
||||
// so set a flag whether this request came from ourselves
|
||||
// (in which case it is a redirect if the url does not match the current one)
|
||||
|
@ -69,22 +96,22 @@ export class URLRouter {
|
|||
this._isApplyingUrl = false;
|
||||
}
|
||||
|
||||
_urlAsNavPath(url) {
|
||||
private _urlAsNavPath(url: string): Path<T> {
|
||||
const urlPath = this._history.urlAsPath(url);
|
||||
return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId));
|
||||
}
|
||||
|
||||
_applyUrl(url) {
|
||||
private _applyUrl(url: string): void {
|
||||
const navPath = this._urlAsNavPath(url);
|
||||
this._applyNavPathToNavigation(navPath);
|
||||
}
|
||||
|
||||
pushUrl(url) {
|
||||
pushUrl(url: string): void {
|
||||
this._history.pushUrl(url);
|
||||
}
|
||||
|
||||
tryRestoreLastUrl() {
|
||||
const lastNavPath = this._urlAsNavPath(this._history.getLastUrl() || "");
|
||||
tryRestoreLastUrl(): boolean {
|
||||
const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
|
||||
if (lastNavPath.segments.length !== 0) {
|
||||
this._applyNavPathToNavigation(lastNavPath);
|
||||
return true;
|
||||
|
@ -92,8 +119,8 @@ export class URLRouter {
|
|||
return false;
|
||||
}
|
||||
|
||||
urlForSegments(segments) {
|
||||
let path = this._navigation.path;
|
||||
urlForSegments(segments: Segment<T>[]): string | undefined {
|
||||
let path: Path<T> | undefined = this._navigation.path;
|
||||
for (const segment of segments) {
|
||||
path = path.with(segment);
|
||||
if (!path) {
|
||||
|
@ -103,29 +130,29 @@ export class URLRouter {
|
|||
return this.urlForPath(path);
|
||||
}
|
||||
|
||||
urlForSegment(type, value) {
|
||||
return this.urlForSegments([this._navigation.segment(type, value)]);
|
||||
urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined {
|
||||
return this.urlForSegments([this._navigation.segment(type, ...value)]);
|
||||
}
|
||||
|
||||
urlUntilSegment(type) {
|
||||
urlUntilSegment(type: keyof T): string {
|
||||
return this.urlForPath(this._navigation.path.until(type));
|
||||
}
|
||||
|
||||
urlForPath(path) {
|
||||
urlForPath(path: Path<T>): string {
|
||||
return this._history.pathAsUrl(this._stringifyPath(path));
|
||||
}
|
||||
|
||||
openRoomActionUrl(roomId) {
|
||||
openRoomActionUrl(roomId: string): string {
|
||||
// not a segment to navigation knowns about, so append it manually
|
||||
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
|
||||
return this._history.pathAsUrl(urlPath);
|
||||
}
|
||||
|
||||
createSSOCallbackURL() {
|
||||
createSSOCallbackURL(): string {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
normalizeUrl() {
|
||||
normalizeUrl(): void {
|
||||
// Remove any queryParameters from the URL
|
||||
// Gets rid of the loginToken after SSO
|
||||
this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`);
|
|
@ -14,25 +14,43 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {Navigation, Segment} from "./Navigation.js";
|
||||
import {URLRouter} from "./URLRouter.js";
|
||||
import {Navigation, Segment} from "./Navigation";
|
||||
import {URLRouter} from "./URLRouter";
|
||||
import type {Path, OptionalValue} from "./Navigation";
|
||||
|
||||
export function createNavigation() {
|
||||
export type SegmentType = {
|
||||
"login": true;
|
||||
"session": string | boolean;
|
||||
"sso": string;
|
||||
"logout": true;
|
||||
"room": string;
|
||||
"rooms": string[];
|
||||
"settings": true;
|
||||
"create-room": true;
|
||||
"empty-grid-tile": number;
|
||||
"lightbox": string;
|
||||
"right-panel": true;
|
||||
"details": true;
|
||||
"members": true;
|
||||
"member": string;
|
||||
};
|
||||
|
||||
export function createNavigation(): Navigation<SegmentType> {
|
||||
return new Navigation(allowsChild);
|
||||
}
|
||||
|
||||
export function createRouter({history, navigation}) {
|
||||
return new URLRouter({history, navigation, stringifyPath, parseUrlPath});
|
||||
export function createRouter({history, navigation}: {history: History, navigation: Navigation<SegmentType>}): URLRouter<SegmentType> {
|
||||
return new URLRouter(history, navigation, parseUrlPath, stringifyPath);
|
||||
}
|
||||
|
||||
function allowsChild(parent, child) {
|
||||
function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<SegmentType>): boolean {
|
||||
const {type} = child;
|
||||
switch (parent?.type) {
|
||||
case undefined:
|
||||
// allowed root segments
|
||||
return type === "login" || type === "session" || type === "sso";
|
||||
return type === "login" || type === "session" || type === "sso" || type === "logout";
|
||||
case "session":
|
||||
return type === "room" || type === "rooms" || type === "settings";
|
||||
return type === "room" || type === "rooms" || type === "settings" || type === "create-room";
|
||||
case "rooms":
|
||||
// downside of the approach: both of these will control which tile is selected
|
||||
return type === "room" || type === "empty-grid-tile";
|
||||
|
@ -45,8 +63,9 @@ function allowsChild(parent, child) {
|
|||
}
|
||||
}
|
||||
|
||||
export function removeRoomFromPath(path, roomId) {
|
||||
const rooms = path.get("rooms");
|
||||
export function removeRoomFromPath(path: Path<SegmentType>, roomId: string): Path<SegmentType> | undefined {
|
||||
let newPath: Path<SegmentType> | undefined = path;
|
||||
const rooms = newPath.get("rooms");
|
||||
let roomIdGridIndex = -1;
|
||||
// first delete from rooms segment
|
||||
if (rooms) {
|
||||
|
@ -54,22 +73,22 @@ export function removeRoomFromPath(path, roomId) {
|
|||
if (roomIdGridIndex !== -1) {
|
||||
const idsWithoutRoom = rooms.value.slice();
|
||||
idsWithoutRoom[roomIdGridIndex] = "";
|
||||
path = path.replace(new Segment("rooms", idsWithoutRoom));
|
||||
newPath = newPath.replace(new Segment("rooms", idsWithoutRoom));
|
||||
}
|
||||
}
|
||||
const room = path.get("room");
|
||||
const room = newPath!.get("room");
|
||||
// then from room (which occurs with or without rooms)
|
||||
if (room && room.value === roomId) {
|
||||
if (roomIdGridIndex !== -1) {
|
||||
path = path.with(new Segment("empty-grid-tile", roomIdGridIndex));
|
||||
newPath = newPath!.with(new Segment("empty-grid-tile", roomIdGridIndex));
|
||||
} else {
|
||||
path = path.until("session");
|
||||
newPath = newPath!.until("session");
|
||||
}
|
||||
}
|
||||
return path;
|
||||
return newPath;
|
||||
}
|
||||
|
||||
function roomsSegmentWithRoom(rooms, roomId, path) {
|
||||
function roomsSegmentWithRoom(rooms: Segment<SegmentType, "rooms">, roomId: string, path: Path<SegmentType>): Segment<SegmentType, "rooms"> {
|
||||
if(!rooms.value.includes(roomId)) {
|
||||
const emptyGridTile = path.get("empty-grid-tile");
|
||||
const oldRoom = path.get("room");
|
||||
|
@ -87,28 +106,28 @@ function roomsSegmentWithRoom(rooms, roomId, path) {
|
|||
}
|
||||
}
|
||||
|
||||
function pushRightPanelSegment(array, segment, value = true) {
|
||||
function pushRightPanelSegment<T extends keyof SegmentType>(array: Segment<SegmentType>[], segment: T, ...value: OptionalValue<SegmentType[T]>): void {
|
||||
array.push(new Segment("right-panel"));
|
||||
array.push(new Segment(segment, value));
|
||||
array.push(new Segment(segment, ...value));
|
||||
}
|
||||
|
||||
export function addPanelIfNeeded(navigation, path) {
|
||||
export function addPanelIfNeeded<T extends SegmentType>(navigation: Navigation<T>, path: Path<T>): Path<T> {
|
||||
const segments = navigation.path.segments;
|
||||
const i = segments.findIndex(segment => segment.type === "right-panel");
|
||||
let _path = path;
|
||||
if (i !== -1) {
|
||||
_path = path.until("room");
|
||||
_path = _path.with(segments[i]);
|
||||
_path = _path.with(segments[i + 1]);
|
||||
_path = _path.with(segments[i])!;
|
||||
_path = _path.with(segments[i + 1])!;
|
||||
}
|
||||
return _path;
|
||||
}
|
||||
|
||||
export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
|
||||
// substr(1) to take of initial /
|
||||
const parts = urlPath.substr(1).split("/");
|
||||
export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>, defaultSessionId?: string): Segment<SegmentType>[] {
|
||||
// substring(1) to take of initial /
|
||||
const parts = urlPath.substring(1).split("/");
|
||||
const iterator = parts[Symbol.iterator]();
|
||||
const segments = [];
|
||||
const segments: Segment<SegmentType>[] = [];
|
||||
let next;
|
||||
while (!(next = iterator.next()).done) {
|
||||
const type = next.value;
|
||||
|
@ -170,9 +189,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
|
|||
return segments;
|
||||
}
|
||||
|
||||
export function stringifyPath(path) {
|
||||
export function stringifyPath(path: Path<SegmentType>): string {
|
||||
let urlPath = "";
|
||||
let prevSegment;
|
||||
let prevSegment: Segment<SegmentType> | undefined;
|
||||
for (const segment of path.segments) {
|
||||
switch (segment.type) {
|
||||
case "rooms":
|
||||
|
@ -205,9 +224,15 @@ export function stringifyPath(path) {
|
|||
}
|
||||
|
||||
export function tests() {
|
||||
function createEmptyPath() {
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([]);
|
||||
return path;
|
||||
}
|
||||
|
||||
return {
|
||||
"stringify grid url with focused empty tile": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", "c"]),
|
||||
|
@ -217,7 +242,7 @@ export function tests() {
|
|||
assert.equal(urlPath, "/session/1/rooms/a,b,c/3");
|
||||
},
|
||||
"stringify grid url with focused room": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", "c"]),
|
||||
|
@ -227,7 +252,7 @@ export function tests() {
|
|||
assert.equal(urlPath, "/session/1/rooms/a,b,c/1");
|
||||
},
|
||||
"stringify url with right-panel and details segment": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", "c"]),
|
||||
|
@ -239,13 +264,15 @@ export function tests() {
|
|||
assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details");
|
||||
},
|
||||
"Parse loginToken query parameter into SSO segment": assert => {
|
||||
const segments = parseUrlPath("?loginToken=a1232aSD123");
|
||||
const path = createEmptyPath();
|
||||
const segments = parseUrlPath("?loginToken=a1232aSD123", path);
|
||||
assert.equal(segments.length, 1);
|
||||
assert.equal(segments[0].type, "sso");
|
||||
assert.equal(segments[0].value, "a1232aSD123");
|
||||
},
|
||||
"parse grid url path with focused empty tile": assert => {
|
||||
const segments = parseUrlPath("/session/1/rooms/a,b,c/3");
|
||||
const path = createEmptyPath();
|
||||
const segments = parseUrlPath("/session/1/rooms/a,b,c/3", path);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.equal(segments[0].type, "session");
|
||||
assert.equal(segments[0].value, "1");
|
||||
|
@ -255,7 +282,8 @@ export function tests() {
|
|||
assert.equal(segments[2].value, 3);
|
||||
},
|
||||
"parse grid url path with focused room": assert => {
|
||||
const segments = parseUrlPath("/session/1/rooms/a,b,c/1");
|
||||
const path = createEmptyPath();
|
||||
const segments = parseUrlPath("/session/1/rooms/a,b,c/1", path);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.equal(segments[0].type, "session");
|
||||
assert.equal(segments[0].value, "1");
|
||||
|
@ -265,7 +293,8 @@ export function tests() {
|
|||
assert.equal(segments[2].value, "b");
|
||||
},
|
||||
"parse empty grid url": assert => {
|
||||
const segments = parseUrlPath("/session/1/rooms/");
|
||||
const path = createEmptyPath();
|
||||
const segments = parseUrlPath("/session/1/rooms/", path);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.equal(segments[0].type, "session");
|
||||
assert.equal(segments[0].value, "1");
|
||||
|
@ -275,7 +304,8 @@ export function tests() {
|
|||
assert.equal(segments[2].value, 0);
|
||||
},
|
||||
"parse empty grid url with focus": assert => {
|
||||
const segments = parseUrlPath("/session/1/rooms//1");
|
||||
const path = createEmptyPath();
|
||||
const segments = parseUrlPath("/session/1/rooms//1", path);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.equal(segments[0].type, "session");
|
||||
assert.equal(segments[0].value, "1");
|
||||
|
@ -285,7 +315,7 @@ export function tests() {
|
|||
assert.equal(segments[2].value, 1);
|
||||
},
|
||||
"parse open-room action replacing the current focused room": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", "c"]),
|
||||
|
@ -301,7 +331,7 @@ export function tests() {
|
|||
assert.equal(segments[2].value, "d");
|
||||
},
|
||||
"parse open-room action changing focus to an existing room": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", "c"]),
|
||||
|
@ -317,7 +347,7 @@ export function tests() {
|
|||
assert.equal(segments[2].value, "a");
|
||||
},
|
||||
"parse open-room action changing focus to an existing room with details open": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", "c"]),
|
||||
|
@ -339,7 +369,7 @@ export function tests() {
|
|||
assert.equal(segments[4].value, true);
|
||||
},
|
||||
"open-room action should only copy over previous segments if there are no parts after open-room": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", "c"]),
|
||||
|
@ -361,7 +391,7 @@ export function tests() {
|
|||
assert.equal(segments[4].value, "foo");
|
||||
},
|
||||
"parse open-room action setting a room in an empty tile": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", "c"]),
|
||||
|
@ -377,82 +407,83 @@ export function tests() {
|
|||
assert.equal(segments[2].value, "d");
|
||||
},
|
||||
"parse session url path without id": assert => {
|
||||
const segments = parseUrlPath("/session");
|
||||
const path = createEmptyPath();
|
||||
const segments = parseUrlPath("/session", path);
|
||||
assert.equal(segments.length, 1);
|
||||
assert.equal(segments[0].type, "session");
|
||||
assert.strictEqual(segments[0].value, true);
|
||||
},
|
||||
"remove active room from grid path turns it into empty tile": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", "c"]),
|
||||
new Segment("room", "b")
|
||||
]);
|
||||
const newPath = removeRoomFromPath(path, "b");
|
||||
assert.equal(newPath.segments.length, 3);
|
||||
assert.equal(newPath.segments[0].type, "session");
|
||||
assert.equal(newPath.segments[0].value, 1);
|
||||
assert.equal(newPath.segments[1].type, "rooms");
|
||||
assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]);
|
||||
assert.equal(newPath.segments[2].type, "empty-grid-tile");
|
||||
assert.equal(newPath.segments[2].value, 1);
|
||||
assert.equal(newPath?.segments.length, 3);
|
||||
assert.equal(newPath?.segments[0].type, "session");
|
||||
assert.equal(newPath?.segments[0].value, 1);
|
||||
assert.equal(newPath?.segments[1].type, "rooms");
|
||||
assert.deepEqual(newPath?.segments[1].value, ["a", "", "c"]);
|
||||
assert.equal(newPath?.segments[2].type, "empty-grid-tile");
|
||||
assert.equal(newPath?.segments[2].value, 1);
|
||||
},
|
||||
"remove inactive room from grid path": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", "c"]),
|
||||
new Segment("room", "b")
|
||||
]);
|
||||
const newPath = removeRoomFromPath(path, "a");
|
||||
assert.equal(newPath.segments.length, 3);
|
||||
assert.equal(newPath.segments[0].type, "session");
|
||||
assert.equal(newPath.segments[0].value, 1);
|
||||
assert.equal(newPath.segments[1].type, "rooms");
|
||||
assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]);
|
||||
assert.equal(newPath.segments[2].type, "room");
|
||||
assert.equal(newPath.segments[2].value, "b");
|
||||
assert.equal(newPath?.segments.length, 3);
|
||||
assert.equal(newPath?.segments[0].type, "session");
|
||||
assert.equal(newPath?.segments[0].value, 1);
|
||||
assert.equal(newPath?.segments[1].type, "rooms");
|
||||
assert.deepEqual(newPath?.segments[1].value, ["", "b", "c"]);
|
||||
assert.equal(newPath?.segments[2].type, "room");
|
||||
assert.equal(newPath?.segments[2].value, "b");
|
||||
},
|
||||
"remove inactive room from grid path with empty tile": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("rooms", ["a", "b", ""]),
|
||||
new Segment("empty-grid-tile", 3)
|
||||
]);
|
||||
const newPath = removeRoomFromPath(path, "b");
|
||||
assert.equal(newPath.segments.length, 3);
|
||||
assert.equal(newPath.segments[0].type, "session");
|
||||
assert.equal(newPath.segments[0].value, 1);
|
||||
assert.equal(newPath.segments[1].type, "rooms");
|
||||
assert.deepEqual(newPath.segments[1].value, ["a", "", ""]);
|
||||
assert.equal(newPath.segments[2].type, "empty-grid-tile");
|
||||
assert.equal(newPath.segments[2].value, 3);
|
||||
assert.equal(newPath?.segments.length, 3);
|
||||
assert.equal(newPath?.segments[0].type, "session");
|
||||
assert.equal(newPath?.segments[0].value, 1);
|
||||
assert.equal(newPath?.segments[1].type, "rooms");
|
||||
assert.deepEqual(newPath?.segments[1].value, ["a", "", ""]);
|
||||
assert.equal(newPath?.segments[2].type, "empty-grid-tile");
|
||||
assert.equal(newPath?.segments[2].value, 3);
|
||||
},
|
||||
"remove active room": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("room", "b")
|
||||
]);
|
||||
const newPath = removeRoomFromPath(path, "b");
|
||||
assert.equal(newPath.segments.length, 1);
|
||||
assert.equal(newPath.segments[0].type, "session");
|
||||
assert.equal(newPath.segments[0].value, 1);
|
||||
assert.equal(newPath?.segments.length, 1);
|
||||
assert.equal(newPath?.segments[0].type, "session");
|
||||
assert.equal(newPath?.segments[0].value, 1);
|
||||
},
|
||||
"remove inactive room doesn't do anything": assert => {
|
||||
const nav = new Navigation(allowsChild);
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
const path = nav.pathFrom([
|
||||
new Segment("session", 1),
|
||||
new Segment("room", "b")
|
||||
]);
|
||||
const newPath = removeRoomFromPath(path, "a");
|
||||
assert.equal(newPath.segments.length, 2);
|
||||
assert.equal(newPath.segments[0].type, "session");
|
||||
assert.equal(newPath.segments[0].value, 1);
|
||||
assert.equal(newPath.segments[1].type, "room");
|
||||
assert.equal(newPath.segments[1].value, "b");
|
||||
assert.equal(newPath?.segments.length, 2);
|
||||
assert.equal(newPath?.segments[0].type, "session");
|
||||
assert.equal(newPath?.segments[0].value, 1);
|
||||
assert.equal(newPath?.segments[1].type, "room");
|
||||
assert.equal(newPath?.segments[1].value, "b");
|
||||
},
|
||||
|
||||
}
|
65
src/domain/rageshake.ts
Normal file
65
src/domain/rageshake.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type {BlobHandle} from "../platform/web/dom/BlobHandle";
|
||||
import type {RequestFunction} from "../platform/types/types";
|
||||
|
||||
// see https://github.com/matrix-org/rageshake#readme
|
||||
type RageshakeData = {
|
||||
// A textual description of the problem. Included in the details.log.gz file.
|
||||
text: string | undefined;
|
||||
// Application user-agent. Included in the details.log.gz file.
|
||||
userAgent: string;
|
||||
// Identifier for the application (eg 'riot-web'). Should correspond to a mapping configured in the configuration file for github issue reporting to work.
|
||||
app: string;
|
||||
// Application version. Included in the details.log.gz file.
|
||||
version: string;
|
||||
// Label to attach to the github issue, and include in the details file.
|
||||
label: string | undefined;
|
||||
};
|
||||
|
||||
export async function submitLogsToRageshakeServer(data: RageshakeData, logsBlob: BlobHandle, submitUrl: string, request: RequestFunction): Promise<void> {
|
||||
const formData = new Map<string, string | {name: string, blob: BlobHandle}>();
|
||||
if (data.text) {
|
||||
formData.set("text", data.text);
|
||||
}
|
||||
formData.set("user_agent", data.userAgent);
|
||||
formData.set("app", data.app);
|
||||
formData.set("version", data.version);
|
||||
if (data.label) {
|
||||
formData.set("label", data.label);
|
||||
}
|
||||
formData.set("file", {name: "logs.json", blob: logsBlob});
|
||||
const headers: Map<string, string> = new Map();
|
||||
headers.set("Accept", "application/json");
|
||||
const result = request(submitUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
response = await result.response();
|
||||
} catch (err) {
|
||||
throw new Error(`Could not submit logs to ${submitUrl}, got error ${err.message}`);
|
||||
}
|
||||
const {status, body} = response;
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new Error(`Could not submit logs to ${submitUrl}, got status code ${status} with body ${body}`);
|
||||
}
|
||||
// we don't bother with reading report_url from the body as the rageshake server doesn't always return it
|
||||
// and would have to have CORS setup properly for us to be able to read it.
|
||||
}
|
144
src/domain/session/CreateRoomViewModel.js
Normal file
144
src/domain/session/CreateRoomViewModel.js
Normal file
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
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";
|
||||
import {imageToInfo} from "./common.js";
|
||||
import {RoomType} from "../../matrix/room/common";
|
||||
|
||||
export class CreateRoomViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {session} = options;
|
||||
this._session = session;
|
||||
this._name = undefined;
|
||||
this._topic = undefined;
|
||||
this._roomAlias = undefined;
|
||||
this._isPublic = false;
|
||||
this._isEncrypted = true;
|
||||
this._isAdvancedShown = false;
|
||||
this._isFederationDisabled = false;
|
||||
this._avatarScaledBlob = undefined;
|
||||
this._avatarFileName = undefined;
|
||||
this._avatarInfo = undefined;
|
||||
}
|
||||
|
||||
get isPublic() { return this._isPublic; }
|
||||
get isEncrypted() { return this._isEncrypted; }
|
||||
get canCreate() { return !!this._name; }
|
||||
avatarUrl() { return this._avatarScaledBlob.url; }
|
||||
get avatarTitle() { return this._name; }
|
||||
get avatarLetter() { return ""; }
|
||||
get avatarColorNumber() { return 0; }
|
||||
get hasAvatar() { return !!this._avatarScaledBlob; }
|
||||
get isFederationDisabled() { return this._isFederationDisabled; }
|
||||
get isAdvancedShown() { return this._isAdvancedShown; }
|
||||
|
||||
setName(name) {
|
||||
this._name = name;
|
||||
this.emitChange("canCreate");
|
||||
}
|
||||
|
||||
setRoomAlias(roomAlias) {
|
||||
this._roomAlias = roomAlias;
|
||||
}
|
||||
|
||||
setTopic(topic) {
|
||||
this._topic = topic;
|
||||
}
|
||||
|
||||
setPublic(isPublic) {
|
||||
this._isPublic = isPublic;
|
||||
this.emitChange("isPublic");
|
||||
}
|
||||
|
||||
setEncrypted(isEncrypted) {
|
||||
this._isEncrypted = isEncrypted;
|
||||
this.emitChange("isEncrypted");
|
||||
}
|
||||
|
||||
setFederationDisabled(disable) {
|
||||
this._isFederationDisabled = disable;
|
||||
this.emitChange("isFederationDisabled");
|
||||
}
|
||||
|
||||
toggleAdvancedShown() {
|
||||
this._isAdvancedShown = !this._isAdvancedShown;
|
||||
this.emitChange("isAdvancedShown");
|
||||
}
|
||||
|
||||
create() {
|
||||
let avatar;
|
||||
if (this._avatarScaledBlob) {
|
||||
avatar = {
|
||||
info: this._avatarInfo,
|
||||
name: this._avatarFileName,
|
||||
blob: this._avatarScaledBlob
|
||||
}
|
||||
}
|
||||
const roomBeingCreated = this._session.createRoom({
|
||||
type: this.isPublic ? RoomType.Public : RoomType.Private,
|
||||
name: this._name ?? undefined,
|
||||
topic: this._topic ?? undefined,
|
||||
isEncrypted: !this.isPublic && this._isEncrypted,
|
||||
isFederationDisabled: this._isFederationDisabled,
|
||||
alias: this.isPublic ? ensureAliasIsLocalPart(this._roomAlias) : undefined,
|
||||
avatar,
|
||||
});
|
||||
this.navigation.push("room", roomBeingCreated.id);
|
||||
}
|
||||
|
||||
async selectAvatar() {
|
||||
if (!this.platform.hasReadPixelPermission()) {
|
||||
alert("Please allow canvas image data access, so we can scale your images down.");
|
||||
return;
|
||||
}
|
||||
if (this._avatarScaledBlob) {
|
||||
this._avatarScaledBlob.dispose();
|
||||
}
|
||||
this._avatarScaledBlob = undefined;
|
||||
this._avatarFileName = undefined;
|
||||
this._avatarInfo = undefined;
|
||||
|
||||
const file = await this.platform.openFile("image/*");
|
||||
if (!file || !file.blob.mimeType.startsWith("image/")) {
|
||||
// allow to clear the avatar by not selecting an image
|
||||
this.emitChange("hasAvatar");
|
||||
return;
|
||||
}
|
||||
let image = await this.platform.loadImage(file.blob);
|
||||
const limit = 800;
|
||||
if (image.maxDimension > limit) {
|
||||
const scaledImage = await image.scale(limit);
|
||||
image.dispose();
|
||||
image = scaledImage;
|
||||
}
|
||||
this._avatarScaledBlob = image.blob;
|
||||
this._avatarInfo = imageToInfo(image);
|
||||
this._avatarFileName = file.name;
|
||||
this.emitChange("hasAvatar");
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAliasIsLocalPart(roomAliasLocalPart) {
|
||||
if (roomAliasLocalPart.startsWith("#")) {
|
||||
roomAliasLocalPart = roomAliasLocalPart.substr(1);
|
||||
}
|
||||
const colonIdx = roomAliasLocalPart.indexOf(":");
|
||||
if (colonIdx !== -1) {
|
||||
roomAliasLocalPart = roomAliasLocalPart.substr(0, colonIdx);
|
||||
}
|
||||
return roomAliasLocalPart;
|
||||
}
|
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../ViewModel.js";
|
||||
import {addPanelIfNeeded} from "../navigation/index.js";
|
||||
import {ViewModel} from "../ViewModel";
|
||||
import {addPanelIfNeeded} from "../navigation/index";
|
||||
|
||||
function dedupeSparse(roomIds) {
|
||||
return roomIds.map((id, idx) => {
|
||||
|
@ -185,7 +185,7 @@ export class RoomGridViewModel extends ViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
import {createNavigation} from "../navigation/index.js";
|
||||
import {createNavigation} from "../navigation/index";
|
||||
import {ObservableValue} from "../../observable/ObservableValue";
|
||||
|
||||
export function tests() {
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {ObservableValue} from "../../observable/ObservableValue";
|
||||
import {RoomStatus} from "../../matrix/room/common";
|
||||
|
||||
/**
|
||||
Depending on the status of a room (invited, joined, archived, or none),
|
||||
|
@ -34,11 +35,11 @@ the now transferred child view model.
|
|||
This is also why there is an explicit initialize method, see comment there.
|
||||
*/
|
||||
export class RoomViewModelObservable extends ObservableValue {
|
||||
constructor(sessionViewModel, roomId) {
|
||||
constructor(sessionViewModel, roomIdOrLocalId) {
|
||||
super(null);
|
||||
this._statusSubscription = null;
|
||||
this._sessionViewModel = sessionViewModel;
|
||||
this.id = roomId;
|
||||
this.id = roomIdOrLocalId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -59,11 +60,21 @@ export class RoomViewModelObservable extends ObservableValue {
|
|||
}
|
||||
|
||||
async _statusToViewModel(status) {
|
||||
if (status.invited) {
|
||||
if (status & RoomStatus.Replaced) {
|
||||
if (status & RoomStatus.BeingCreated) {
|
||||
const {session} = this._sessionViewModel._client;
|
||||
const roomBeingCreated = session.roomsBeingCreated.get(this.id);
|
||||
this._sessionViewModel.notifyRoomReplaced(roomBeingCreated.id, roomBeingCreated.roomId);
|
||||
} else {
|
||||
throw new Error("Don't know how to replace a room with this status: " + (status ^ RoomStatus.Replaced));
|
||||
}
|
||||
} else if (status & RoomStatus.BeingCreated) {
|
||||
return this._sessionViewModel._createRoomBeingCreatedViewModel(this.id);
|
||||
} else if (status & RoomStatus.Invited) {
|
||||
return this._sessionViewModel._createInviteViewModel(this.id);
|
||||
} else if (status.joined) {
|
||||
return this._sessionViewModel._createRoomViewModel(this.id);
|
||||
} else if (status.archived) {
|
||||
} else if (status & RoomStatus.Joined) {
|
||||
return this._sessionViewModel._createRoomViewModelInstance(this.id);
|
||||
} else if (status & RoomStatus.Archived) {
|
||||
return await this._sessionViewModel._createArchivedRoomViewModel(this.id);
|
||||
} else {
|
||||
return this._sessionViewModel._createUnknownRoomViewModel(this.id);
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../ViewModel.js";
|
||||
import {ViewModel} from "../ViewModel";
|
||||
import {createEnum} from "../../utils/enum";
|
||||
import {ConnectionStatus} from "../../matrix/net/Reconnector";
|
||||
import {SyncStatus} from "../../matrix/Sync.js";
|
||||
|
@ -36,7 +36,7 @@ export class SessionStatusViewModel extends ViewModel {
|
|||
this._reconnector = reconnector;
|
||||
this._status = this._calculateState(reconnector.connectionStatus.get(), sync.status.get());
|
||||
this._session = session;
|
||||
this._setupSessionBackupUrl = this.urlCreator.urlForSegment("settings");
|
||||
this._setupKeyBackupUrl = this.urlCreator.urlForSegment("settings");
|
||||
this._dismissSecretStorage = false;
|
||||
}
|
||||
|
||||
|
@ -44,17 +44,17 @@ export class SessionStatusViewModel extends ViewModel {
|
|||
const update = () => this._updateStatus();
|
||||
this.track(this._sync.status.subscribe(update));
|
||||
this.track(this._reconnector.connectionStatus.subscribe(update));
|
||||
this.track(this._session.needsSessionBackup.subscribe(() => {
|
||||
this.track(this._session.needsKeyBackup.subscribe(() => {
|
||||
this.emitChange();
|
||||
}));
|
||||
}
|
||||
|
||||
get setupSessionBackupUrl () {
|
||||
return this._setupSessionBackupUrl;
|
||||
get setupKeyBackupUrl () {
|
||||
return this._setupKeyBackupUrl;
|
||||
}
|
||||
|
||||
get isShown() {
|
||||
return (this._session.needsSessionBackup.get() && !this._dismissSecretStorage) || this._status !== SessionStatus.Syncing;
|
||||
return (this._session.needsKeyBackup.get() && !this._dismissSecretStorage) || this._status !== SessionStatus.Syncing;
|
||||
}
|
||||
|
||||
get statusLabel() {
|
||||
|
@ -70,7 +70,7 @@ export class SessionStatusViewModel extends ViewModel {
|
|||
case SessionStatus.SyncError:
|
||||
return this.i18n`Sync failed because of ${this._sync.error}`;
|
||||
}
|
||||
if (this._session.needsSessionBackup.get()) {
|
||||
if (this._session.needsKeyBackup.get()) {
|
||||
return this.i18n`Set up session backup to decrypt older messages.`;
|
||||
}
|
||||
return "";
|
||||
|
@ -135,7 +135,7 @@ export class SessionStatusViewModel extends ViewModel {
|
|||
|
||||
get isSecretStorageShown() {
|
||||
// TODO: we need a model here where we can have multiple messages queued up and their buttons don't bleed into each other.
|
||||
return this._status === SessionStatus.Syncing && this._session.needsSessionBackup.get() && !this._dismissSecretStorage;
|
||||
return this._status === SessionStatus.Syncing && this._session.needsKeyBackup.get() && !this._dismissSecretStorage;
|
||||
}
|
||||
|
||||
get canDismiss() {
|
||||
|
|
|
@ -19,11 +19,13 @@ import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
|
|||
import {RoomViewModel} from "./room/RoomViewModel.js";
|
||||
import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js";
|
||||
import {InviteViewModel} from "./room/InviteViewModel.js";
|
||||
import {RoomBeingCreatedViewModel} from "./room/RoomBeingCreatedViewModel.js";
|
||||
import {LightboxViewModel} from "./room/LightboxViewModel.js";
|
||||
import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
|
||||
import {RoomGridViewModel} from "./RoomGridViewModel.js";
|
||||
import {SettingsViewModel} from "./settings/SettingsViewModel.js";
|
||||
import {ViewModel} from "../ViewModel.js";
|
||||
import {CreateRoomViewModel} from "./CreateRoomViewModel.js";
|
||||
import {ViewModel} from "../ViewModel";
|
||||
import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
|
||||
import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js";
|
||||
|
||||
|
@ -37,13 +39,11 @@ export class SessionViewModel extends ViewModel {
|
|||
reconnector: client.reconnector,
|
||||
session: client.session,
|
||||
})));
|
||||
this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({
|
||||
invites: this._client.session.invites,
|
||||
rooms: this._client.session.rooms
|
||||
})));
|
||||
this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({session: this._client.session})));
|
||||
this._settingsViewModel = null;
|
||||
this._roomViewModelObservable = null;
|
||||
this._gridViewModel = null;
|
||||
this._createRoomViewModel = null;
|
||||
this._setupNavigation();
|
||||
}
|
||||
|
||||
|
@ -75,6 +75,12 @@ export class SessionViewModel extends ViewModel {
|
|||
}));
|
||||
this._updateSettings(settings.get());
|
||||
|
||||
const createRoom = this.navigation.observe("create-room");
|
||||
this.track(createRoom.subscribe(createRoomOpen => {
|
||||
this._updateCreateRoom(createRoomOpen);
|
||||
}));
|
||||
this._updateCreateRoom(createRoom.get());
|
||||
|
||||
const lightbox = this.navigation.observe("lightbox");
|
||||
this.track(lightbox.subscribe(eventId => {
|
||||
this._updateLightbox(eventId);
|
||||
|
@ -96,7 +102,7 @@ export class SessionViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
get activeMiddleViewModel() {
|
||||
return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel;
|
||||
return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel || this._createRoomViewModel;
|
||||
}
|
||||
|
||||
get roomGridViewModel() {
|
||||
|
@ -119,11 +125,14 @@ export class SessionViewModel extends ViewModel {
|
|||
return this._roomViewModelObservable?.get();
|
||||
}
|
||||
|
||||
|
||||
get rightPanelViewModel() {
|
||||
return this._rightPanelViewModel;
|
||||
}
|
||||
|
||||
get createRoomViewModel() {
|
||||
return this._createRoomViewModel;
|
||||
}
|
||||
|
||||
_updateGrid(roomIds) {
|
||||
const changed = !(this._gridViewModel && roomIds);
|
||||
const currentRoomId = this.navigation.path.get("room");
|
||||
|
@ -162,7 +171,7 @@ export class SessionViewModel extends ViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
_createRoomViewModel(roomId) {
|
||||
_createRoomViewModelInstance(roomId) {
|
||||
const room = this._client.session.rooms.get(roomId);
|
||||
if (room) {
|
||||
const roomVM = new RoomViewModel(this.childOptions({room}));
|
||||
|
@ -200,6 +209,17 @@ export class SessionViewModel extends ViewModel {
|
|||
return null;
|
||||
}
|
||||
|
||||
_createRoomBeingCreatedViewModel(localId) {
|
||||
const roomBeingCreated = this._client.session.roomsBeingCreated.get(localId);
|
||||
if (roomBeingCreated) {
|
||||
return new RoomBeingCreatedViewModel(this.childOptions({
|
||||
roomBeingCreated,
|
||||
mediaRepository: this._client.session.mediaRepository,
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_updateRoom(roomId) {
|
||||
// opening a room and already open?
|
||||
if (this._roomViewModelObservable?.id === roomId) {
|
||||
|
@ -237,6 +257,16 @@ export class SessionViewModel extends ViewModel {
|
|||
this.emitChange("activeMiddleViewModel");
|
||||
}
|
||||
|
||||
_updateCreateRoom(createRoomOpen) {
|
||||
if (this._createRoomViewModel) {
|
||||
this._createRoomViewModel = this.disposeTracked(this._createRoomViewModel);
|
||||
}
|
||||
if (createRoomOpen) {
|
||||
this._createRoomViewModel = this.track(new CreateRoomViewModel(this.childOptions({session: this._client.session})));
|
||||
}
|
||||
this.emitChange("activeMiddleViewModel");
|
||||
}
|
||||
|
||||
_updateLightbox(eventId) {
|
||||
if (this._lightboxViewModel) {
|
||||
this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel);
|
||||
|
@ -263,9 +293,12 @@ export class SessionViewModel extends ViewModel {
|
|||
const enable = !!this.navigation.path.get("right-panel")?.value;
|
||||
if (enable) {
|
||||
const room = this._roomFromNavigation();
|
||||
this._rightPanelViewModel = this.track(new RightPanelViewModel(this.childOptions({room})));
|
||||
this._rightPanelViewModel = this.track(new RightPanelViewModel(this.childOptions({room, session: this._client.session})));
|
||||
}
|
||||
this.emitChange("rightPanelViewModel");
|
||||
}
|
||||
|
||||
notifyRoomReplaced(oldId, newId) {
|
||||
this.navigation.push("room", newId);
|
||||
}
|
||||
}
|
||||
|
|
24
src/domain/session/common.js
Normal file
24
src/domain/session/common.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export function imageToInfo(image) {
|
||||
return {
|
||||
w: image.width,
|
||||
h: image.height,
|
||||
mimetype: image.blob.mimeType,
|
||||
size: image.blob.size
|
||||
};
|
||||
}
|
|
@ -15,10 +15,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
|
||||
const KIND_ORDER = ["invite", "room"];
|
||||
const KIND_ORDER = ["roomBeingCreated", "invite", "room"];
|
||||
|
||||
export class BaseTileViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
/*
|
||||
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");
|
||||
|
@ -16,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {BaseTileViewModel} from "./BaseTileViewModel.js";
|
||||
import {comparePrimitive} from "./common";
|
||||
|
||||
export class InviteTileViewModel extends BaseTileViewModel {
|
||||
constructor(options) {
|
||||
|
@ -25,31 +25,40 @@ export class InviteTileViewModel extends BaseTileViewModel {
|
|||
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;
|
||||
}
|
||||
get busy() { return this._invite.accepting || this._invite.rejecting; }
|
||||
get kind() { return "invite"; }
|
||||
get url() { return this._url; }
|
||||
get name() { return this._invite.name; }
|
||||
get isHighlighted() { return true; }
|
||||
get isUnread() { return true; }
|
||||
get badgeCount() { return this.i18n`!`; }
|
||||
get _avatarSource() { return this._invite; }
|
||||
|
||||
/** very important that sorting order is stable and that comparing
|
||||
* to itself always returns 0, otherwise SortedMapList will
|
||||
* remove the wrong children, etc ... */
|
||||
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;
|
||||
const timeDiff = other._invite.timestamp - this._invite.timestamp;
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff;
|
||||
}
|
||||
return comparePrimitive(this._invite.id, other._invite.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
return {
|
||||
"test compare with timestamp": assert => {
|
||||
const urlCreator = {openRoomActionUrl() { return "";}}
|
||||
const vm1 = new InviteTileViewModel({invite: {timestamp: 500, id: "1"}, urlCreator});
|
||||
const vm2 = new InviteTileViewModel({invite: {timestamp: 250, id: "2"}, urlCreator});
|
||||
assert(vm1.compare(vm2) < 0);
|
||||
assert(vm2.compare(vm1) > 0);
|
||||
assert.equal(vm1.compare(vm1), 0);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,42 +15,47 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {RoomTileViewModel} from "./RoomTileViewModel.js";
|
||||
import {InviteTileViewModel} from "./InviteTileViewModel.js";
|
||||
import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js";
|
||||
import {RoomFilter} from "./RoomFilter.js";
|
||||
import {ApplyMap} from "../../../observable/map/ApplyMap.js";
|
||||
import {addPanelIfNeeded} from "../../navigation/index.js";
|
||||
import {addPanelIfNeeded} from "../../navigation/index";
|
||||
|
||||
export class LeftPanelViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {rooms, invites} = options;
|
||||
this._tileViewModelsMap = this._mapTileViewModels(rooms, invites);
|
||||
const {session} = options;
|
||||
this._tileViewModelsMap = this._mapTileViewModels(session.roomsBeingCreated, session.invites, session.rooms);
|
||||
this._tileViewModelsFilterMap = new ApplyMap(this._tileViewModelsMap);
|
||||
this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b));
|
||||
this._currentTileVM = null;
|
||||
this._setupNavigation();
|
||||
this._closeUrl = this.urlCreator.urlForSegment("session");
|
||||
this._settingsUrl = this.urlCreator.urlForSegment("settings");
|
||||
this._createRoomUrl = this.urlCreator.urlForSegment("create-room");
|
||||
}
|
||||
|
||||
_mapTileViewModels(rooms, invites) {
|
||||
_mapTileViewModels(roomsBeingCreated, invites, rooms) {
|
||||
// join is not commutative, invites will take precedence over rooms
|
||||
return invites.join(rooms).mapValues((roomOrInvite, emitChange) => {
|
||||
const allTiles = invites.join(roomsBeingCreated, rooms).mapValues((item, emitChange) => {
|
||||
let vm;
|
||||
if (roomOrInvite.isInvite) {
|
||||
vm = new InviteTileViewModel(this.childOptions({invite: roomOrInvite, emitChange}));
|
||||
if (item.isBeingCreated) {
|
||||
vm = new RoomBeingCreatedTileViewModel(this.childOptions({roomBeingCreated: item, emitChange}));
|
||||
} else if (item.isInvite) {
|
||||
vm = new InviteTileViewModel(this.childOptions({invite: item, emitChange}));
|
||||
} else {
|
||||
vm = new RoomTileViewModel(this.childOptions({room: roomOrInvite, emitChange}));
|
||||
vm = new RoomTileViewModel(this.childOptions({room: item, emitChange}));
|
||||
}
|
||||
const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id;
|
||||
const isOpen = this.navigation.path.get("room")?.value === item.id;
|
||||
if (isOpen) {
|
||||
vm.open();
|
||||
this._updateCurrentVM(vm);
|
||||
}
|
||||
return vm;
|
||||
});
|
||||
return allTiles;
|
||||
}
|
||||
|
||||
_updateCurrentVM(vm) {
|
||||
|
@ -69,6 +74,8 @@ export class LeftPanelViewModel extends ViewModel {
|
|||
return this._settingsUrl;
|
||||
}
|
||||
|
||||
get createRoomUrl() { return this._createRoomUrl; }
|
||||
|
||||
_setupNavigation() {
|
||||
const roomObservable = this.navigation.observe("room");
|
||||
this.track(roomObservable.subscribe(roomId => this._open(roomId)));
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
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";
|
||||
import {comparePrimitive} from "./common";
|
||||
|
||||
export class RoomBeingCreatedTileViewModel extends BaseTileViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {roomBeingCreated} = options;
|
||||
this._roomBeingCreated = roomBeingCreated;
|
||||
this._url = this.urlCreator.openRoomActionUrl(this._roomBeingCreated.id);
|
||||
}
|
||||
|
||||
get busy() { return !this._roomBeingCreated.error; }
|
||||
get kind() { return "roomBeingCreated"; }
|
||||
get isHighlighted() { return !this.busy; }
|
||||
get badgeCount() { return !this.busy && this.i18n`Failed`; }
|
||||
get url() { return this._url; }
|
||||
get name() { return this._roomBeingCreated.name; }
|
||||
get _avatarSource() { return this._roomBeingCreated; }
|
||||
|
||||
/** very important that sorting order is stable and that comparing
|
||||
* to itself always returns 0, otherwise SortedMapList will
|
||||
* remove the wrong children, etc ... */
|
||||
compare(other) {
|
||||
const parentCmp = super.compare(other);
|
||||
if (parentCmp !== 0) {
|
||||
return parentCmp;
|
||||
}
|
||||
const nameCmp = comparePrimitive(this.name, other.name);
|
||||
if (nameCmp === 0) {
|
||||
return comparePrimitive(this._roomBeingCreated.id, other._roomBeingCreated.id);
|
||||
} else {
|
||||
return nameCmp;
|
||||
}
|
||||
}
|
||||
|
||||
avatarUrl(size) {
|
||||
// allow blob url which doesn't need mxc => http resolution
|
||||
return this._roomBeingCreated.avatarBlobUrl ?? super.avatarUrl(size);
|
||||
}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
return {
|
||||
"test compare with names": assert => {
|
||||
const urlCreator = {openRoomActionUrl() { return "";}}
|
||||
const vm1 = new RoomBeingCreatedTileViewModel({roomBeingCreated: {name: "A", id: "1"}, urlCreator});
|
||||
const vm2 = new RoomBeingCreatedTileViewModel({roomBeingCreated: {name: "B", id: "2"}, urlCreator});
|
||||
assert(vm1.compare(vm2) < 0);
|
||||
assert(vm2.compare(vm1) > 0);
|
||||
assert.equal(vm1.compare(vm1), 0);
|
||||
},
|
||||
}
|
||||
}
|
|
@ -33,6 +33,9 @@ export class RoomTileViewModel extends BaseTileViewModel {
|
|||
return this._url;
|
||||
}
|
||||
|
||||
/** very important that sorting order is stable and that comparing
|
||||
* to itself always returns 0, otherwise SortedMapList will
|
||||
* remove the wrong children, etc ... */
|
||||
compare(other) {
|
||||
const parentComparison = super.compare(other);
|
||||
if (parentComparison !== 0) {
|
||||
|
|
23
src/domain/session/leftpanel/common.js
Normal file
23
src/domain/session/leftpanel/common.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
export function comparePrimitive(a, b) {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
} else {
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
}
|
|
@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {RoomType} from "../../../matrix/room/common";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
|
||||
export class MemberDetailsViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
|
@ -25,6 +26,7 @@ export class MemberDetailsViewModel extends ViewModel {
|
|||
this._member = this._observableMember.get();
|
||||
this._isEncrypted = options.isEncrypted;
|
||||
this._powerLevelsObservable = options.powerLevelsObservable;
|
||||
this._session = options.session;
|
||||
this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange()));
|
||||
this.track(this._observableMember.subscribe( () => this._onMemberChange()));
|
||||
}
|
||||
|
@ -77,6 +79,19 @@ export class MemberDetailsViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
get linkToUser() {
|
||||
return `https://matrix.to/#/${this._member.userId}`;
|
||||
return `https://matrix.to/#/${encodeURIComponent(this._member.userId)}`;
|
||||
}
|
||||
|
||||
async openDirectMessage() {
|
||||
const room = this._session.findDirectMessageForUserId(this.userId);
|
||||
let roomId = room?.id;
|
||||
if (!roomId) {
|
||||
const roomBeingCreated = await this._session.createRoom({
|
||||
type: RoomType.DirectMessage,
|
||||
invites: [this.userId]
|
||||
});
|
||||
roomId = roomBeingCreated.id;
|
||||
}
|
||||
this.navigation.push("room", roomId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {MemberTileViewModel} from "./MemberTileViewModel.js";
|
||||
import {createMemberComparator} from "./members/comparator.js";
|
||||
import {Disambiguator} from "./members/disambiguator.js";
|
||||
|
|
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
|
||||
export class MemberTileViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {RoomDetailsViewModel} from "./RoomDetailsViewModel.js";
|
||||
import {MemberListViewModel} from "./MemberListViewModel.js";
|
||||
import {MemberDetailsViewModel} from "./MemberDetailsViewModel.js";
|
||||
|
@ -23,6 +23,7 @@ export class RightPanelViewModel extends ViewModel {
|
|||
constructor(options) {
|
||||
super(options);
|
||||
this._room = options.room;
|
||||
this._session = options.session;
|
||||
this._members = null;
|
||||
this._setupNavigation();
|
||||
}
|
||||
|
@ -48,7 +49,13 @@ export class RightPanelViewModel extends ViewModel {
|
|||
}
|
||||
const isEncrypted = this._room.isEncrypted;
|
||||
const powerLevelsObservable = await this._room.observePowerLevels();
|
||||
return {observableMember, isEncrypted, powerLevelsObservable, mediaRepository: this._room.mediaRepository};
|
||||
return {
|
||||
observableMember,
|
||||
isEncrypted,
|
||||
powerLevelsObservable,
|
||||
mediaRepository: this._room.mediaRepository,
|
||||
session: this._session
|
||||
};
|
||||
}
|
||||
|
||||
_setupNavigation() {
|
||||
|
|
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
|
||||
export class RoomDetailsViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
|
|
|
@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
|
||||
export class ComposerViewModel extends ViewModel {
|
||||
constructor(roomVM) {
|
||||
super();
|
||||
super(roomVM.options);
|
||||
this._roomVM = roomVM;
|
||||
this._isEmpty = true;
|
||||
this._replyVM = null;
|
||||
|
@ -30,6 +30,7 @@ export class ComposerViewModel extends ViewModel {
|
|||
this._replyVM = this.disposeTracked(this._replyVM);
|
||||
if (entry) {
|
||||
this._replyVM = this.track(this._roomVM._createTile(entry));
|
||||
this._replyVM.notifyVisible();
|
||||
}
|
||||
this.emitChange("replyViewModel");
|
||||
this.emit("focus");
|
||||
|
|
|
@ -15,8 +15,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
|
||||
export class InviteViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
|
||||
export class LightboxViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
# "Room" view models
|
||||
|
||||
InviteViewModel and RoomViewModel are interchangebly used as "room view model":
|
||||
- SessionViewModel.roomViewModel can be an instance of either
|
||||
- RoomGridViewModel.roomViewModelAt(i) can return an instance of either
|
||||
InviteViewModel, RoomViewModel and RoomBeingCreatedViewModel are interchangebly used as "room view model":
|
||||
- SessionViewModel.roomViewModel can be an instance of any
|
||||
- RoomGridViewModel.roomViewModelAt(i) can return an instance of any
|
||||
|
||||
This is because they are accessed by the same url and need to transition into each other, in these two locations. Having two methods, especially in RoomGridViewModel would have been more cumbersome, even though this is not in line with how different view models are exposed in SessionViewModel.
|
||||
|
||||
They share an `id` and `kind` property, the latter can be used to differentiate them from the view, and a `focus` method.
|
||||
Once we convert this folder to typescript, we should use this interface for all the view models:
|
||||
```ts
|
||||
interface IGridItemViewModel {
|
||||
id: string;
|
||||
kind: string;
|
||||
focus();
|
||||
}
|
||||
```
|
||||
|
|
75
src/domain/session/room/RoomBeingCreatedViewModel.js
Normal file
75
src/domain/session/room/RoomBeingCreatedViewModel.js
Normal file
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
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 {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
|
||||
export class RoomBeingCreatedViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {roomBeingCreated, mediaRepository} = options;
|
||||
this._roomBeingCreated = roomBeingCreated;
|
||||
this._mediaRepository = mediaRepository;
|
||||
this._onRoomChange = this._onRoomChange.bind(this);
|
||||
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
||||
this._roomBeingCreated.on("change", this._onRoomChange);
|
||||
}
|
||||
|
||||
get kind() { return "roomBeingCreated"; }
|
||||
get closeUrl() { return this._closeUrl; }
|
||||
get name() { return this._roomBeingCreated.name; }
|
||||
get id() { return this._roomBeingCreated.id; }
|
||||
get isEncrypted() { return this._roomBeingCreated.isEncrypted; }
|
||||
get error() {
|
||||
const {error} = this._roomBeingCreated;
|
||||
if (error) {
|
||||
if (error.name === "ConnectionError") {
|
||||
return this.i18n`You seem to be offline`;
|
||||
} else {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
get avatarLetter() { return avatarInitials(this.name); }
|
||||
get avatarColorNumber() { return getIdentifierColorNumber(this._roomBeingCreated.avatarColorId); }
|
||||
get avatarTitle() { return this.name; }
|
||||
|
||||
avatarUrl(size) {
|
||||
// allow blob url which doesn't need mxc => http resolution
|
||||
return this._roomBeingCreated.avatarBlobUrl ??
|
||||
getAvatarHttpUrl(this._roomBeingCreated.avatarUrl, size, this.platform, this._mediaRepository);
|
||||
}
|
||||
|
||||
focus() {}
|
||||
|
||||
_onRoomChange() {
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this._roomBeingCreated.cancel();
|
||||
// navigate away from the room
|
||||
this.navigation.applyPath(this.navigation.path.until("session"));
|
||||
}
|
||||
|
||||
dispose() {
|
||||
super.dispose();
|
||||
this._roomBeingCreated.off("change", this._onRoomChange);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,25 +17,30 @@ limitations under the License.
|
|||
|
||||
import {TimelineViewModel} from "./timeline/TimelineViewModel.js";
|
||||
import {ComposerViewModel} from "./ComposerViewModel.js"
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
|
||||
import {tilesCreator} from "./timeline/tilesCreator.js";
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {imageToInfo} from "../common.js";
|
||||
// TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry
|
||||
// this is a breaking SDK change though to make this option mandatory
|
||||
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
|
||||
import {RoomStatus} from "../../../matrix/room/common";
|
||||
|
||||
export class RoomViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {room} = options;
|
||||
const {room, tileClassForEntry} = options;
|
||||
this._room = room;
|
||||
this._timelineVM = null;
|
||||
this._tilesCreator = null;
|
||||
this._tileClassForEntry = tileClassForEntry ?? defaultTileClassForEntry;
|
||||
this._tileOptions = undefined;
|
||||
this._onRoomChange = this._onRoomChange.bind(this);
|
||||
this._timelineError = null;
|
||||
this._sendError = null;
|
||||
this._composerVM = null;
|
||||
if (room.isArchived) {
|
||||
this._composerVM = new ArchivedViewModel(this.childOptions({archivedRoom: room}));
|
||||
this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room})));
|
||||
} else {
|
||||
this._composerVM = new ComposerViewModel(this);
|
||||
this._recreateComposerOnPowerLevelChange();
|
||||
}
|
||||
this._clearUnreadTimout = null;
|
||||
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
||||
|
@ -45,12 +50,13 @@ export class RoomViewModel extends ViewModel {
|
|||
this._room.on("change", this._onRoomChange);
|
||||
try {
|
||||
const timeline = await this._room.openTimeline();
|
||||
this._tilesCreator = tilesCreator(this.childOptions({
|
||||
this._tileOptions = this.childOptions({
|
||||
roomVM: this,
|
||||
timeline,
|
||||
}));
|
||||
tileClassForEntry: this._tileClassForEntry,
|
||||
});
|
||||
this._timelineVM = this.track(new TimelineViewModel(this.childOptions({
|
||||
tilesCreator: this._tilesCreator,
|
||||
tileOptions: this._tileOptions,
|
||||
timeline,
|
||||
})));
|
||||
this.emitChange("timelineViewModel");
|
||||
|
@ -62,6 +68,30 @@ export class RoomViewModel extends ViewModel {
|
|||
this._clearUnreadAfterDelay();
|
||||
}
|
||||
|
||||
async _recreateComposerOnPowerLevelChange() {
|
||||
const powerLevelObservable = await this._room.observePowerLevels();
|
||||
const canSendMessage = () => powerLevelObservable.get().canSendType("m.room.message");
|
||||
let oldCanSendMessage = canSendMessage();
|
||||
const recreateComposer = newCanSendMessage => {
|
||||
this._composerVM = this.disposeTracked(this._composerVM);
|
||||
if (newCanSendMessage) {
|
||||
this._composerVM = this.track(new ComposerViewModel(this));
|
||||
}
|
||||
else {
|
||||
this._composerVM = this.track(new LowerPowerLevelViewModel(this.childOptions()));
|
||||
}
|
||||
this.emitChange("powerLevelObservable")
|
||||
};
|
||||
this.track(powerLevelObservable.subscribe(() => {
|
||||
const newCanSendMessage = canSendMessage();
|
||||
if (oldCanSendMessage !== newCanSendMessage) {
|
||||
recreateComposer(newCanSendMessage);
|
||||
oldCanSendMessage = newCanSendMessage;
|
||||
}
|
||||
}));
|
||||
recreateComposer(oldCanSendMessage);
|
||||
}
|
||||
|
||||
async _clearUnreadAfterDelay() {
|
||||
if (this._room.isArchived || this._clearUnreadTimout) {
|
||||
return;
|
||||
|
@ -97,9 +127,8 @@ export class RoomViewModel extends ViewModel {
|
|||
// room doesn't tell us yet which fields changed,
|
||||
// so emit all fields originating from summary
|
||||
_onRoomChange() {
|
||||
if (this._room.isArchived) {
|
||||
this._composerVM.emitChange();
|
||||
}
|
||||
// propagate the update to the child view models so it's bindings can update based on room changes
|
||||
this._composerVM.emitChange();
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
|
@ -161,21 +190,97 @@ export class RoomViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
_createTile(entry) {
|
||||
return this._tilesCreator(entry);
|
||||
if (this._tileOptions) {
|
||||
const Tile = this._tileOptions.tileClassForEntry(entry);
|
||||
if (Tile) {
|
||||
return new Tile(entry, this._tileOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _processCommandJoin(roomName) {
|
||||
try {
|
||||
const roomId = await this._options.client.session.joinRoom(roomName);
|
||||
const roomStatusObserver = await this._options.client.session.observeRoomStatus(roomId);
|
||||
await roomStatusObserver.waitFor(status => status === RoomStatus.Joined);
|
||||
this.navigation.push("room", roomId);
|
||||
} catch (err) {
|
||||
let exc;
|
||||
if ((err.statusCode ?? err.status) === 400) {
|
||||
exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`);
|
||||
} else if ((err.statusCode ?? err.status) === 404 || (err.statusCode ?? err.status) === 502 || err.message == "Internal Server Error") {
|
||||
exc = new Error(`/join : room '${roomName}' not found`);
|
||||
} else if ((err.statusCode ?? err.status) === 403) {
|
||||
exc = new Error(`/join : you're not invited to join '${roomName}'`);
|
||||
} else {
|
||||
exc = err;
|
||||
}
|
||||
this._sendError = exc;
|
||||
this._timelineError = null;
|
||||
this.emitChange("error");
|
||||
}
|
||||
}
|
||||
|
||||
async _processCommand (message) {
|
||||
let msgtype;
|
||||
const [commandName, ...args] = message.substring(1).split(" ");
|
||||
switch (commandName) {
|
||||
case "me":
|
||||
message = args.join(" ");
|
||||
msgtype = "m.emote";
|
||||
break;
|
||||
case "join":
|
||||
if (args.length === 1) {
|
||||
const roomName = args[0];
|
||||
await this._processCommandJoin(roomName);
|
||||
} else {
|
||||
this._sendError = new Error("join syntax: /join <room-id>");
|
||||
this._timelineError = null;
|
||||
this.emitChange("error");
|
||||
}
|
||||
break;
|
||||
case "shrug":
|
||||
message = "¯\\_(ツ)_/¯ " + args.join(" ");
|
||||
msgtype = "m.text";
|
||||
break;
|
||||
case "tableflip":
|
||||
message = "(╯°□°)╯︵ ┻━┻ " + args.join(" ");
|
||||
msgtype = "m.text";
|
||||
break;
|
||||
case "unflip":
|
||||
message = "┬──┬ ノ( ゜-゜ノ) " + args.join(" ");
|
||||
msgtype = "m.text";
|
||||
break;
|
||||
case "lenny":
|
||||
message = "( ͡° ͜ʖ ͡°) " + args.join(" ");
|
||||
msgtype = "m.text";
|
||||
break;
|
||||
default:
|
||||
this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`);
|
||||
this._timelineError = null;
|
||||
this.emitChange("error");
|
||||
message = undefined;
|
||||
}
|
||||
return {type: msgtype, message: message};
|
||||
}
|
||||
|
||||
async _sendMessage(message, replyingTo) {
|
||||
if (!this._room.isArchived && message) {
|
||||
let messinfo = {type : "m.text", message : message};
|
||||
if (message.startsWith("//")) {
|
||||
messinfo.message = message.substring(1).trim();
|
||||
} else if (message.startsWith("/")) {
|
||||
messinfo = await this._processCommand(message);
|
||||
}
|
||||
try {
|
||||
let msgtype = "m.text";
|
||||
if (message.startsWith("/me ")) {
|
||||
message = message.substr(4).trim();
|
||||
msgtype = "m.emote";
|
||||
}
|
||||
if (replyingTo) {
|
||||
await replyingTo.reply(msgtype, message);
|
||||
} else {
|
||||
await this._room.sendEvent("m.room.message", {msgtype, body: message});
|
||||
const msgtype = messinfo.type;
|
||||
const message = messinfo.message;
|
||||
if (msgtype && message) {
|
||||
if (replyingTo) {
|
||||
await replyingTo.reply(msgtype, message);
|
||||
} else {
|
||||
await this._room.sendEvent("m.room.message", {msgtype, body: message});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`);
|
||||
|
@ -274,7 +379,9 @@ export class RoomViewModel extends ViewModel {
|
|||
let image = await this.platform.loadImage(file.blob);
|
||||
const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
|
||||
if (limit && image.maxDimension > limit) {
|
||||
image = await image.scale(limit);
|
||||
const scaledImage = await image.scale(limit);
|
||||
image.dispose();
|
||||
image = scaledImage;
|
||||
}
|
||||
const content = {
|
||||
body: file.name,
|
||||
|
@ -318,15 +425,11 @@ export class RoomViewModel extends ViewModel {
|
|||
this._composerVM.setReplyingTo(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function imageToInfo(image) {
|
||||
return {
|
||||
w: image.width,
|
||||
h: image.height,
|
||||
mimetype: image.blob.mimeType,
|
||||
size: image.blob.size
|
||||
};
|
||||
dismissError() {
|
||||
this._sendError = null;
|
||||
this.emitChange("error");
|
||||
}
|
||||
}
|
||||
|
||||
function videoToInfo(video) {
|
||||
|
@ -360,6 +463,16 @@ class ArchivedViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
get kind() {
|
||||
return "archived";
|
||||
return "disabled";
|
||||
}
|
||||
}
|
||||
|
||||
class LowerPowerLevelViewModel extends ViewModel {
|
||||
get description() {
|
||||
return this.i18n`You do not have the powerlevel necessary to send messages`;
|
||||
}
|
||||
|
||||
get kind() {
|
||||
return "disabled";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
|
||||
export class UnknownRoomViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { linkify } from "./linkify/linkify.js";
|
||||
import { getIdentifierColorNumber, avatarInitials } from "../../../avatar.js";
|
||||
import { getIdentifierColorNumber, avatarInitials } from "../../../avatar";
|
||||
|
||||
/**
|
||||
* Parse text into parts such as newline, links and text.
|
||||
|
|
|
@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import {ObservableMap} from "../../../../observable/map/ObservableMap.js";
|
||||
import {ObservableMap} from "../../../../observable/map/ObservableMap";
|
||||
|
||||
export class ReactionsViewModel {
|
||||
constructor(parentTile) {
|
||||
|
@ -222,10 +222,10 @@ export function tests() {
|
|||
};
|
||||
const tiles = new MappedList(timeline.entries, entry => {
|
||||
if (entry.eventType === "m.room.message") {
|
||||
return new BaseMessageTile({entry, roomVM: {room}, timeline, platform: {logger}});
|
||||
return new BaseMessageTile(entry, {roomVM: {room}, timeline, platform: {logger}});
|
||||
}
|
||||
return null;
|
||||
}, (tile, params, entry) => tile?.updateEntry(entry, params));
|
||||
}, (tile, params, entry) => tile?.updateEntry(entry, params, function () {}));
|
||||
return tiles;
|
||||
}
|
||||
|
||||
|
@ -254,8 +254,14 @@ export function tests() {
|
|||
// 2. setup queue & timeline
|
||||
const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api});
|
||||
const powerLevelsObservable = new ObservableValue(new PowerLevels({ ownUserId: alice, membership: "join" }));
|
||||
const timeline = new Timeline({roomId, storage, fragmentIdComparer,
|
||||
clock: new MockClock(), pendingEvents: queue.pendingEvents, powerLevelsObservable});
|
||||
const timeline = new Timeline({
|
||||
roomId,
|
||||
storage,
|
||||
fragmentIdComparer,
|
||||
clock: new MockClock(),
|
||||
pendingEvents: queue.pendingEvents,
|
||||
powerLevelsObservable
|
||||
});
|
||||
// 3. load the timeline, which will load the message with the reaction
|
||||
await timeline.load(new User(alice), "join", new NullLogItem());
|
||||
const tiles = mapMessageEntriesToBaseMessageTile(timeline, queue);
|
||||
|
|
|
@ -18,20 +18,27 @@ import {BaseObservableList} from "../../../../observable/list/BaseObservableList
|
|||
import {sortedIndex} from "../../../../utils/sortedIndex";
|
||||
|
||||
// maps 1..n entries to 0..1 tile. Entries are what is stored in the timeline, either an event or fragmentboundary
|
||||
// for now, tileCreator should be stable in whether it returns a tile or not.
|
||||
// for now, tileClassForEntry should be stable in whether it returns a tile or not.
|
||||
// e.g. the decision to create a tile or not should be based on properties
|
||||
// not updated later on (e.g. event type)
|
||||
// also see big comment in onUpdate
|
||||
export class TilesCollection extends BaseObservableList {
|
||||
constructor(entries, tileCreator) {
|
||||
constructor(entries, tileOptions) {
|
||||
super();
|
||||
this._entries = entries;
|
||||
this._tiles = null;
|
||||
this._entrySubscription = null;
|
||||
this._tileCreator = tileCreator;
|
||||
this._tileOptions = tileOptions;
|
||||
this._emitSpontanousUpdate = this._emitSpontanousUpdate.bind(this);
|
||||
}
|
||||
|
||||
_createTile(entry) {
|
||||
const Tile = this._tileOptions.tileClassForEntry(entry);
|
||||
if (Tile) {
|
||||
return new Tile(entry, this._tileOptions);
|
||||
}
|
||||
}
|
||||
|
||||
_emitSpontanousUpdate(tile, params) {
|
||||
const entry = tile.lowerEntry;
|
||||
const tileIdx = this._findTileIdx(entry);
|
||||
|
@ -48,7 +55,7 @@ export class TilesCollection extends BaseObservableList {
|
|||
let currentTile = null;
|
||||
for (let entry of this._entries) {
|
||||
if (!currentTile || !currentTile.tryIncludeEntry(entry)) {
|
||||
currentTile = this._tileCreator(entry);
|
||||
currentTile = this._createTile(entry);
|
||||
if (currentTile) {
|
||||
this._tiles.push(currentTile);
|
||||
}
|
||||
|
@ -121,7 +128,7 @@ export class TilesCollection extends BaseObservableList {
|
|||
return;
|
||||
}
|
||||
|
||||
const newTile = this._tileCreator(entry);
|
||||
const newTile = this._createTile(entry);
|
||||
if (newTile) {
|
||||
if (prevTile) {
|
||||
prevTile.updateNextSibling(newTile);
|
||||
|
@ -152,7 +159,7 @@ export class TilesCollection extends BaseObservableList {
|
|||
if (tile) {
|
||||
const action = tile.updateEntry(entry, params);
|
||||
if (action.shouldReplace) {
|
||||
const newTile = this._tileCreator(entry);
|
||||
const newTile = this._createTile(entry);
|
||||
if (newTile) {
|
||||
this._replaceTile(tileIdx, tile, newTile, action.updateParams);
|
||||
newTile.setUpdateEmit(this._emitSpontanousUpdate);
|
||||
|
@ -303,7 +310,10 @@ export function tests() {
|
|||
}
|
||||
}
|
||||
const entries = new ObservableArray([{n: 5}, {n: 10}]);
|
||||
const tiles = new TilesCollection(entries, entry => new UpdateOnSiblingTile(entry));
|
||||
const tileOptions = {
|
||||
tileClassForEntry: () => UpdateOnSiblingTile,
|
||||
};
|
||||
const tiles = new TilesCollection(entries, tileOptions);
|
||||
let receivedAdd = false;
|
||||
tiles.subscribe({
|
||||
onAdd(idx, tile) {
|
||||
|
@ -326,7 +336,10 @@ export function tests() {
|
|||
}
|
||||
}
|
||||
const entries = new ObservableArray([{n: 5}, {n: 10}, {n: 15}]);
|
||||
const tiles = new TilesCollection(entries, entry => new UpdateOnSiblingTile(entry));
|
||||
const tileOptions = {
|
||||
tileClassForEntry: () => UpdateOnSiblingTile,
|
||||
};
|
||||
const tiles = new TilesCollection(entries, tileOptions);
|
||||
const events = [];
|
||||
tiles.subscribe({
|
||||
onUpdate(idx, tile) {
|
||||
|
|
|
@ -32,14 +32,14 @@ to the room timeline, which unload entries from memory.
|
|||
when loading, it just reads events from a sortkey backwards or forwards...
|
||||
*/
|
||||
import {TilesCollection} from "./TilesCollection.js";
|
||||
import {ViewModel} from "../../../ViewModel.js";
|
||||
import {ViewModel} from "../../../ViewModel";
|
||||
|
||||
export class TimelineViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {timeline, tilesCreator} = options;
|
||||
const {timeline, tileOptions} = options;
|
||||
this._timeline = this.track(timeline);
|
||||
this._tiles = new TilesCollection(timeline.entries, tilesCreator);
|
||||
this._tiles = new TilesCollection(timeline.entries, tileOptions);
|
||||
this._startTile = null;
|
||||
this._endTile = null;
|
||||
this._topLoadingPromise = null;
|
||||
|
|
|
@ -34,8 +34,7 @@ const baseUrl = 'https://matrix.to';
|
|||
const linkPrefix = `${baseUrl}/#/`;
|
||||
|
||||
class Deserializer {
|
||||
constructor(result, mediaRepository, allowReplies) {
|
||||
this.allowReplies = allowReplies;
|
||||
constructor(result, mediaRepository) {
|
||||
this.result = result;
|
||||
this.mediaRepository = mediaRepository;
|
||||
}
|
||||
|
@ -289,7 +288,7 @@ class Deserializer {
|
|||
}
|
||||
|
||||
_isAllowedNode(node) {
|
||||
return this.allowReplies || !this._ensureElement(node, "MX-REPLY");
|
||||
return !this._ensureElement(node, "MX-REPLY");
|
||||
}
|
||||
|
||||
_parseInlineNodes(nodes, into) {
|
||||
|
@ -345,9 +344,9 @@ class Deserializer {
|
|||
}
|
||||
}
|
||||
|
||||
export function parseHTMLBody(platform, mediaRepository, allowReplies, html) {
|
||||
export function parseHTMLBody(platform, mediaRepository, html) {
|
||||
const parseResult = platform.parseHTML(html);
|
||||
const deserializer = new Deserializer(parseResult, mediaRepository, allowReplies);
|
||||
const deserializer = new Deserializer(parseResult, mediaRepository);
|
||||
const parts = deserializer.parseAnyNodes(parseResult.rootNodes);
|
||||
return new MessageBody(html, parts);
|
||||
}
|
||||
|
@ -401,8 +400,8 @@ export async function tests() {
|
|||
parseHTML: (html) => new HTMLParseResult(parse(html))
|
||||
};
|
||||
|
||||
function test(assert, input, output, replies=true) {
|
||||
assert.deepEqual(parseHTMLBody(platform, null, replies, input), new MessageBody(input, output));
|
||||
function test(assert, input, output) {
|
||||
assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output));
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -500,23 +499,14 @@ export async function tests() {
|
|||
];
|
||||
test(assert, input, output);
|
||||
},
|
||||
"Replies are inserted when allowed": assert => {
|
||||
const input = 'Hello, <em><mx-reply>World</mx-reply></em>!';
|
||||
const output = [
|
||||
new TextPart('Hello, '),
|
||||
new FormatPart("em", [new TextPart('World')]),
|
||||
new TextPart('!'),
|
||||
];
|
||||
test(assert, input, output);
|
||||
},
|
||||
"Replies are stripped when not allowed": assert => {
|
||||
"Reply fallback is always stripped": assert => {
|
||||
const input = 'Hello, <em><mx-reply>World</mx-reply></em>!';
|
||||
const output = [
|
||||
new TextPart('Hello, '),
|
||||
new FormatPart("em", []),
|
||||
new TextPart('!'),
|
||||
];
|
||||
test(assert, input, output, false);
|
||||
assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output));
|
||||
}
|
||||
/* Doesnt work: HTML library doesn't handle <pre><code> properly.
|
||||
"Text with code block": assert => {
|
||||
|
|
|
@ -21,14 +21,35 @@ const MAX_HEIGHT = 300;
|
|||
const MAX_WIDTH = 400;
|
||||
|
||||
export class BaseMediaTile extends BaseMessageTile {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
constructor(entry, options) {
|
||||
super(entry, options);
|
||||
this._decryptedThumbnail = null;
|
||||
this._decryptedFile = null;
|
||||
this._isVisible = false;
|
||||
this._error = null;
|
||||
if (!this.isPending) {
|
||||
this._tryLoadEncryptedThumbnail();
|
||||
this._downloading = false;
|
||||
this._downloadError = null;
|
||||
}
|
||||
|
||||
async downloadMedia() {
|
||||
if (this._downloading || this.isPending) {
|
||||
return;
|
||||
}
|
||||
const content = this._getContent();
|
||||
const filename = content.body;
|
||||
this._downloading = true;
|
||||
this.emitChange("status");
|
||||
let blob;
|
||||
try {
|
||||
blob = await this._mediaRepository.downloadAttachment(content);
|
||||
this.platform.saveFileAs(blob, filename);
|
||||
} catch (err) {
|
||||
this._downloadError = err;
|
||||
} finally {
|
||||
blob?.dispose();
|
||||
this._downloading = false;
|
||||
}
|
||||
this.emitChange("status");
|
||||
}
|
||||
|
||||
get isUploading() {
|
||||
|
@ -40,7 +61,7 @@ export class BaseMediaTile extends BaseMessageTile {
|
|||
return pendingEvent && Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100);
|
||||
}
|
||||
|
||||
get sendStatus() {
|
||||
get status() {
|
||||
const {pendingEvent} = this._entry;
|
||||
switch (pendingEvent?.status) {
|
||||
case SendStatus.Waiting:
|
||||
|
@ -55,11 +76,20 @@ export class BaseMediaTile extends BaseMessageTile {
|
|||
case SendStatus.Error:
|
||||
return this.i18n`Error: ${pendingEvent.error.message}`;
|
||||
default:
|
||||
if (this._downloadError) {
|
||||
return `Download failed`;
|
||||
}
|
||||
if (this._downloading) {
|
||||
return this.i18n`Downloading…`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
get thumbnailUrl() {
|
||||
if (!this._isVisible) {
|
||||
return "";
|
||||
}
|
||||
if (this._decryptedThumbnail) {
|
||||
return this._decryptedThumbnail.url;
|
||||
} else {
|
||||
|
@ -85,6 +115,15 @@ export class BaseMediaTile extends BaseMessageTile {
|
|||
return "";
|
||||
}
|
||||
|
||||
notifyVisible() {
|
||||
super.notifyVisible();
|
||||
this._isVisible = true;
|
||||
this.emitChange("thumbnailUrl");
|
||||
if (!this.isPending) {
|
||||
this._tryLoadEncryptedThumbnail();
|
||||
}
|
||||
}
|
||||
|
||||
get width() {
|
||||
const info = this._getContent()?.info;
|
||||
return Math.round(info?.w * this._scaleFactor());
|
||||
|
|
|
@ -16,23 +16,39 @@ limitations under the License.
|
|||
|
||||
import {SimpleTile} from "./SimpleTile.js";
|
||||
import {ReactionsViewModel} from "../ReactionsViewModel.js";
|
||||
import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar.js";
|
||||
import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar";
|
||||
|
||||
export class BaseMessageTile extends SimpleTile {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
constructor(entry, options) {
|
||||
super(entry, options);
|
||||
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
|
||||
this._isContinuation = false;
|
||||
this._reactions = null;
|
||||
this._replyTile = null;
|
||||
if (this._entry.annotations || this._entry.pendingAnnotations) {
|
||||
this._updateReactions();
|
||||
}
|
||||
this._updateReplyTileIfNeeded(undefined);
|
||||
}
|
||||
|
||||
notifyVisible() {
|
||||
super.notifyVisible();
|
||||
this._replyTile?.notifyVisible();
|
||||
}
|
||||
|
||||
|
||||
get _mediaRepository() {
|
||||
return this._room.mediaRepository;
|
||||
}
|
||||
|
||||
get permaLink() {
|
||||
return `https://matrix.to/#/${encodeURIComponent(this._room.id)}/${encodeURIComponent(this._entry.id)}`;
|
||||
}
|
||||
|
||||
get senderProfileLink() {
|
||||
return `https://matrix.to/#/${encodeURIComponent(this.sender)}`;
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this._entry.displayName || this.sender;
|
||||
}
|
||||
|
@ -82,6 +98,10 @@ export class BaseMessageTile extends SimpleTile {
|
|||
return this._entry.isUnverified;
|
||||
}
|
||||
|
||||
get isReply() {
|
||||
return this._entry.isReply;
|
||||
}
|
||||
|
||||
_getContent() {
|
||||
return this._entry.content;
|
||||
}
|
||||
|
@ -107,9 +127,29 @@ export class BaseMessageTile extends SimpleTile {
|
|||
if (action.shouldUpdate) {
|
||||
this._updateReactions();
|
||||
}
|
||||
this._updateReplyTileIfNeeded(param);
|
||||
return action;
|
||||
}
|
||||
|
||||
_updateReplyTileIfNeeded(param) {
|
||||
const replyEntry = this._entry.contextEntry;
|
||||
if (replyEntry) {
|
||||
// this is an update to contextEntry used for replyPreview
|
||||
const action = this._replyTile?.updateEntry(replyEntry, param);
|
||||
if (action?.shouldReplace || !this._replyTile) {
|
||||
this.disposeTracked(this._replyTile);
|
||||
const tileClassForEntry = this._options.tileClassForEntry;
|
||||
const ReplyTile = tileClassForEntry(replyEntry);
|
||||
if (ReplyTile) {
|
||||
this._replyTile = new ReplyTile(replyEntry, this._options);
|
||||
}
|
||||
}
|
||||
if(action?.shouldUpdate) {
|
||||
this._replyTile?.emitChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startReply() {
|
||||
this._roomVM.startReply(this._entry);
|
||||
}
|
||||
|
@ -202,4 +242,11 @@ export class BaseMessageTile extends SimpleTile {
|
|||
this._reactions.update(annotations, pendingAnnotations);
|
||||
}
|
||||
}
|
||||
|
||||
get replyTile() {
|
||||
if (!this._entry.contextEventId) {
|
||||
return null;
|
||||
}
|
||||
return this._replyTile;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,8 +21,8 @@ import {createEnum} from "../../../../../utils/enum";
|
|||
export const BodyFormat = createEnum("Plain", "Html");
|
||||
|
||||
export class BaseTextTile extends BaseMessageTile {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
constructor(entry, options) {
|
||||
super(entry, options);
|
||||
this._messageBody = null;
|
||||
this._format = null
|
||||
}
|
||||
|
@ -56,4 +56,5 @@ export class BaseTextTile extends BaseMessageTile {
|
|||
}
|
||||
return this._messageBody;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -20,8 +20,8 @@ import {formatSize} from "../../../../../utils/formatSize";
|
|||
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
|
||||
|
||||
export class FileTile extends BaseMessageTile {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
constructor(entry, options) {
|
||||
super(entry, options);
|
||||
this._downloadError = null;
|
||||
this._downloading = false;
|
||||
}
|
||||
|
|
|
@ -18,8 +18,8 @@ import {SimpleTile} from "./SimpleTile.js";
|
|||
import {UpdateAction} from "../UpdateAction.js";
|
||||
|
||||
export class GapTile extends SimpleTile {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
constructor(entry, options) {
|
||||
super(entry, options);
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._isAtTop = true;
|
||||
|
@ -125,7 +125,7 @@ export function tests() {
|
|||
tile.updateEntry(newEntry);
|
||||
}
|
||||
};
|
||||
const tile = new GapTile({entry: new FragmentBoundaryEntry(fragment, true), roomVM: {room}});
|
||||
const tile = new GapTile(new FragmentBoundaryEntry(fragment, true), {roomVM: {room}});
|
||||
await tile.fill();
|
||||
await tile.fill();
|
||||
await tile.fill();
|
||||
|
|
|
@ -18,8 +18,8 @@ limitations under the License.
|
|||
import {BaseMediaTile} from "./BaseMediaTile.js";
|
||||
|
||||
export class ImageTile extends BaseMediaTile {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
constructor(entry, options) {
|
||||
super(entry, options);
|
||||
this._lightboxUrl = this.urlCreator.urlForSegments([
|
||||
// ensure the right room is active if in grid view
|
||||
this.navigation.segment("room", this._room.id),
|
||||
|
|
|
@ -16,21 +16,43 @@ 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 shape() {
|
||||
return "location";
|
||||
}
|
||||
|
||||
get mapsLink() {
|
||||
const geoUri = this._getContent().geo_uri;
|
||||
const [lat, long] = geoUri.split(":")[1].split(",");
|
||||
return `maps:${lat} ${long}`;
|
||||
try {
|
||||
const url = new URL(this._getContent().geo_uri);
|
||||
if (url.protocol !== "geo:") {
|
||||
return "";
|
||||
}
|
||||
const [locationStr, ...namedParams] = url.pathname.split(";");
|
||||
const [latStr, longStr] = locationStr.split(",");
|
||||
const lat = parseFloat(latStr);
|
||||
const long = parseFloat(longStr);
|
||||
let uncertainty;
|
||||
for (const namedParam of namedParams) {
|
||||
const [name, value] = namedParam.split("=");
|
||||
if (name === "u") {
|
||||
uncertainty = parseFloat(value);
|
||||
}
|
||||
}
|
||||
if (this.platform.isIOS) {
|
||||
return `http://maps.apple.com/?ll=${lat},${long}`;
|
||||
} else {
|
||||
let uri = `geo:${lat},${long}`;
|
||||
if (uncertainty) {
|
||||
uri = uri + `;u=${uncertainty}`;
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
get label() {
|
||||
return `${this.sender} sent their location, click to see it in maps.`;
|
||||
return this.i18n`${this.displayName} sent their location`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,23 +66,25 @@ export class RoomMemberTile extends SimpleTile {
|
|||
export function tests() {
|
||||
return {
|
||||
"user removes display name": (assert) => {
|
||||
const tile = new RoomMemberTile({
|
||||
entry: {
|
||||
const tile = new RoomMemberTile(
|
||||
{
|
||||
prevContent: {displayname: "foo", membership: "join"},
|
||||
content: {membership: "join"},
|
||||
stateKey: "foo@bar.com",
|
||||
},
|
||||
});
|
||||
{}
|
||||
);
|
||||
assert.strictEqual(tile.announcement, "foo@bar.com removed their name (foo)");
|
||||
},
|
||||
"user without display name sets a new display name": (assert) => {
|
||||
const tile = new RoomMemberTile({
|
||||
entry: {
|
||||
const tile = new RoomMemberTile(
|
||||
{
|
||||
prevContent: {membership: "join"},
|
||||
content: {displayname: "foo", membership: "join" },
|
||||
stateKey: "foo@bar.com",
|
||||
},
|
||||
});
|
||||
{}
|
||||
);
|
||||
assert.strictEqual(tile.announcement, "foo@bar.com changed their name to foo");
|
||||
},
|
||||
};
|
||||
|
|
|
@ -15,13 +15,14 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {UpdateAction} from "../UpdateAction.js";
|
||||
import {ViewModel} from "../../../../ViewModel.js";
|
||||
import {ViewModel} from "../../../../ViewModel";
|
||||
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
|
||||
|
||||
export class SimpleTile extends ViewModel {
|
||||
constructor(options) {
|
||||
constructor(entry, options) {
|
||||
super(options);
|
||||
this._entry = options.entry;
|
||||
this._entry = entry;
|
||||
this._emitUpdate = undefined;
|
||||
}
|
||||
// view model props for all subclasses
|
||||
// hmmm, could also do instanceof ... ?
|
||||
|
@ -44,6 +45,10 @@ export class SimpleTile extends ViewModel {
|
|||
return this._entry.asEventKey();
|
||||
}
|
||||
|
||||
get eventId() {
|
||||
return this._entry.id;
|
||||
}
|
||||
|
||||
get isPending() {
|
||||
return this._entry.isPending;
|
||||
}
|
||||
|
@ -63,16 +68,20 @@ export class SimpleTile extends ViewModel {
|
|||
|
||||
// TilesCollection contract below
|
||||
setUpdateEmit(emitUpdate) {
|
||||
this.updateOptions({emitChange: paramName => {
|
||||
this._emitUpdate = emitUpdate;
|
||||
}
|
||||
|
||||
/** overrides the emitChange in ViewModel to also emit the update over the tiles collection */
|
||||
emitChange(changedProps) {
|
||||
if (this._emitUpdate) {
|
||||
// it can happen that after some network call
|
||||
// we switched away from the room and the response
|
||||
// comes in, triggering an emitChange in a tile that
|
||||
// has been disposed already (and hence the change
|
||||
// callback has been cleared by dispose) We should just ignore this.
|
||||
if (emitUpdate) {
|
||||
emitUpdate(this, paramName);
|
||||
}
|
||||
}});
|
||||
this._emitUpdate(this, changedProps);
|
||||
}
|
||||
super.emitChange(changedProps);
|
||||
}
|
||||
|
||||
get upperEntry() {
|
||||
|
|
|
@ -50,7 +50,7 @@ export class TextTile extends BaseTextTile {
|
|||
_parseBody(body, format) {
|
||||
let messageBody;
|
||||
if (format === BodyFormat.Html) {
|
||||
messageBody = parseHTMLBody(this.platform, this._mediaRepository, this._entry.isReply, body);
|
||||
messageBody = parseHTMLBody(this.platform, this._mediaRepository, body);
|
||||
} else {
|
||||
messageBody = parsePlainBody(body);
|
||||
}
|
||||
|
|
94
src/domain/session/room/timeline/tiles/index.ts
Normal file
94
src/domain/session/room/timeline/tiles/index.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
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 "./GapTile.js";
|
||||
import {TextTile} from "./TextTile.js";
|
||||
import {RedactedTile} from "./RedactedTile.js";
|
||||
import {ImageTile} from "./ImageTile.js";
|
||||
import {VideoTile} from "./VideoTile.js";
|
||||
import {FileTile} from "./FileTile.js";
|
||||
import {LocationTile} from "./LocationTile.js";
|
||||
import {RoomNameTile} from "./RoomNameTile.js";
|
||||
import {RoomMemberTile} from "./RoomMemberTile.js";
|
||||
import {EncryptedEventTile} from "./EncryptedEventTile.js";
|
||||
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
|
||||
import {MissingAttachmentTile} from "./MissingAttachmentTile.js";
|
||||
|
||||
import type {SimpleTile} from "./SimpleTile.js";
|
||||
import type {Room} from "../../../../../matrix/room/Room";
|
||||
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
|
||||
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
|
||||
import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry";
|
||||
import type {PendingEventEntry} from "../../../../../matrix/room/timeline/entries/PendingEventEntry";
|
||||
import type {Options as ViewModelOptions} from "../../../../ViewModel";
|
||||
|
||||
export type TimelineEntry = FragmentBoundaryEntry | EventEntry | PendingEventEntry;
|
||||
export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined;
|
||||
export type Options = ViewModelOptions & {
|
||||
room: Room,
|
||||
timeline: Timeline
|
||||
tileClassForEntry: TileClassForEntryFn;
|
||||
};
|
||||
export type TileConstructor = new (entry: TimelineEntry, options: Options) => SimpleTile;
|
||||
|
||||
export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undefined {
|
||||
if (entry.isGap) {
|
||||
return GapTile;
|
||||
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
|
||||
return MissingAttachmentTile;
|
||||
} else if (entry.eventType) {
|
||||
switch (entry.eventType) {
|
||||
case "m.room.message": {
|
||||
if (entry.isRedacted) {
|
||||
return RedactedTile;
|
||||
}
|
||||
const content = entry.content;
|
||||
const msgtype = content && content.msgtype;
|
||||
switch (msgtype) {
|
||||
case "m.text":
|
||||
case "m.notice":
|
||||
case "m.emote":
|
||||
return TextTile;
|
||||
case "m.image":
|
||||
return ImageTile;
|
||||
case "m.video":
|
||||
return VideoTile;
|
||||
case "m.file":
|
||||
return FileTile;
|
||||
case "m.location":
|
||||
return LocationTile;
|
||||
default:
|
||||
// unknown msgtype not rendered
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
case "m.room.name":
|
||||
return RoomNameTile;
|
||||
case "m.room.member":
|
||||
return RoomMemberTile;
|
||||
case "m.room.encrypted":
|
||||
if (entry.isRedacted) {
|
||||
return RedactedTile;
|
||||
}
|
||||
return EncryptedEventTile;
|
||||
case "m.room.encryption":
|
||||
return EncryptionEnabledTile;
|
||||
default:
|
||||
// unknown type not rendered
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,80 +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":
|
||||
if (entry.isRedacted) {
|
||||
return new RedactedTile(options);
|
||||
}
|
||||
return new EncryptedEventTile(options);
|
||||
case "m.room.encryption":
|
||||
return new EncryptionEnabledTile(options);
|
||||
default:
|
||||
// unknown type not rendered
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {KeyType} from "../../../matrix/ssss/index";
|
||||
import {createEnum} from "../../../utils/enum";
|
||||
|
||||
export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending");
|
||||
export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable");
|
||||
export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending");
|
||||
|
||||
export class SessionBackupViewModel extends ViewModel {
|
||||
export class KeyBackupViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._session = options.session;
|
||||
|
@ -28,8 +29,16 @@ export class SessionBackupViewModel extends ViewModel {
|
|||
this._isBusy = false;
|
||||
this._dehydratedDeviceId = undefined;
|
||||
this._status = undefined;
|
||||
this._backupOperation = this._session.keyBackup.flatMap(keyBackup => keyBackup.operationInProgress);
|
||||
this._progress = this._backupOperation.flatMap(op => op.progress);
|
||||
this.track(this._backupOperation.subscribe(() => {
|
||||
// see if needsNewKey might be set
|
||||
this._reevaluateStatus();
|
||||
this.emitChange("isBackingUp");
|
||||
}));
|
||||
this.track(this._progress.subscribe(() => this.emitChange("backupPercentage")));
|
||||
this._reevaluateStatus();
|
||||
this.track(this._session.hasSecretStorageKey.subscribe(() => {
|
||||
this.track(this._session.keyBackup.subscribe(() => {
|
||||
if (this._reevaluateStatus()) {
|
||||
this.emitChange("status");
|
||||
}
|
||||
|
@ -41,11 +50,11 @@ export class SessionBackupViewModel extends ViewModel {
|
|||
return false;
|
||||
}
|
||||
let status;
|
||||
const hasSecretStorageKey = this._session.hasSecretStorageKey.get();
|
||||
if (hasSecretStorageKey === true) {
|
||||
status = this._session.sessionBackup ? Status.Enabled : Status.SetupKey;
|
||||
} else if (hasSecretStorageKey === false) {
|
||||
status = Status.SetupKey;
|
||||
const keyBackup = this._session.keyBackup.get();
|
||||
if (keyBackup) {
|
||||
status = keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled;
|
||||
} else if (keyBackup === null) {
|
||||
status = this.showPhraseSetup() ? Status.SetupPhrase : Status.SetupKey;
|
||||
} else {
|
||||
status = Status.Pending;
|
||||
}
|
||||
|
@ -59,7 +68,7 @@ export class SessionBackupViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
get purpose() {
|
||||
return this.i18n`set up session backup`;
|
||||
return this.i18n`set up key backup`;
|
||||
}
|
||||
|
||||
offerDehydratedDeviceSetup() {
|
||||
|
@ -75,7 +84,28 @@ export class SessionBackupViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
get backupVersion() {
|
||||
return this._session.sessionBackup?.version;
|
||||
return this._session.keyBackup.get()?.version;
|
||||
}
|
||||
|
||||
get backupWriteStatus() {
|
||||
const keyBackup = this._session.keyBackup.get();
|
||||
if (!keyBackup) {
|
||||
return BackupWriteStatus.Pending;
|
||||
} else if (keyBackup.hasStopped) {
|
||||
return BackupWriteStatus.Stopped;
|
||||
}
|
||||
const operation = keyBackup.operationInProgress.get();
|
||||
if (operation) {
|
||||
return BackupWriteStatus.Writing;
|
||||
} else if (keyBackup.hasBackedUpAllKeys) {
|
||||
return BackupWriteStatus.Done;
|
||||
} else {
|
||||
return BackupWriteStatus.Pending;
|
||||
}
|
||||
}
|
||||
|
||||
get backupError() {
|
||||
return this._session.keyBackup.get()?.error?.message;
|
||||
}
|
||||
|
||||
get status() {
|
||||
|
@ -144,4 +174,33 @@ export class SessionBackupViewModel extends ViewModel {
|
|||
this.emitChange("");
|
||||
}
|
||||
}
|
||||
|
||||
get isBackingUp() {
|
||||
return !!this._backupOperation.get();
|
||||
}
|
||||
|
||||
get backupPercentage() {
|
||||
const progress = this._progress.get();
|
||||
if (progress) {
|
||||
return Math.round((progress.finished / progress.total) * 100);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
get backupInProgressLabel() {
|
||||
const progress = this._progress.get();
|
||||
if (progress) {
|
||||
return this.i18n`${progress.finished} of ${progress.total}`;
|
||||
}
|
||||
return this.i18n`…`;
|
||||
}
|
||||
|
||||
cancelBackup() {
|
||||
this._backupOperation.get()?.abort();
|
||||
}
|
||||
|
||||
startBackup() {
|
||||
this._session.keyBackup.get()?.flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel.js";
|
||||
import {SessionBackupViewModel} from "./SessionBackupViewModel.js";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {KeyBackupViewModel} from "./KeyBackupViewModel.js";
|
||||
import {submitLogsToRageshakeServer} from "../../../domain/rageshake";
|
||||
|
||||
class PushNotificationStatus {
|
||||
constructor() {
|
||||
|
@ -43,14 +44,15 @@ export class SettingsViewModel extends ViewModel {
|
|||
this._updateService = options.updateService;
|
||||
const {client} = options;
|
||||
this._client = client;
|
||||
this._sessionBackupViewModel = this.track(new SessionBackupViewModel(this.childOptions({session: this._session})));
|
||||
this._keyBackupViewModel = this.track(new KeyBackupViewModel(this.childOptions({session: this._session})));
|
||||
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
||||
this._estimate = null;
|
||||
this.sentImageSizeLimit = null;
|
||||
this.minSentImageSizeLimit = 400;
|
||||
this.maxSentImageSizeLimit = 4000;
|
||||
this.pushNotifications = new PushNotificationStatus();
|
||||
this._isLoggingOut = false;
|
||||
this._activeTheme = undefined;
|
||||
this._logsFeedbackMessage = undefined;
|
||||
}
|
||||
|
||||
get _session() {
|
||||
|
@ -58,14 +60,9 @@ export class SettingsViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
async logout() {
|
||||
this._isLoggingOut = true;
|
||||
await this._client.logout();
|
||||
this.emitChange("isLoggingOut");
|
||||
this.navigation.push("session", true);
|
||||
this.navigation.push("logout", this._client.sessionId);
|
||||
}
|
||||
|
||||
get isLoggingOut() { return this._isLoggingOut; }
|
||||
|
||||
setSentImageSizeLimit(size) {
|
||||
if (size > this.maxSentImageSizeLimit || size < this.minSentImageSizeLimit) {
|
||||
this.sentImageSizeLimit = null;
|
||||
|
@ -82,6 +79,9 @@ export class SettingsViewModel extends ViewModel {
|
|||
this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
|
||||
this.pushNotifications.supported = await this.platform.notificationService.supportsPush();
|
||||
this.pushNotifications.enabled = await this._session.arePushNotificationsEnabled();
|
||||
if (!import.meta.env.DEV) {
|
||||
this._activeTheme = await this.platform.themeLoader.getActiveTheme();
|
||||
}
|
||||
this.emitChange("");
|
||||
}
|
||||
|
||||
|
@ -121,8 +121,8 @@ export class SettingsViewModel extends ViewModel {
|
|||
return !!this.platform.updateService;
|
||||
}
|
||||
|
||||
get sessionBackupViewModel() {
|
||||
return this._sessionBackupViewModel;
|
||||
get keyBackupViewModel() {
|
||||
return this._keyBackupViewModel;
|
||||
}
|
||||
|
||||
get storageQuota() {
|
||||
|
@ -133,6 +133,14 @@ export class SettingsViewModel extends ViewModel {
|
|||
return this._formatBytes(this._estimate?.usage);
|
||||
}
|
||||
|
||||
get themeMapping() {
|
||||
return this.platform.themeLoader.themeMapping;
|
||||
}
|
||||
|
||||
get activeTheme() {
|
||||
return this._activeTheme;
|
||||
}
|
||||
|
||||
_formatBytes(n) {
|
||||
if (typeof n === "number") {
|
||||
return Math.round(n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
|
@ -146,6 +154,51 @@ export class SettingsViewModel extends ViewModel {
|
|||
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
|
||||
}
|
||||
|
||||
get canSendLogsToServer() {
|
||||
return !!this.platform.config.bugReportEndpointUrl;
|
||||
}
|
||||
|
||||
get logsServer() {
|
||||
const {bugReportEndpointUrl} = this.platform.config;
|
||||
try {
|
||||
if (bugReportEndpointUrl) {
|
||||
return new URL(bugReportEndpointUrl).hostname;
|
||||
}
|
||||
} catch (e) {}
|
||||
return "";
|
||||
}
|
||||
|
||||
async sendLogsToServer() {
|
||||
const {bugReportEndpointUrl} = this.platform.config;
|
||||
if (bugReportEndpointUrl) {
|
||||
this._logsFeedbackMessage = this.i18n`Sending logs…`;
|
||||
this.emitChange();
|
||||
try {
|
||||
const logExport = await this.logger.export();
|
||||
await submitLogsToRageshakeServer(
|
||||
{
|
||||
app: "hydrogen",
|
||||
userAgent: this.platform.description,
|
||||
version: DEFINE_VERSION,
|
||||
text: `Submit logs from settings for user ${this._session.userId} on device ${this._session.deviceId}`,
|
||||
},
|
||||
logExport.asBlob(),
|
||||
bugReportEndpointUrl,
|
||||
this.platform.request
|
||||
);
|
||||
this._logsFeedbackMessage = this.i18n`Logs sent succesfully!`;
|
||||
this.emitChange();
|
||||
} catch (err) {
|
||||
this._logsFeedbackMessage = err.message;
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get logsFeedbackMessage() {
|
||||
return this._logsFeedbackMessage;
|
||||
}
|
||||
|
||||
async togglePushNotifications() {
|
||||
this.pushNotifications.updating = true;
|
||||
this.pushNotifications.enabledOnServer = null;
|
||||
|
@ -175,5 +228,11 @@ export class SettingsViewModel extends ViewModel {
|
|||
this.emitChange("pushNotifications.serverError");
|
||||
}
|
||||
}
|
||||
|
||||
changeThemeOption(themeName, themeVariant) {
|
||||
this.platform.themeLoader.setTheme(themeName, themeVariant);
|
||||
// emit so that radio-buttons become displayed/hidden
|
||||
this.emitChange("themeOption");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,8 +2,6 @@
|
|||
<!-- this file contains all references to include in the SDK asset build (using vite.sdk-assets-config.js) -->
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="./platform/web/ui/css/main.css">
|
||||
<link rel="stylesheet" type="text/css" href="./platform/web/ui/css/themes/element/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<script type="module">
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue