Compare commits
661 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f485bfd55 | ||
|
|
a2fdab8eb9 | ||
|
|
2c052c162f | ||
|
|
b1c9e8c07a | ||
|
|
f71817b0a2 | ||
|
|
73d09bc99c | ||
|
|
5ebb54a857 | ||
|
|
8725b2c230 | ||
|
|
fd18f2acdf | ||
|
|
3bffe58549 | ||
|
|
e8bc22370b | ||
|
|
b7be3011da | ||
|
|
f0045c9406 | ||
|
|
3186b5f24b | ||
|
|
ca5ce7d468 | ||
|
|
a05f6a64a8 | ||
|
|
70dffe95ff | ||
|
|
0360889fd6 | ||
|
|
7304411c5d | ||
|
|
22dd095ea9 | ||
|
|
30a270193f | ||
|
|
ee1dd2293e | ||
|
|
34d5e88def | ||
|
|
30c9dfce02 | ||
|
|
48ad4d040d | ||
|
|
1b4f097b1c | ||
|
|
7b6193ab62 | ||
|
|
10a2733fd5 | ||
|
|
e7353e184f | ||
|
|
a479863f88 | ||
|
|
c550545116 | ||
|
|
1d7da9c455 | ||
|
|
5be0fdea0b | ||
|
|
a2a6eaf695 | ||
|
|
d08573b6b8 | ||
|
|
af7daee3e7 | ||
|
|
3406b46db5 | ||
|
|
2b45cf1f67 | ||
|
|
ba4258aa89 | ||
|
|
fc0a3f38ac | ||
|
|
ad96da59c3 | ||
|
|
c7ce689739 | ||
|
|
fa0a8d30e7 | ||
|
|
b57ef84e66 | ||
|
|
e5432ef260 | ||
|
|
719156aadf | ||
|
|
0720005c93 | ||
|
|
897f127fbd | ||
|
|
fd8ade1bf1 | ||
|
|
7f6b0f572b | ||
|
|
a4d982ea62 | ||
|
|
317f27e5f9 | ||
|
|
b2427bd810 | ||
|
|
4ac5c2c677 | ||
|
|
2234962acc | ||
|
|
8f95da4b07 | ||
|
|
102bde65ba | ||
|
|
3d5421819f | ||
|
|
5167cacee8 | ||
|
|
882eed0737 | ||
|
|
e82ed2cbcb | ||
|
|
05466fbd7f | ||
|
|
2bfd26b2b5 | ||
|
|
a17b62b14c | ||
|
|
88cffdb70e | ||
|
|
51ae1c819a | ||
|
|
2608f9558c | ||
|
|
8176d60d96 | ||
|
|
2ce99b969d | ||
|
|
8b97904144 | ||
|
|
0e34f9a464 | ||
|
|
c09380644b | ||
|
|
1dfffce606 | ||
|
|
7e98b19587 | ||
|
|
2a1689009a | ||
|
|
5ef3b055ff | ||
|
|
f554afd6b1 | ||
|
|
5474693711 | ||
|
|
f9a41be530 | ||
|
|
c61bc46673 | ||
|
|
dd304d3569 | ||
|
|
2eff251e0c | ||
|
|
531db48c25 | ||
|
|
9c0ce6526c | ||
|
|
96123ccf63 | ||
|
|
305c2cb806 | ||
|
|
9af122b96e | ||
|
|
7ca08f2f30 | ||
|
|
c7dbfca53d | ||
|
|
8aa66dddfd | ||
|
|
eb43b96a1b | ||
|
|
a2963adbee | ||
|
|
baebfdb0bb | ||
|
|
c3c2f409e7 | ||
|
|
89312ceb58 | ||
|
|
9b915d289b | ||
|
|
3de8f9077d | ||
|
|
90b4e44bbe | ||
|
|
bd25b7f3b7 | ||
|
|
85dfb3c1e5 | ||
|
|
d16e42374f | ||
|
|
d56b802786 | ||
|
|
93db217239 | ||
|
|
33ef680c41 | ||
|
|
a150619d08 | ||
|
|
7d5fb5f041 | ||
|
|
e824b3cfe2 | ||
|
|
cd885e3b3a | ||
|
|
005622800d | ||
|
|
aef4fd39b9 | ||
|
|
2e57eaad1d | ||
|
|
a5d5f75f52 | ||
|
|
130073689d | ||
|
|
2d99acabe2 | ||
|
|
0e5231ba43 | ||
|
|
e62d76a6f2 | ||
|
|
ce55ed8221 | ||
|
|
c5e7fe7bdc | ||
|
|
c723fae0e2 | ||
|
|
68172d12b0 | ||
|
|
44ce76bcb1 | ||
|
|
44b9bd0046 | ||
|
|
2e38558a9d | ||
|
|
a679bfcd95 | ||
|
|
44315f327b | ||
|
|
4f7724dbaf | ||
|
|
dc3cc33893 | ||
|
|
2537088099 | ||
|
|
02aaa06cb3 | ||
|
|
abf5121b74 | ||
|
|
cc7584a223 | ||
|
|
43b6351237 | ||
|
|
3b74920ece | ||
|
|
005762a1a2 | ||
|
|
5841c4f38d | ||
|
|
6acc84fd9e | ||
|
|
afc072da2c | ||
|
|
8634c16a47 | ||
|
|
0aa3359f96 | ||
|
|
077e5b2998 | ||
|
|
4b01000d4c | ||
|
|
949d28a88f | ||
|
|
57cde41983 | ||
|
|
cb5b3e9468 | ||
|
|
69f19d24a3 | ||
|
|
549c54e311 | ||
|
|
ec7f9effd8 | ||
|
|
1f4cc7bb19 | ||
|
|
1d78e2bc20 | ||
|
|
942800a2a6 | ||
|
|
414996c3f5 | ||
|
|
0c3dab8dd2 | ||
|
|
c48f9a69cc | ||
|
|
3277887089 | ||
|
|
304339f589 | ||
|
|
45cfdef45d | ||
|
|
f440c3f2c8 | ||
|
|
db74a486c5 | ||
|
|
4f36d149d7 | ||
|
|
3727bfb67f | ||
|
|
f26ab2f941 | ||
|
|
cf56b24dda | ||
|
|
2a8cb3c4e2 | ||
|
|
5478e648a7 | ||
|
|
b47d633727 | ||
|
|
810cdeeab4 | ||
|
|
075049abc4 | ||
|
|
56afbe6eb1 | ||
|
|
cf309102a2 | ||
|
|
32b37ed8f0 | ||
|
|
ce8ac0a81c | ||
|
|
4d8e0d7b85 | ||
|
|
6d7f52d2d6 | ||
|
|
e63b3d1b3e | ||
|
|
d77d953f84 | ||
|
|
689835cc17 | ||
|
|
6456a6b0c0 | ||
|
|
996c5f86c1 | ||
|
|
3fc8fe505b | ||
|
|
daeecc9b68 | ||
|
|
982398b32f | ||
|
|
fae4c504c9 | ||
|
|
b4a56f6dd7 | ||
|
|
fc26bef80a | ||
|
|
034552a063 | ||
|
|
bb505273f4 | ||
|
|
f876df6acc | ||
|
|
d097223d41 | ||
|
|
d01f7be58a | ||
|
|
d5375ca9ed | ||
|
|
eda8404144 | ||
|
|
e17a7cedb6 | ||
|
|
4ad4cff23f | ||
|
|
cc7a44dc17 | ||
|
|
873e68e1e1 | ||
|
|
4f44a68198 | ||
|
|
1eab957d85 | ||
|
|
4c145af7a3 | ||
|
|
7fab4ca1ba | ||
|
|
c1e45c4a30 | ||
|
|
5784a005dc | ||
|
|
a3e4d6998f | ||
|
|
32907764b3 | ||
|
|
cb34b1634d | ||
|
|
5199fd2566 | ||
|
|
b31c6c6780 | ||
|
|
aeec2c076e | ||
|
|
8bbce188ef | ||
|
|
dbdc010764 | ||
|
|
a81c48cc22 | ||
|
|
6eb77b7c2f | ||
|
|
92a50fe51d | ||
|
|
572caf6826 | ||
|
|
b0c8ceb302 | ||
|
|
c9ae6532a0 | ||
|
|
619e3c4852 | ||
|
|
e5cfcb601b | ||
|
|
2b92bf3694 | ||
|
|
cd42d09ea9 | ||
|
|
c56b1c8a86 | ||
|
|
e8d99e15f7 | ||
|
|
4dcec504ca | ||
|
|
1308e52e42 | ||
|
|
f6d356c5ce | ||
|
|
eb2de869b8 | ||
|
|
c6030d33ca | ||
|
|
655058a7e6 | ||
|
|
16d4ffbe3a | ||
|
|
775125c8a7 | ||
|
|
631e63a0b5 | ||
|
|
4cb2306de0 | ||
|
|
f15ee439a9 | ||
|
|
b9a2473d19 | ||
|
|
5b58223f9d | ||
|
|
f34fd0bd00 | ||
|
|
984b02700e | ||
|
|
e310392800 | ||
|
|
2cc291dccd | ||
|
|
2dcf043787 | ||
|
|
6b03ae0dc3 | ||
|
|
5dd5668389 | ||
|
|
8380894692 | ||
|
|
94f16b986a | ||
|
|
2928df8b8c | ||
|
|
71a819fcf0 | ||
|
|
713136672a | ||
|
|
f1bd47be8c | ||
|
|
2e82960ae6 | ||
|
|
a31fcd7346 | ||
|
|
4a1a53d3ab | ||
|
|
be173a838d | ||
|
|
623bd52e1f | ||
|
|
5ebdf3e878 | ||
|
|
761eee2cdc | ||
|
|
831e49919b | ||
|
|
6d90586aee | ||
|
|
a7f0ade83a | ||
|
|
c49e300247 | ||
|
|
6d8e34762e | ||
|
|
33461f5ac2 | ||
|
|
4e3345482f | ||
|
|
7dc6fb27ea | ||
|
|
5ced94755b | ||
|
|
0ffd860fdb | ||
|
|
05e786e3d6 | ||
|
|
d5e638c8f7 | ||
|
|
122ffeeab5 | ||
|
|
1448eac7c1 | ||
|
|
f2dbd5ff96 | ||
|
|
dcae5ad5f2 | ||
|
|
9bd3ade93d | ||
|
|
22dcb883b3 | ||
|
|
2e945780de | ||
|
|
9033b688ab | ||
|
|
1d4ed6609d | ||
|
|
b0269e310f | ||
|
|
74ccf7d820 | ||
|
|
2eae6243bb | ||
|
|
276532e2e1 | ||
|
|
fc07dd2af9 | ||
|
|
989712c2d5 | ||
|
|
ee43fcc91f | ||
|
|
18ca92cec4 | ||
|
|
dc11814695 | ||
|
|
17a31e0904 | ||
|
|
f990530031 | ||
|
|
46f1f0f8e9 | ||
|
|
885e933948 | ||
|
|
9b2e99c559 | ||
|
|
60ed54d6d3 | ||
|
|
939398b277 | ||
|
|
d2c820f080 | ||
|
|
375578177b | ||
|
|
eb9f2ccbaa | ||
|
|
d4b211e678 | ||
|
|
9fc4fbc3e7 | ||
|
|
1f5ac411f6 | ||
|
|
a7748a8492 | ||
|
|
edbcf95ead | ||
|
|
0aa29f775c | ||
|
|
a4a6105bc9 | ||
|
|
23098131b8 | ||
|
|
fdcedb5592 | ||
|
|
17098cf2ab | ||
|
|
7ef3dcc56c | ||
|
|
8a38276f5d | ||
|
|
21ec08ffbd | ||
|
|
1a7211198b | ||
|
|
4f9efb3563 | ||
|
|
190c57e853 | ||
|
|
785eca7289 | ||
|
|
2667e78b43 | ||
|
|
878b48aa7a | ||
|
|
b314e047c1 | ||
|
|
69cfa1db6d | ||
|
|
977016fbb2 | ||
|
|
fb3d9e2a16 | ||
|
|
8da492d00d | ||
|
|
9676014120 | ||
|
|
7d87b8d1e5 | ||
|
|
ecb139721b | ||
|
|
aa45261b0d | ||
|
|
017ec13981 | ||
|
|
880a2ca127 | ||
|
|
5282ab5f12 | ||
|
|
582e6637dc | ||
|
|
65804cd962 | ||
|
|
0411e1cac8 | ||
|
|
bab5c9aa42 | ||
|
|
d680a36cab | ||
|
|
25bde3560b | ||
|
|
ddac2ba5ef | ||
|
|
cd55098921 | ||
|
|
f1bdad0d7f | ||
|
|
9fac2c95e5 | ||
|
|
486d0abd30 | ||
|
|
d9bd48b9a6 | ||
|
|
64e30c89e3 | ||
|
|
1860eaae7a | ||
|
|
771424cbf0 | ||
|
|
925a909ec1 | ||
|
|
f07ee54e05 | ||
|
|
7ee2f630db | ||
|
|
626fdb9f79 | ||
|
|
2cf40ff0b8 | ||
|
|
9edc1acc90 | ||
|
|
641e6c53b6 | ||
|
|
14fbddf780 | ||
|
|
2a69b72bed | ||
|
|
e21094b525 | ||
|
|
da3d038547 | ||
|
|
c6b90803f8 | ||
|
|
93baa19ba1 | ||
|
|
9444f43c72 | ||
|
|
26251e1e60 | ||
|
|
5b3183cbd3 | ||
|
|
e9b963080c | ||
|
|
1164e6f1e7 | ||
|
|
21c7bb979e | ||
|
|
1ff9073a1a | ||
|
|
7ed2f9bd9a | ||
|
|
2cdbeb6f12 | ||
|
|
7bd95621f1 | ||
|
|
a05501a909 | ||
|
|
e6960a1e15 | ||
|
|
c057713004 | ||
|
|
35e2135e3c | ||
|
|
af74228f8e | ||
|
|
9a44790450 | ||
|
|
5c4bab2a8a | ||
|
|
94380b64bd | ||
|
|
cbfd03f9c6 | ||
|
|
edf58f1d7d | ||
|
|
17fed7cd9c | ||
|
|
266861bdad | ||
|
|
426e1a433b | ||
|
|
3b8dfcec51 | ||
|
|
6f892edd5e | ||
|
|
126bfec339 | ||
|
|
59938cd46b | ||
|
|
a445bcd0b9 | ||
|
|
2acb6825e9 | ||
|
|
7d44a1e979 | ||
|
|
aa1fabf857 | ||
|
|
c714a0608c | ||
|
|
92d15e110a | ||
|
|
1367ff9914 | ||
|
|
7a2d64c0ef | ||
|
|
60b5f7cab2 | ||
|
|
d81c52e9bb | ||
|
|
c54f1bd7a3 | ||
|
|
24f721e414 | ||
|
|
3e19843bf7 | ||
|
|
183eea9f24 | ||
|
|
548ea7220b | ||
|
|
8cd45b64a1 | ||
|
|
c33d97a2ed | ||
|
|
7926a1f9b9 | ||
|
|
c7da1177ab | ||
|
|
1e5539f165 | ||
|
|
d019add257 | ||
|
|
cc8ce7a05c | ||
|
|
6913fddcd3 | ||
|
|
c13040f0b0 | ||
|
|
b3285974f9 | ||
|
|
24a1091954 | ||
|
|
9fd7329554 | ||
|
|
2a19a9964d | ||
|
|
3fc9c1b74a | ||
|
|
f6f0c20b08 | ||
|
|
26a1c165d9 | ||
|
|
2af87fa8b8 | ||
|
|
d34c8d08a4 | ||
|
|
0f687fb8b8 | ||
|
|
603dd3786a | ||
|
|
9fbe4278c2 | ||
|
|
b222b4f708 | ||
|
|
abc2449b07 | ||
|
|
e6459de0d9 | ||
|
|
323505fbb4 | ||
|
|
2b06c6f2e6 | ||
|
|
5a56e46f7b | ||
|
|
abe9ece38f | ||
|
|
cb8d837370 | ||
|
|
500a19d655 | ||
|
|
0d3daf5fa3 | ||
|
|
66aede01dc | ||
|
|
6d7be57dcf | ||
|
|
5b913205af | ||
|
|
fd93d89b26 | ||
|
|
abdfcd879d | ||
|
|
b231424f96 | ||
|
|
b2418d5384 | ||
|
|
f2232a0740 | ||
|
|
04c6d990bd | ||
|
|
455bb09108 | ||
|
|
d8fe617535 | ||
|
|
970568fd17 | ||
|
|
f6677889e0 | ||
|
|
04780ab7aa | ||
|
|
b7df8019f0 | ||
|
|
0a9115248d | ||
|
|
27d492e9e2 | ||
|
|
bc22d36ef8 | ||
|
|
cf9625f33e | ||
|
|
446fd9c7c0 | ||
|
|
adc7892d8c | ||
|
|
f805f4ead6 | ||
|
|
00ffa1b6cd | ||
|
|
055fbe786d | ||
|
|
7a561bd034 | ||
|
|
5fb1f556d5 | ||
|
|
f4ba315cef | ||
|
|
9ba12da544 | ||
|
|
657096fd9a | ||
|
|
9374900ce0 | ||
|
|
7e5610eb36 | ||
|
|
1253638861 | ||
|
|
83feb28909 | ||
|
|
5422cb76f1 | ||
|
|
a6eb52ae76 | ||
|
|
4488947eed | ||
|
|
bf8f164f55 | ||
|
|
5487fbc048 | ||
|
|
a70dbb130f | ||
|
|
7edf544d73 | ||
|
|
ad3bde9920 | ||
|
|
85a98b3706 | ||
|
|
85e3f3761a | ||
|
|
f0b116714b | ||
|
|
dbef06269b | ||
|
|
894815268a | ||
|
|
8ecec0bc7e | ||
|
|
66839e02f6 | ||
|
|
bad8f36bf5 | ||
|
|
f5c50230a9 | ||
|
|
0136fd3cab | ||
|
|
2d18953344 | ||
|
|
d930ab869a | ||
|
|
dbdb82bd74 | ||
|
|
61309bacd9 | ||
|
|
b3e88d33a7 | ||
|
|
73fda641c8 | ||
|
|
be01a4bd81 | ||
|
|
0814e3c905 | ||
|
|
c7dd2e2093 | ||
|
|
cfa525f957 | ||
|
|
43d579744f | ||
|
|
48a008093b | ||
|
|
70c099c4b5 | ||
|
|
363f2340a0 | ||
|
|
3a6346aa63 | ||
|
|
9ef9680e07 | ||
|
|
e3cec93669 | ||
|
|
b6c926d2c8 | ||
|
|
c430ebb3a3 | ||
|
|
ae13814449 | ||
|
|
7a9ff98550 | ||
|
|
3d54047f87 | ||
|
|
dc75c1cfb4 | ||
|
|
e2aee0be81 | ||
|
|
44486aa62d | ||
|
|
a0e4de73cc | ||
|
|
38f9a79bd3 | ||
|
|
fc1aaf02bf | ||
|
|
c05b6c5118 | ||
|
|
72197c1a0a | ||
|
|
46bcb8ac75 | ||
|
|
2ba1bab82d | ||
|
|
3c56f7f481 | ||
|
|
fcd8a41fc9 | ||
|
|
35f8b1ed85 | ||
|
|
7969e13fc1 | ||
|
|
4d433ab22d | ||
|
|
d7f46607ad | ||
|
|
1e59390599 | ||
|
|
2457476bae | ||
|
|
35fb1e710b | ||
|
|
014b740e47 | ||
|
|
2b3c04592b | ||
|
|
ae50d57814 | ||
|
|
9900d661be | ||
|
|
369b59a203 | ||
|
|
6a18ba0110 | ||
|
|
0a49ddb31e | ||
|
|
25385edf12 | ||
|
|
721cccf152 | ||
|
|
3b017eb92b | ||
|
|
641b82dc45 | ||
|
|
42e2041d6f | ||
|
|
2c3ebd4c03 | ||
|
|
81a763f17f | ||
|
|
1ab7d27ba9 | ||
|
|
e76a805c8f | ||
|
|
9fc4af2bd7 | ||
|
|
0f3a7f9fd9 | ||
|
|
1cc634509b | ||
|
|
cb07ce32cb | ||
|
|
6866d662f7 | ||
|
|
51a2027d64 | ||
|
|
0f6b8f9bb1 | ||
|
|
63229ce2d7 | ||
|
|
1d620910c5 | ||
|
|
47357b3fc6 | ||
|
|
3ed35f9477 | ||
|
|
a369444b62 | ||
|
|
742d658021 | ||
|
|
681c24a0ca | ||
|
|
fc057bf988 | ||
|
|
51561e2f4e | ||
|
|
4168540017 | ||
|
|
942630c2fc | ||
|
|
9251cd9964 | ||
|
|
145826d1f3 | ||
|
|
5e42881c5c | ||
|
|
0824bfb4ed | ||
|
|
6ec9e4b666 | ||
|
|
ec447429c5 | ||
|
|
9c3e4907c8 | ||
|
|
cde352bcae | ||
|
|
2d2400edae | ||
|
|
7c80682b08 | ||
|
|
1d8cd8c3c8 | ||
|
|
a33d1364b6 | ||
|
|
a189f3ad98 | ||
|
|
f3cee359c0 | ||
|
|
be45c0319e | ||
|
|
dec47d21c0 | ||
|
|
c4f335ebb6 | ||
|
|
35c11660a3 | ||
|
|
089c891a55 | ||
|
|
3f60cd0386 | ||
|
|
ef8021e1a8 | ||
|
|
8ab68ed8c8 | ||
|
|
76b2e8b29e | ||
|
|
91366585ff | ||
|
|
21e4516bc3 | ||
|
|
f8b4331ec7 | ||
|
|
f7cb015390 | ||
|
|
f2c3c82d3a | ||
|
|
48f3f430da | ||
|
|
d6fb0e836d | ||
|
|
fddc8a1209 | ||
|
|
6032f6ba44 | ||
|
|
d1368f4622 | ||
|
|
6da369f3fe | ||
|
|
289f7285ae | ||
|
|
da69dd8320 | ||
|
|
d7d38c1ba9 | ||
|
|
abae58489c | ||
|
|
d4fec73d64 | ||
|
|
a8cb9f290a | ||
|
|
251f6a92a9 | ||
|
|
9163d5a25d | ||
|
|
3ee4058dce | ||
|
|
a8d6f21af9 | ||
|
|
78eff5fa9e | ||
|
|
6311a869f9 | ||
|
|
98355edf92 | ||
|
|
c71f37a8f8 | ||
|
|
97ab7ee2c0 | ||
|
|
d6567658c0 | ||
|
|
8df13ee7c8 | ||
|
|
36d59c98c0 | ||
|
|
f6b3d6830e | ||
|
|
a7ba511278 | ||
|
|
5819654bc7 | ||
|
|
3d571a00c6 | ||
|
|
3c30ca5f95 | ||
|
|
cb2cce243a | ||
|
|
19fe760833 | ||
|
|
5f4ac97787 | ||
|
|
e9fc90c55b | ||
|
|
096460ecfe | ||
|
|
86ccc2431e | ||
|
|
bcd58aae90 | ||
|
|
c609d42554 | ||
|
|
b3b73e9874 | ||
|
|
f6c5484d1b | ||
|
|
4efcc53628 | ||
|
|
4be14159c5 | ||
|
|
22f8fef87d | ||
|
|
d1e645fbc0 | ||
|
|
aa6bbbaaa0 | ||
|
|
627c64dca3 | ||
|
|
7d08ea2143 | ||
|
|
3cb59aebf5 | ||
|
|
2b1a523973 | ||
|
|
546ab06d60 | ||
|
|
0e407c08df | ||
|
|
ca18873a1b | ||
|
|
ebf61511f1 | ||
|
|
73eacdb23f | ||
|
|
3fac266013 | ||
|
|
6621e20da3 | ||
|
|
0adc4b3d66 | ||
|
|
8a452d80e2 | ||
|
|
71986f6001 | ||
|
|
eb4207e41d | ||
|
|
0fe38000f5 | ||
|
|
550c45b69e | ||
|
|
8be578763d | ||
|
|
3ec01293e6 | ||
|
|
3d3663c540 | ||
|
|
095b0287f0 | ||
|
|
d59f0e748d | ||
|
|
24ccfa0dd8 | ||
|
|
0e273a6dc5 | ||
|
|
f4936f221f | ||
|
|
ef8c28f274 | ||
|
|
eb620e9220 | ||
|
|
87e5cafb77 | ||
|
|
ffc5208865 | ||
|
|
fe724783ff | ||
|
|
658424efa0 | ||
|
|
ab73a351f8 | ||
|
|
d45d37b18a | ||
|
|
66e5ec976b | ||
|
|
b65874a6fc | ||
|
|
28b7e76ce0 | ||
|
|
31845dea10 |
25
.env
25
.env
@@ -1,25 +0,0 @@
|
|||||||
####
|
|
||||||
# App Config
|
|
||||||
# Environment files are documented here:
|
|
||||||
# https://vitejs.dev/guide/env-and-mode.html#env-files
|
|
||||||
####
|
|
||||||
|
|
||||||
# Used for determining the homeserver to use for short urls etc.
|
|
||||||
# VITE_DEFAULT_HOMESERVER=http://localhost:8008
|
|
||||||
|
|
||||||
# The room id for the space to use for listing public group call rooms
|
|
||||||
# VITE_PUBLIC_SPACE_ROOM_ID=!hjdfshkdskjdsk:myhomeserver.com
|
|
||||||
|
|
||||||
# The Sentry DSN to use for error reporting. Leave undefined to disable.
|
|
||||||
# VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
|
|
||||||
|
|
||||||
# VITE_CUSTOM_THEME=true
|
|
||||||
# VITE_PRIMARY_COLOR=#0dbd8b
|
|
||||||
# VITE_BG_COLOR_1=#ffffff
|
|
||||||
# VITE_BG_COLOR_2=#f0f1f4
|
|
||||||
# VITE_BG_COLOR_3=#dbdfe4
|
|
||||||
# VITE_BG_COLOR_4=#d1d3d7
|
|
||||||
# VITE_INPUT_BORDER_COLOR=#e7e7e7
|
|
||||||
# VITE_INPUT_BORDER_COLOR_FOCUSED=#238cf5
|
|
||||||
# VITE_TEXT_COLOR_1=#17191c
|
|
||||||
# VITE_TEXT_COLOR_2=#61708b
|
|
||||||
30
.env.example
Normal file
30
.env.example
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
####
|
||||||
|
# App Config
|
||||||
|
# Environment files are documented here:
|
||||||
|
# https://vitejs.dev/guide/env-and-mode.html#env-files
|
||||||
|
####
|
||||||
|
|
||||||
|
# Used for determining the homeserver to use for short urls etc.
|
||||||
|
# VITE_DEFAULT_HOMESERVER=http://localhost:8008
|
||||||
|
|
||||||
|
# Used for submitting debug logs to an external rageshake server
|
||||||
|
# VITE_RAGESHAKE_SUBMIT_URL=http://localhost:9110/api/submit
|
||||||
|
|
||||||
|
# The Sentry DSN to use for error reporting. Leave undefined to disable.
|
||||||
|
# VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
|
||||||
|
|
||||||
|
# VITE_CUSTOM_THEME=true
|
||||||
|
# VITE_THEME_ACCENT=#0dbd8b
|
||||||
|
# VITE_THEME_ACCENT_20=#0dbd8b33
|
||||||
|
# VITE_THEME_ALERT=#ff5b55
|
||||||
|
# VITE_THEME_ALERT_20=#ff5b5533
|
||||||
|
# VITE_THEME_LINKS=#0086e6
|
||||||
|
# VITE_THEME_PRIMARY_CONTENT=#ffffff
|
||||||
|
# VITE_THEME_SECONDARY_CONTENT=#a9b2bc
|
||||||
|
# VITE_THEME_TERTIARY_CONTENT=#8e99a4
|
||||||
|
# VITE_THEME_TERTIARY_CONTENT_20=#8e99a433
|
||||||
|
# VITE_THEME_QUATERNARY_CONTENT=#6f7882
|
||||||
|
# VITE_THEME_QUINARY_CONTENT=#394049
|
||||||
|
# VITE_THEME_SYSTEM=#21262c
|
||||||
|
# VITE_THEME_BACKGROUND=#15191e
|
||||||
|
# VITE_THEME_BACKGROUND_85=#15191ed9
|
||||||
38
.eslintrc.js
Normal file
38
.eslintrc.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
"matrix-org",
|
||||||
|
],
|
||||||
|
extends: [
|
||||||
|
"plugin:matrix-org/react",
|
||||||
|
"plugin:matrix-org/a11y",
|
||||||
|
"prettier",
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module",
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"jsx-a11y/media-has-caption": ["off"],
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
"src/**/*.{ts,tsx}",
|
||||||
|
],
|
||||||
|
extends: [
|
||||||
|
"plugin:matrix-org/typescript",
|
||||||
|
"plugin:matrix-org/react",
|
||||||
|
"prettier",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @vector-im/element-call-reviewers
|
||||||
67
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
labels: [T-Defect]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to fill out this bug report!
|
||||||
|
|
||||||
|
Please report security issues by email to security@matrix.org
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction-steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Please attach screenshots, videos or logs if you can.
|
||||||
|
placeholder: Tell us what you see!
|
||||||
|
value: |
|
||||||
|
1. Where are you starting? What can you see?
|
||||||
|
2. What do you click?
|
||||||
|
3. More steps…
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: result
|
||||||
|
attributes:
|
||||||
|
label: Outcome
|
||||||
|
placeholder: Tell us what went wrong
|
||||||
|
value: |
|
||||||
|
#### What did you expect?
|
||||||
|
|
||||||
|
#### What happened instead?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Operating system
|
||||||
|
placeholder: Windows, macOS, Ubuntu, Android…
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: browser
|
||||||
|
attributes:
|
||||||
|
label: Browser information
|
||||||
|
description: Which browser are you using? Which version?
|
||||||
|
placeholder: e.g. Chromium Version 92.0.4515.131
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: webapp-url
|
||||||
|
attributes:
|
||||||
|
label: URL for webapp
|
||||||
|
description: Which URL are you using to access the webapp? If a private server, tell us what version of Element Call you are using.
|
||||||
|
placeholder: e.g. call.element.io
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: dropdown
|
||||||
|
id: rageshake
|
||||||
|
attributes:
|
||||||
|
label: Will you send logs?
|
||||||
|
description: |
|
||||||
|
To send them, press the 'Submit Feedback' button and check 'Include Debug Logs'. Please link to this issue in the description field.
|
||||||
|
options:
|
||||||
|
- 'Yes'
|
||||||
|
- 'No'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Questions & support
|
||||||
|
url: https://matrix.to/#/#webrtc:matrix.org
|
||||||
|
about: Please ask and answer questions here.
|
||||||
36
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
36
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Enhancement request
|
||||||
|
description: Do you have a suggestion or feature request?
|
||||||
|
labels: [T-Enhancement]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for taking the time to propose a new feature or make a suggestion.
|
||||||
|
- type: textarea
|
||||||
|
id: usecase
|
||||||
|
attributes:
|
||||||
|
label: Your use case
|
||||||
|
description: What would you like to be able to do? Please feel welcome to include screenshots or mock ups.
|
||||||
|
placeholder: Tell us what you would like to do!
|
||||||
|
value: |
|
||||||
|
#### What would you like to do?
|
||||||
|
|
||||||
|
#### Why would you like to do it?
|
||||||
|
|
||||||
|
#### How would you like to achieve it?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternative
|
||||||
|
attributes:
|
||||||
|
label: Have you considered any alternatives?
|
||||||
|
placeholder: A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
placeholder: Is there anything else you'd like to add?
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
31
.github/workflows/build.yaml
vendored
Normal file
31
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: Build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
env:
|
||||||
|
VITE_DEFAULT_HOMESERVER: "https://call.ems.host"
|
||||||
|
VITE_SENTRY_DSN: https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
|
||||||
|
VITE_SENTRY_ENVIRONMENT: main-branch-cd
|
||||||
|
VITE_RAGESHAKE_SUBMIT_URL: https://element.io/bugreports/submit
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Yarn cache
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
cache: 'yarn'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: "yarn install"
|
||||||
|
- name: Build
|
||||||
|
run: "yarn run build"
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: build
|
||||||
|
path: dist
|
||||||
|
# We'll only use this in a triggered job, then we're done with it
|
||||||
|
retention-days: 1
|
||||||
22
.github/workflows/lint.yaml
vendored
Normal file
22
.github/workflows/lint.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: Lint, format & type check
|
||||||
|
on:
|
||||||
|
pull_request: {}
|
||||||
|
jobs:
|
||||||
|
prettier:
|
||||||
|
name: Lint, format & type check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Yarn cache
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
cache: 'yarn'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: "yarn install"
|
||||||
|
- name: Prettier
|
||||||
|
run: "yarn run prettier:check"
|
||||||
|
- name: ESLint
|
||||||
|
run: "yarn run lint:js"
|
||||||
|
- name: Type check
|
||||||
|
run: "yarn run lint:types"
|
||||||
79
.github/workflows/netlify-main.yaml
vendored
Normal file
79
.github/workflows/netlify-main.yaml
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
name: Netlify Main
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
deployments: write
|
||||||
|
if: github.event.workflow_run.conclusion == 'success'
|
||||||
|
steps:
|
||||||
|
- name: Create Deployment
|
||||||
|
uses: bobheadxi/deployments@v1
|
||||||
|
id: deployment
|
||||||
|
with:
|
||||||
|
step: start
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
env: main-branch-cd
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
|
- name: 'Download artifact'
|
||||||
|
uses: actions/github-script@v3.1.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const artifacts = await github.actions.listWorkflowRunArtifacts({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: ${{ github.event.workflow_run.id }},
|
||||||
|
});
|
||||||
|
const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
|
||||||
|
return artifact.name == "build"
|
||||||
|
})[0];
|
||||||
|
const download = await github.actions.downloadArtifact({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
artifact_id: matchArtifact.id,
|
||||||
|
archive_format: 'zip',
|
||||||
|
});
|
||||||
|
const fs = require('fs');
|
||||||
|
fs.writeFileSync('${{github.workspace}}/build.zip', Buffer.from(download.data));
|
||||||
|
|
||||||
|
- name: Extract Artifacts
|
||||||
|
run: unzip -d dist build.zip && rm build.zip
|
||||||
|
|
||||||
|
- name: Add redirects file
|
||||||
|
# We fetch from github directly as we don't bother checking out the repo
|
||||||
|
run: curl -s https://raw.githubusercontent.com/vector-im/element-call/main/config/netlify_redirects > dist/_redirects
|
||||||
|
|
||||||
|
- name: Deploy to Netlify
|
||||||
|
id: netlify
|
||||||
|
uses: nwtgck/actions-netlify@v1.2.3
|
||||||
|
with:
|
||||||
|
publish-dir: dist
|
||||||
|
deploy-message: "Deploy from GitHub Actions"
|
||||||
|
production-branch: main
|
||||||
|
production-deploy: true
|
||||||
|
# These don't work because we're in workflow_run
|
||||||
|
enable-pull-request-comment: false
|
||||||
|
enable-commit-comment: false
|
||||||
|
github-deployment-environment: main
|
||||||
|
env:
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
timeout-minutes: 1
|
||||||
|
|
||||||
|
- name: Update deployment status
|
||||||
|
uses: bobheadxi/deployments@v1
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
step: finish
|
||||||
|
override: false
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
status: ${{ job.status }}
|
||||||
|
env: ${{ steps.deployment.outputs.env }}
|
||||||
|
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
|
||||||
|
env_url: ${{ steps.netlify.outputs.deploy-url }}
|
||||||
4
.github/workflows/publish.yaml
vendored
4
.github/workflows/publish.yaml
vendored
@@ -32,10 +32,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.idea/
|
||||||
|
|||||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
25
.storybook/main.js
Normal file
25
.storybook/main.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const svgrPlugin = require("vite-plugin-svgr");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
|
||||||
|
framework: "@storybook/react",
|
||||||
|
core: {
|
||||||
|
builder: "storybook-builder-vite",
|
||||||
|
},
|
||||||
|
async viteFinal(config) {
|
||||||
|
config.plugins = config.plugins.filter(
|
||||||
|
(item) =>
|
||||||
|
!(
|
||||||
|
Array.isArray(item) &&
|
||||||
|
item.length > 0 &&
|
||||||
|
item[0].name === "vite-plugin-mdx"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
config.plugins.push(svgrPlugin());
|
||||||
|
config.resolve = config.resolve || {};
|
||||||
|
config.resolve.dedupe = config.resolve.dedupe || [];
|
||||||
|
config.resolve.dedupe.push("react", "react-dom", "matrix-js-sdk");
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
25
.storybook/preview.jsx
Normal file
25
.storybook/preview.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { addDecorator } from "@storybook/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { usePageFocusStyle } from "../src/usePageFocusStyle";
|
||||||
|
import { OverlayProvider } from "@react-aria/overlays";
|
||||||
|
import "../src/index.css";
|
||||||
|
|
||||||
|
export const parameters = {
|
||||||
|
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
addDecorator((story) => {
|
||||||
|
usePageFocusStyle();
|
||||||
|
return (
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<OverlayProvider>{story()}</OverlayProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
});
|
||||||
21
.vscode/settings.json
vendored
21
.vscode/settings.json
vendored
@@ -1,5 +1,22 @@
|
|||||||
{
|
{
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.insertSpaces": true,
|
"editor.insertSpaces": true,
|
||||||
"editor.tabSize": 2
|
"editor.tabSize": 2,
|
||||||
}
|
"[typescriptreact]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
},
|
||||||
|
"[javascriptreact]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
4
CONTRIBUTING.md
Normal file
4
CONTRIBUTING.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Contributing code to Element
|
||||||
|
============================
|
||||||
|
|
||||||
|
Element follows the same pattern as the [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md).
|
||||||
16
Dockerfile
16
Dockerfile
@@ -1,14 +1,18 @@
|
|||||||
FROM node:16-buster as builder
|
FROM --platform=$BUILDPLATFORM node:16-buster as builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY . /src/matrix-video-chat
|
COPY . /src/element-call
|
||||||
RUN matrix-video-chat/scripts/dockerbuild.sh
|
RUN element-call/scripts/dockerbuild.sh
|
||||||
|
|
||||||
# App
|
# App
|
||||||
FROM nginxinc/nginx-unprivileged:alpine
|
FROM nginxinc/nginx-unprivileged:alpine
|
||||||
|
|
||||||
COPY --from=builder /src/matrix-video-chat/dist /app
|
COPY --from=builder /src/element-call/dist /app
|
||||||
|
COPY config/default.conf /etc/nginx/conf.d/
|
||||||
|
|
||||||
RUN rm -rf /usr/share/nginx/html \
|
USER root
|
||||||
&& ln -s /app /usr/share/nginx/html
|
|
||||||
|
RUN rm -rf /usr/share/nginx/html
|
||||||
|
|
||||||
|
USER 101
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -1,10 +1,12 @@
|
|||||||
# Matrix Video Chat
|
# Element Call
|
||||||
|
|
||||||
Testbed for full mesh video chat.
|
Showcase for full mesh video chat powered by Matrix, implementing [MSC3401](https://github.com/matrix-org/matrix-spec-proposals/blob/matthew/group-voip/proposals/3401-group-voip.md).
|
||||||
|
|
||||||
|
Discussion in [#webrtc:matrix.org: ](https://matrix.to/#/#webrtc:matrix.org)
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
`matrix-video-chat` is built against the `robertlong/group-call` branch of both [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/pull/1902) and [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/pull/6848). Because of how these packages are configured and Vite's requirements, you will need to clone them locally and use `yarn link` to stich things together.
|
`element-call` is built against the `robertlong/group-call` branch of [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/pull/1902). Because of how this package is configured and Vite's requirements, you will need to clone it locally and use `yarn link` to stich things together.
|
||||||
|
|
||||||
First clone, install, and link `matrix-js-sdk`
|
First clone, install, and link `matrix-js-sdk`
|
||||||
|
|
||||||
@@ -16,30 +18,37 @@ yarn
|
|||||||
yarn link
|
yarn link
|
||||||
```
|
```
|
||||||
|
|
||||||
Then clone, install, link `matrix-js-sdk` into `matrix-react-sdk`, and link `matrix-react-sdk`
|
|
||||||
|
|
||||||
```
|
|
||||||
git clone https://github.com/matrix-org/matrix-react-sdk.git
|
|
||||||
cd matrix-react-sdk
|
|
||||||
git checkout robertlong/group-call
|
|
||||||
yarn
|
|
||||||
yarn link matrix-js-sdk
|
|
||||||
yarn link
|
|
||||||
```
|
|
||||||
|
|
||||||
Next you'll also need [Synapse](https://matrix-org.github.io/synapse/latest/setup/installation.html) installed locally and running on port 8008.
|
Next you'll also need [Synapse](https://matrix-org.github.io/synapse/latest/setup/installation.html) installed locally and running on port 8008.
|
||||||
|
|
||||||
Finally we can set up this project.
|
Finally we can set up this project.
|
||||||
|
|
||||||
```
|
```
|
||||||
git clone https://github.com/vector-im/matrix-video-chat.git
|
git clone https://github.com/vector-im/element-call.git
|
||||||
cd matrix-video-chat
|
cd element-call
|
||||||
yarn
|
yarn
|
||||||
yarn link matrix-js-sdk
|
yarn link matrix-js-sdk
|
||||||
yarn link matrix-react-sdk
|
cp .env.example .env
|
||||||
yarn dev
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Configuration options are documented in the `.env` file.
|
Configuration options are documented in the `.env` file.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
All files in this project are:
|
||||||
|
|
||||||
|
Copyright 2021-2022 New Vector Ltd
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
10
config/default.conf
Normal file
10
config/default.conf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /app;
|
||||||
|
try_files $uri /$uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
4
config/netlify_redirects
Normal file
4
config/netlify_redirects
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# This file is copied to the netlify deploy dir in the upload stage
|
||||||
|
|
||||||
|
# Redirect any unknown path to index.html
|
||||||
|
/* /index.html 200
|
||||||
16
index.html
16
index.html
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/src/favicon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
|
||||||
<title>Matrix Video Chat</title>
|
|
||||||
<script>
|
|
||||||
window.global = window;
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
48
package.json
48
package.json
@@ -3,9 +3,18 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview"
|
"serve": "vite preview",
|
||||||
|
"storybook": "start-storybook -p 6006",
|
||||||
|
"build-storybook": "build-storybook",
|
||||||
|
"prettier:check": "prettier -c src",
|
||||||
|
"prettier:format": "prettier -w src",
|
||||||
|
"lint": "yarn lint:types && yarn lint:js",
|
||||||
|
"lint:js": "eslint --max-warnings 0 src",
|
||||||
|
"lint:types": "tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@juggle/resize-observer": "^3.3.1",
|
||||||
|
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
|
||||||
"@react-aria/button": "^3.3.4",
|
"@react-aria/button": "^3.3.4",
|
||||||
"@react-aria/dialog": "^3.1.4",
|
"@react-aria/dialog": "^3.1.4",
|
||||||
"@react-aria/focus": "^3.5.0",
|
"@react-aria/focus": "^3.5.0",
|
||||||
@@ -15,6 +24,7 @@
|
|||||||
"@react-aria/tabs": "^3.1.0",
|
"@react-aria/tabs": "^3.1.0",
|
||||||
"@react-aria/tooltip": "^3.1.3",
|
"@react-aria/tooltip": "^3.1.3",
|
||||||
"@react-aria/utils": "^3.10.0",
|
"@react-aria/utils": "^3.10.0",
|
||||||
|
"@react-spring/web": "^9.4.4",
|
||||||
"@react-stately/collections": "^3.3.4",
|
"@react-stately/collections": "^3.3.4",
|
||||||
"@react-stately/overlays": "^3.1.3",
|
"@react-stately/overlays": "^3.1.3",
|
||||||
"@react-stately/select": "^3.1.3",
|
"@react-stately/select": "^3.1.3",
|
||||||
@@ -22,23 +32,51 @@
|
|||||||
"@react-stately/tree": "^3.2.0",
|
"@react-stately/tree": "^3.2.0",
|
||||||
"@sentry/react": "^6.13.3",
|
"@sentry/react": "^6.13.3",
|
||||||
"@sentry/tracing": "^6.13.3",
|
"@sentry/tracing": "^6.13.3",
|
||||||
|
"@types/grecaptcha": "^3.0.4",
|
||||||
|
"@types/sdp-transform": "^2.4.5",
|
||||||
|
"@use-gesture/react": "^10.2.11",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"color-hash": "^2.0.1",
|
"color-hash": "^2.0.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#robertlong/group-call",
|
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#586a313c8d2fd5e8982459b6e31d27c09d5066b8",
|
||||||
"matrix-react-sdk": "github:matrix-org/matrix-react-sdk#robertlong/group-call",
|
"matrix-widget-api": "^1.0.0",
|
||||||
"postcss-preset-env": "^6.7.0",
|
"mermaid": "^8.13.8",
|
||||||
|
"normalize.css": "^8.0.1",
|
||||||
|
"pako": "^2.0.4",
|
||||||
|
"postcss-preset-env": "^7",
|
||||||
"re-resizable": "^6.9.0",
|
"re-resizable": "^6.9.0",
|
||||||
"react": "^17.0.0",
|
"react": "^17.0.0",
|
||||||
"react-dom": "^17.0.0",
|
"react-dom": "^17.0.0",
|
||||||
"react-json-view": "^1.21.3",
|
"react-json-view": "^1.21.3",
|
||||||
"react-router": "6",
|
"react-router": "6",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-use-clipboard": "^1.0.7"
|
"react-use-clipboard": "^1.0.7",
|
||||||
|
"react-use-measure": "^2.1.1",
|
||||||
|
"sdp-transform": "^2.14.1",
|
||||||
|
"unique-names-generator": "^4.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.16.5",
|
||||||
|
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
|
||||||
|
"@storybook/react": "^6.5.0-alpha.5",
|
||||||
|
"@types/request": "^2.48.8",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.22.0",
|
||||||
|
"@typescript-eslint/parser": "^5.22.0",
|
||||||
|
"babel-loader": "^8.2.3",
|
||||||
|
"eslint": "^8.14.0",
|
||||||
|
"eslint-config-google": "^0.14.0",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"eslint-plugin-import": "^2.26.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||||
|
"eslint-plugin-matrix-org": "^0.4.0",
|
||||||
|
"eslint-plugin-react": "^7.29.4",
|
||||||
|
"eslint-plugin-react-hooks": "^4.5.0",
|
||||||
|
"prettier": "^2.6.2",
|
||||||
"sass": "^1.42.1",
|
"sass": "^1.42.1",
|
||||||
|
"storybook-builder-vite": "^0.1.12",
|
||||||
|
"typescript": "^4.6.4",
|
||||||
"vite": "^2.4.2",
|
"vite": "^2.4.2",
|
||||||
|
"vite-plugin-html-template": "^1.1.0",
|
||||||
"vite-plugin-svgr": "^0.4.0"
|
"vite-plugin-svgr": "^0.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
20
public/index.html
Normal file
20
public/index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||||
|
<title>
|
||||||
|
<%- title %>
|
||||||
|
</title>
|
||||||
|
<script>
|
||||||
|
window.global = window;
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
set -ex
|
set -ex
|
||||||
|
|
||||||
VITE_DEFAULT_HOMESERVER=https://call.ems.host
|
export VITE_DEFAULT_HOMESERVER=https://call.ems.host
|
||||||
VITE_SENTRY_DSN=https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
|
export VITE_SENTRY_DSN=https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
|
||||||
|
export VITE_RAGESHAKE_SUBMIT_URL=https://element.io/bugreports/submit
|
||||||
|
export VITE_PRODUCT_NAME="Element Call"
|
||||||
|
|
||||||
git clone https://github.com/matrix-org/matrix-js-sdk.git
|
git clone https://github.com/matrix-org/matrix-js-sdk.git
|
||||||
cd matrix-js-sdk
|
cd matrix-js-sdk
|
||||||
@@ -11,19 +13,11 @@ git checkout robertlong/group-call
|
|||||||
yarn install
|
yarn install
|
||||||
yarn run build
|
yarn run build
|
||||||
yarn link
|
yarn link
|
||||||
cd ..
|
|
||||||
|
|
||||||
git clone https://github.com/matrix-org/matrix-react-sdk.git
|
cd ../element-call
|
||||||
cd matrix-react-sdk
|
|
||||||
git checkout robertlong/group-call
|
export VITE_APP_VERSION=$(git describe --tags --abbrev=0)
|
||||||
|
|
||||||
yarn link matrix-js-sdk
|
yarn link matrix-js-sdk
|
||||||
yarn install
|
yarn install
|
||||||
yarn run build
|
yarn run build
|
||||||
yarn link
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
cd matrix-video-chat
|
|
||||||
yarn link matrix-js-sdk
|
|
||||||
yarn link matrix-react-sdk
|
|
||||||
yarn install
|
|
||||||
yarn run build
|
|
||||||
|
|||||||
30
src/@types/global.d.ts
vendored
Normal file
30
src/@types/global.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 "matrix-js-sdk/src/@types/global";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
||||||
|
OLM_OPTIONS: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript doesn't know about the experimental setSinkId method, so we
|
||||||
|
// declare it ourselves
|
||||||
|
interface MediaElement extends HTMLVideoElement {
|
||||||
|
setSinkId: (id: string) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/@types/modules.d.ts
vendored
Normal file
2
src/@types/modules.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-svgr/client" />
|
||||||
105
src/App.jsx
105
src/App.jsx
@@ -1,105 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2021 New Vector Ltd
|
|
||||||
|
|
||||||
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 React, { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
BrowserRouter as Router,
|
|
||||||
Switch,
|
|
||||||
Route,
|
|
||||||
useLocation,
|
|
||||||
useHistory,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import * as Sentry from "@sentry/react";
|
|
||||||
import { OverlayProvider } from "@react-aria/overlays";
|
|
||||||
import { Home } from "./Home";
|
|
||||||
import { LoginPage } from "./LoginPage";
|
|
||||||
import { RegisterPage } from "./RegisterPage";
|
|
||||||
import { Room } from "./Room";
|
|
||||||
import {
|
|
||||||
ClientProvider,
|
|
||||||
defaultHomeserverHost,
|
|
||||||
} from "./ConferenceCallManagerHooks";
|
|
||||||
import { useFocusVisible } from "@react-aria/interactions";
|
|
||||||
import styles from "./App.module.css";
|
|
||||||
import { LoadingView } from "./FullScreenView";
|
|
||||||
|
|
||||||
const SentryRoute = Sentry.withSentryRouting(Route);
|
|
||||||
|
|
||||||
export default function App({ history }) {
|
|
||||||
const { isFocusVisible } = useFocusVisible();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const classList = document.body.classList;
|
|
||||||
const hasClass = classList.contains(styles.hideFocus);
|
|
||||||
|
|
||||||
if (isFocusVisible && hasClass) {
|
|
||||||
classList.remove(styles.hideFocus);
|
|
||||||
} else if (!isFocusVisible && !hasClass) {
|
|
||||||
classList.add(styles.hideFocus);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
classList.remove(styles.hideFocus);
|
|
||||||
};
|
|
||||||
}, [isFocusVisible]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Router history={history}>
|
|
||||||
<ClientProvider>
|
|
||||||
<OverlayProvider>
|
|
||||||
<Switch>
|
|
||||||
<SentryRoute exact path="/">
|
|
||||||
<Home />
|
|
||||||
</SentryRoute>
|
|
||||||
<SentryRoute exact path="/login">
|
|
||||||
<LoginPage />
|
|
||||||
</SentryRoute>
|
|
||||||
<SentryRoute exact path="/register">
|
|
||||||
<RegisterPage />
|
|
||||||
</SentryRoute>
|
|
||||||
<SentryRoute path="/room/:roomId?">
|
|
||||||
<Room />
|
|
||||||
</SentryRoute>
|
|
||||||
<SentryRoute path="*">
|
|
||||||
<RoomRedirect />
|
|
||||||
</SentryRoute>
|
|
||||||
</Switch>
|
|
||||||
</OverlayProvider>
|
|
||||||
</ClientProvider>
|
|
||||||
</Router>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoomRedirect() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let roomId = pathname;
|
|
||||||
|
|
||||||
if (pathname.startsWith("/")) {
|
|
||||||
roomId = roomId.substr(1, roomId.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!roomId.startsWith("#") && !roomId.startsWith("!")) {
|
|
||||||
roomId = `#${roomId}:${defaultHomeserverHost}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
history.replace(`/room/${roomId}`);
|
|
||||||
}, [pathname, history]);
|
|
||||||
|
|
||||||
return <LoadingView />;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.hideFocus * {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
76
src/App.tsx
Normal file
76
src/App.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2021 New Vector Ltd
|
||||||
|
|
||||||
|
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 React from "react";
|
||||||
|
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
|
import { OverlayProvider } from "@react-aria/overlays";
|
||||||
|
|
||||||
|
import { HomePage } from "./home/HomePage";
|
||||||
|
import { LoginPage } from "./auth/LoginPage";
|
||||||
|
import { RegisterPage } from "./auth/RegisterPage";
|
||||||
|
import { RoomPage } from "./room/RoomPage";
|
||||||
|
import { RoomRedirect } from "./room/RoomRedirect";
|
||||||
|
import { ClientProvider } from "./ClientContext";
|
||||||
|
import { usePageFocusStyle } from "./usePageFocusStyle";
|
||||||
|
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
|
||||||
|
import { InspectorContextProvider } from "./room/GroupCallInspector";
|
||||||
|
import { CrashView } from "./FullScreenView";
|
||||||
|
|
||||||
|
const SentryRoute = Sentry.withSentryRouting(Route);
|
||||||
|
|
||||||
|
interface AppProps {
|
||||||
|
history: History;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App({ history }: AppProps) {
|
||||||
|
usePageFocusStyle();
|
||||||
|
|
||||||
|
const errorPage = <CrashView />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Router history={history}>
|
||||||
|
<ClientProvider>
|
||||||
|
<InspectorContextProvider>
|
||||||
|
<Sentry.ErrorBoundary fallback={errorPage}>
|
||||||
|
<OverlayProvider>
|
||||||
|
<Switch>
|
||||||
|
<SentryRoute exact path="/">
|
||||||
|
<HomePage />
|
||||||
|
</SentryRoute>
|
||||||
|
<SentryRoute exact path="/login">
|
||||||
|
<LoginPage />
|
||||||
|
</SentryRoute>
|
||||||
|
<SentryRoute exact path="/register">
|
||||||
|
<RegisterPage />
|
||||||
|
</SentryRoute>
|
||||||
|
<SentryRoute path="/room/:roomId?">
|
||||||
|
<RoomPage />
|
||||||
|
</SentryRoute>
|
||||||
|
<SentryRoute path="/inspector">
|
||||||
|
<SequenceDiagramViewerPage />
|
||||||
|
</SentryRoute>
|
||||||
|
<SentryRoute path="*">
|
||||||
|
<RoomRedirect />
|
||||||
|
</SentryRoute>
|
||||||
|
</Switch>
|
||||||
|
</OverlayProvider>
|
||||||
|
</Sentry.ErrorBoundary>
|
||||||
|
</InspectorContextProvider>
|
||||||
|
</ClientProvider>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import React, { useMemo } from "react";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import styles from "./Avatar.module.css";
|
|
||||||
|
|
||||||
const backgroundColors = [
|
|
||||||
"#5C56F5",
|
|
||||||
"#03B381",
|
|
||||||
"#368BD6",
|
|
||||||
"#AC3BA8",
|
|
||||||
"#E64F7A",
|
|
||||||
"#FF812D",
|
|
||||||
"#2DC2C5",
|
|
||||||
"#74D12C",
|
|
||||||
];
|
|
||||||
|
|
||||||
function hashStringToArrIndex(str, arrLength) {
|
|
||||||
let sum = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
sum += str.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sum % arrLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Avatar({
|
|
||||||
bgKey,
|
|
||||||
src,
|
|
||||||
fallback,
|
|
||||||
size,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
...rest
|
|
||||||
}) {
|
|
||||||
const backgroundColor = useMemo(() => {
|
|
||||||
const index = hashStringToArrIndex(
|
|
||||||
bgKey || fallback || src,
|
|
||||||
backgroundColors.length
|
|
||||||
);
|
|
||||||
return backgroundColors[index];
|
|
||||||
}, [bgKey, src, fallback]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.avatar, styles[size || "md"], className)}
|
|
||||||
style={{ backgroundColor, ...style }}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{src ? (
|
|
||||||
<img src={src} />
|
|
||||||
) : typeof fallback === "string" ? (
|
|
||||||
<span>{fallback}</span>
|
|
||||||
) : (
|
|
||||||
fallback
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
.avatar {
|
.avatar {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: #ffffff;
|
color: var(--primary-content);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.avatar svg * {
|
.avatar svg * {
|
||||||
fill: #ffffff;
|
fill: var(--primary-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar span {
|
.avatar span {
|
||||||
@@ -49,11 +49,12 @@
|
|||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border-radius: 42px;
|
border-radius: 42px;
|
||||||
font-size: 36px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xl {
|
.xl {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
border-radius: 90px;
|
border-radius: 90px;
|
||||||
|
font-size: 48px;
|
||||||
}
|
}
|
||||||
|
|||||||
115
src/Avatar.tsx
Normal file
115
src/Avatar.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import React, { useMemo, CSSProperties } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
|
|
||||||
|
import { getAvatarUrl } from "./matrix-utils";
|
||||||
|
import { useClient } from "./ClientContext";
|
||||||
|
import styles from "./Avatar.module.css";
|
||||||
|
|
||||||
|
const backgroundColors = [
|
||||||
|
"#5C56F5",
|
||||||
|
"#03B381",
|
||||||
|
"#368BD6",
|
||||||
|
"#AC3BA8",
|
||||||
|
"#E64F7A",
|
||||||
|
"#FF812D",
|
||||||
|
"#2DC2C5",
|
||||||
|
"#74D12C",
|
||||||
|
];
|
||||||
|
|
||||||
|
export enum Size {
|
||||||
|
XS = "xs",
|
||||||
|
SM = "sm",
|
||||||
|
MD = "md",
|
||||||
|
LG = "lg",
|
||||||
|
XL = "xl",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sizes = new Map([
|
||||||
|
[Size.XS, 22],
|
||||||
|
[Size.SM, 32],
|
||||||
|
[Size.MD, 36],
|
||||||
|
[Size.LG, 42],
|
||||||
|
[Size.XL, 90],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function hashStringToArrIndex(str: string, arrLength: number) {
|
||||||
|
let sum = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
sum += str.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum % arrLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAvatarSrc = (client: MatrixClient, src: string, size: number) =>
|
||||||
|
src?.startsWith("mxc://") ? client && getAvatarUrl(client, src, size) : src;
|
||||||
|
|
||||||
|
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
bgKey?: string;
|
||||||
|
src?: string;
|
||||||
|
size?: Size | number;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
fallback: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Avatar: React.FC<Props> = ({
|
||||||
|
bgKey,
|
||||||
|
src,
|
||||||
|
fallback,
|
||||||
|
size = Size.MD,
|
||||||
|
className,
|
||||||
|
style = {},
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const { client } = useClient();
|
||||||
|
|
||||||
|
const [sizeClass, sizePx, sizeStyle] = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(Size).includes(size as Size)
|
||||||
|
? [styles[size as string], sizes.get(size as Size), {}]
|
||||||
|
: [
|
||||||
|
null,
|
||||||
|
size as number,
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size,
|
||||||
|
fontSize: Math.round((size as number) / 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[size]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedSrc = useMemo(
|
||||||
|
() => resolveAvatarSrc(client, src, sizePx),
|
||||||
|
[client, src, sizePx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const backgroundColor = useMemo(() => {
|
||||||
|
const index = hashStringToArrIndex(
|
||||||
|
bgKey || fallback || src || "",
|
||||||
|
backgroundColors.length
|
||||||
|
);
|
||||||
|
return backgroundColors[index];
|
||||||
|
}, [bgKey, src, fallback]);
|
||||||
|
|
||||||
|
/* eslint-disable jsx-a11y/alt-text */
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.avatar, sizeClass, className)}
|
||||||
|
style={{ backgroundColor, ...sizeStyle, ...style }}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{resolvedSrc ? (
|
||||||
|
<img src={resolvedSrc} />
|
||||||
|
) : typeof fallback === "string" ? (
|
||||||
|
<span>{fallback}</span>
|
||||||
|
) : (
|
||||||
|
fallback
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import React, { useMemo } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { CopyButton } from "./button";
|
|
||||||
import { Facepile } from "./Facepile";
|
|
||||||
import { Avatar } from "./Avatar";
|
|
||||||
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
|
|
||||||
import styles from "./CallList.module.css";
|
|
||||||
import { getRoomUrl } from "./ConferenceCallManagerHooks";
|
|
||||||
|
|
||||||
export function CallList({ title, rooms, client }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
<div className={styles.callList}>
|
|
||||||
{rooms.map(({ roomId, roomName, avatarUrl, participants }) => (
|
|
||||||
<CallTile
|
|
||||||
key={roomId}
|
|
||||||
client={client}
|
|
||||||
name={roomName}
|
|
||||||
avatarUrl={avatarUrl}
|
|
||||||
roomId={roomId}
|
|
||||||
participants={participants}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CallTile({ name, avatarUrl, roomId, participants, client }) {
|
|
||||||
return (
|
|
||||||
<div className={styles.callTile}>
|
|
||||||
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
|
|
||||||
<Avatar
|
|
||||||
size="md"
|
|
||||||
bgKey={name}
|
|
||||||
src={avatarUrl}
|
|
||||||
fallback={<VideoIcon width={16} height={16} />}
|
|
||||||
className={styles.avatar}
|
|
||||||
/>
|
|
||||||
<div className={styles.callInfo}>
|
|
||||||
<h5>{name}</h5>
|
|
||||||
<p>{getRoomUrl(roomId)}</p>
|
|
||||||
{participants && (
|
|
||||||
<Facepile client={client} participants={participants} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.copyButtonSpacer} />
|
|
||||||
</Link>
|
|
||||||
<CopyButton
|
|
||||||
className={styles.copyButton}
|
|
||||||
variant="icon"
|
|
||||||
value={getRoomUrl(roomId)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
347
src/ClientContext.tsx
Normal file
347
src/ClientContext.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2021-2022 New Vector Ltd
|
||||||
|
|
||||||
|
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 React, {
|
||||||
|
FC,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
createContext,
|
||||||
|
useMemo,
|
||||||
|
useContext,
|
||||||
|
} from "react";
|
||||||
|
import { useHistory } from "react-router-dom";
|
||||||
|
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
|
||||||
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
import { ErrorView } from "./FullScreenView";
|
||||||
|
import {
|
||||||
|
initClient,
|
||||||
|
defaultHomeserver,
|
||||||
|
CryptoStoreIntegrityError,
|
||||||
|
} from "./matrix-utils";
|
||||||
|
import { widget } from "./widget";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
matrixclient: MatrixClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
user_id: string;
|
||||||
|
device_id: string;
|
||||||
|
access_token: string;
|
||||||
|
passwordlessUser: boolean;
|
||||||
|
tempPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSession = (): Session => {
|
||||||
|
const data = localStorage.getItem("matrix-auth-store");
|
||||||
|
if (data) return JSON.parse(data);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const saveSession = (session: Session) =>
|
||||||
|
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
|
||||||
|
const clearSession = () => localStorage.removeItem("matrix-auth-store");
|
||||||
|
|
||||||
|
interface ClientState {
|
||||||
|
loading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isPasswordlessUser: boolean;
|
||||||
|
client: MatrixClient;
|
||||||
|
userName: string;
|
||||||
|
changePassword: (password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
setClient: (client: MatrixClient, session: Session) => void;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientContext = createContext<ClientState>(null);
|
||||||
|
|
||||||
|
type ClientProviderState = Omit<
|
||||||
|
ClientState,
|
||||||
|
"changePassword" | "logout" | "setClient"
|
||||||
|
> & { error?: Error };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClientProvider: FC<Props> = ({ children }) => {
|
||||||
|
const history = useHistory();
|
||||||
|
const [
|
||||||
|
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
|
||||||
|
setState,
|
||||||
|
] = useState<ClientProviderState>({
|
||||||
|
loading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isPasswordlessUser: false,
|
||||||
|
client: undefined,
|
||||||
|
userName: null,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async (): Promise<
|
||||||
|
Pick<ClientProviderState, "client" | "isPasswordlessUser">
|
||||||
|
> => {
|
||||||
|
if (widget) {
|
||||||
|
// We're inside a widget, so let's engage *matryoshka mode*
|
||||||
|
logger.log("Using a matryoshka client");
|
||||||
|
|
||||||
|
return {
|
||||||
|
client: await widget.client,
|
||||||
|
isPasswordlessUser: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// We're running as a standalone application
|
||||||
|
try {
|
||||||
|
const session = loadSession();
|
||||||
|
if (!session) return { client: undefined, isPasswordlessUser: false };
|
||||||
|
|
||||||
|
logger.log("Using a standalone client");
|
||||||
|
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
const { user_id, device_id, access_token, passwordlessUser } =
|
||||||
|
session;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
client: await initClient(
|
||||||
|
{
|
||||||
|
baseUrl: defaultHomeserver,
|
||||||
|
accessToken: access_token,
|
||||||
|
userId: user_id,
|
||||||
|
deviceId: device_id,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
),
|
||||||
|
isPasswordlessUser: passwordlessUser,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CryptoStoreIntegrityError) {
|
||||||
|
// We can't use this session anymore, so let's log it out
|
||||||
|
try {
|
||||||
|
const client = await initClient(
|
||||||
|
{
|
||||||
|
baseUrl: defaultHomeserver,
|
||||||
|
accessToken: access_token,
|
||||||
|
userId: user_id,
|
||||||
|
deviceId: device_id,
|
||||||
|
},
|
||||||
|
false // Don't need the crypto store just to log out
|
||||||
|
);
|
||||||
|
await client.logout(undefined, true);
|
||||||
|
} catch (err_) {
|
||||||
|
logger.warn(
|
||||||
|
"The previous session was lost, and we couldn't log it out, " +
|
||||||
|
"either"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
/* eslint-enable camelcase */
|
||||||
|
} catch (err) {
|
||||||
|
clearSession();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init()
|
||||||
|
.then(({ client, isPasswordlessUser }) => {
|
||||||
|
setState({
|
||||||
|
client,
|
||||||
|
loading: false,
|
||||||
|
isAuthenticated: Boolean(client),
|
||||||
|
isPasswordlessUser,
|
||||||
|
userName: client?.getUserIdLocalpart(),
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error(err);
|
||||||
|
setState({
|
||||||
|
client: undefined,
|
||||||
|
loading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isPasswordlessUser: false,
|
||||||
|
userName: null,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changePassword = useCallback(
|
||||||
|
async (password: string) => {
|
||||||
|
const { tempPassword, ...session } = loadSession();
|
||||||
|
|
||||||
|
await client.setPassword(
|
||||||
|
{
|
||||||
|
type: "m.login.password",
|
||||||
|
identifier: {
|
||||||
|
type: "m.id.user",
|
||||||
|
user: session.user_id,
|
||||||
|
},
|
||||||
|
user: session.user_id,
|
||||||
|
password: tempPassword,
|
||||||
|
},
|
||||||
|
password
|
||||||
|
);
|
||||||
|
|
||||||
|
saveSession({ ...session, passwordlessUser: false });
|
||||||
|
|
||||||
|
setState({
|
||||||
|
client,
|
||||||
|
loading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isPasswordlessUser: false,
|
||||||
|
userName: client.getUserIdLocalpart(),
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[client]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setClient = useCallback(
|
||||||
|
(newClient: MatrixClient, session: Session) => {
|
||||||
|
if (client && client !== newClient) {
|
||||||
|
client.stopClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newClient) {
|
||||||
|
saveSession(session);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
client: newClient,
|
||||||
|
loading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isPasswordlessUser: session.passwordlessUser,
|
||||||
|
userName: newClient.getUserIdLocalpart(),
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clearSession();
|
||||||
|
|
||||||
|
setState({
|
||||||
|
client: undefined,
|
||||||
|
loading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isPasswordlessUser: false,
|
||||||
|
userName: null,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client]
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await client.logout(undefined, true);
|
||||||
|
clearSession();
|
||||||
|
setState({
|
||||||
|
client: undefined,
|
||||||
|
loading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isPasswordlessUser: true,
|
||||||
|
userName: "",
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
history.push("/");
|
||||||
|
}, [history, client]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// To protect against multiple sessions writing to the same storage
|
||||||
|
// simultaneously, we send a to-device message that shuts down all other
|
||||||
|
// running instances of the app. This isn't necessary if the app is running
|
||||||
|
// in a widget though, since then it'll be mostly stateless.
|
||||||
|
if (!widget && client) {
|
||||||
|
const loadTime = Date.now();
|
||||||
|
|
||||||
|
const onToDeviceEvent = (event: MatrixEvent) => {
|
||||||
|
if (event.getType() !== "org.matrix.call_duplicate_session") return;
|
||||||
|
|
||||||
|
const content = event.getContent();
|
||||||
|
|
||||||
|
if (content.session_id === client.getSessionId()) return;
|
||||||
|
|
||||||
|
if (content.timestamp > loadTime) {
|
||||||
|
client?.stopClient();
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error: new Error(
|
||||||
|
"This application has been opened in another tab."
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
client.on(ClientEvent.ToDeviceEvent, onToDeviceEvent);
|
||||||
|
|
||||||
|
client.sendToDevice("org.matrix.call_duplicate_session", {
|
||||||
|
[client.getUserId()]: {
|
||||||
|
"*": { session_id: client.getSessionId(), timestamp: loadTime },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
const context = useMemo<ClientState>(
|
||||||
|
() => ({
|
||||||
|
loading,
|
||||||
|
isAuthenticated,
|
||||||
|
isPasswordlessUser,
|
||||||
|
client,
|
||||||
|
changePassword,
|
||||||
|
logout,
|
||||||
|
userName,
|
||||||
|
setClient,
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
loading,
|
||||||
|
isAuthenticated,
|
||||||
|
isPasswordlessUser,
|
||||||
|
client,
|
||||||
|
changePassword,
|
||||||
|
logout,
|
||||||
|
userName,
|
||||||
|
setClient,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.matrixclient = client;
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorView error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useClient = () => useContext(ClientContext);
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2021 New Vector Ltd
|
|
||||||
|
|
||||||
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 EventEmitter from "events";
|
|
||||||
|
|
||||||
export class ConferenceCallDebugger extends EventEmitter {
|
|
||||||
constructor(client, groupCall) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.client = client;
|
|
||||||
this.groupCall = groupCall;
|
|
||||||
|
|
||||||
this.debugState = {
|
|
||||||
users: new Map(),
|
|
||||||
calls: new Map(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.bufferedEvents = [];
|
|
||||||
|
|
||||||
client.on("event", this._onEvent);
|
|
||||||
groupCall.on("call", this._onCall);
|
|
||||||
groupCall.on("debugstate", this._onDebugStateChanged);
|
|
||||||
groupCall.on("entered", this._onEntered);
|
|
||||||
groupCall.on("left", this._onLeft);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onEntered = () => {
|
|
||||||
const eventCount = this.bufferedEvents.length;
|
|
||||||
|
|
||||||
for (let i = 0; i < eventCount; i++) {
|
|
||||||
const event = this.bufferedEvents.pop();
|
|
||||||
this._onEvent(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_onLeft = () => {
|
|
||||||
this.bufferedEvents = [];
|
|
||||||
this.debugState = {
|
|
||||||
users: new Map(),
|
|
||||||
calls: new Map(),
|
|
||||||
};
|
|
||||||
this.emit("debug");
|
|
||||||
};
|
|
||||||
|
|
||||||
_onEvent = (event) => {
|
|
||||||
if (!this.groupCall.entered) {
|
|
||||||
this.bufferedEvents.push(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomId = event.getRoomId();
|
|
||||||
const type = event.getType();
|
|
||||||
|
|
||||||
if (
|
|
||||||
roomId === this.groupCall.room.roomId &&
|
|
||||||
(type.startsWith("m.call.") ||
|
|
||||||
type === "me.robertlong.call.info" ||
|
|
||||||
type === "m.room.member")
|
|
||||||
) {
|
|
||||||
const sender = event.getSender();
|
|
||||||
const { call_id } = event.getContent();
|
|
||||||
|
|
||||||
if (call_id) {
|
|
||||||
if (this.debugState.calls.has(call_id)) {
|
|
||||||
const callState = this.debugState.calls.get(call_id);
|
|
||||||
callState.events.push(event);
|
|
||||||
} else {
|
|
||||||
this.debugState.calls.set(call_id, {
|
|
||||||
state: "unknown",
|
|
||||||
events: [event],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.debugState.users.has(sender)) {
|
|
||||||
const userState = this.debugState.users.get(sender);
|
|
||||||
userState.events.push(event);
|
|
||||||
} else {
|
|
||||||
this.debugState.users.set(sender, {
|
|
||||||
state: "unknown",
|
|
||||||
events: [event],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit("debug");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_onDebugStateChanged = (userId, callId, state) => {
|
|
||||||
if (userId) {
|
|
||||||
const userState = this.debugState.users.get(userId);
|
|
||||||
|
|
||||||
if (userState) {
|
|
||||||
userState.state = state;
|
|
||||||
} else {
|
|
||||||
this.debugState.users.set(userId, {
|
|
||||||
state,
|
|
||||||
events: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (callId) {
|
|
||||||
const callState = this.debugState.calls.get(callId);
|
|
||||||
|
|
||||||
if (callState) {
|
|
||||||
callState.state = state;
|
|
||||||
} else {
|
|
||||||
this.debugState.calls.set(callId, {
|
|
||||||
state,
|
|
||||||
events: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit("debug");
|
|
||||||
};
|
|
||||||
|
|
||||||
_onCall = (call) => {
|
|
||||||
const peerConnection = call.peerConn;
|
|
||||||
|
|
||||||
if (!peerConnection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendWebRTCInfoEvent = async (eventType) => {
|
|
||||||
const event = {
|
|
||||||
call_id: call.callId,
|
|
||||||
eventType,
|
|
||||||
iceConnectionState: peerConnection.iceConnectionState,
|
|
||||||
iceGatheringState: peerConnection.iceGatheringState,
|
|
||||||
signalingState: peerConnection.signalingState,
|
|
||||||
selectedCandidatePair: null,
|
|
||||||
localCandidate: null,
|
|
||||||
remoteCandidate: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// getStats doesn't support selectors in Firefox so get all stats by passing null.
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats#browser_compatibility
|
|
||||||
const stats = await peerConnection.getStats(null);
|
|
||||||
|
|
||||||
const statsArr = Array.from(stats.values());
|
|
||||||
|
|
||||||
// Matrix doesn't support floats so we convert time in seconds to ms
|
|
||||||
function secToMs(time) {
|
|
||||||
if (time === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.round(time * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function processTransportStats(transportStats) {
|
|
||||||
if (!transportStats) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
packetsSent: transportStats.packetsSent,
|
|
||||||
packetsReceived: transportStats.packetsReceived,
|
|
||||||
bytesSent: transportStats.bytesSent,
|
|
||||||
bytesReceived: transportStats.bytesReceived,
|
|
||||||
iceRole: transportStats.iceRole,
|
|
||||||
iceState: transportStats.iceState,
|
|
||||||
dtlsState: transportStats.dtlsState,
|
|
||||||
dtlsCipher: transportStats.dtlsCipher,
|
|
||||||
tlsVersion: transportStats.tlsVersion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function processCandidateStats(candidateStats) {
|
|
||||||
if (!candidateStats) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Figure out how to normalize ip and address across browsers
|
|
||||||
// networkType property excluded for privacy reasons:
|
|
||||||
// https://www.w3.org/TR/webrtc-stats/#sotd
|
|
||||||
return {
|
|
||||||
priority:
|
|
||||||
candidateStats.priority && candidateStats.priority.toString(),
|
|
||||||
candidateType: candidateStats.candidateType,
|
|
||||||
protocol: candidateStats.protocol,
|
|
||||||
address: !!candidateStats.address
|
|
||||||
? candidateStats.address
|
|
||||||
: candidateStats.ip,
|
|
||||||
port: candidateStats.port,
|
|
||||||
url: candidateStats.url,
|
|
||||||
relayProtocol: candidateStats.relayProtocol,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function processCandidatePair(candidatePairStats) {
|
|
||||||
if (!candidatePairStats) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localCandidateStats = statsArr.find(
|
|
||||||
(stat) => stat.id === candidatePairStats.localCandidateId
|
|
||||||
);
|
|
||||||
event.localCandidate = processCandidateStats(localCandidateStats);
|
|
||||||
|
|
||||||
const remoteCandidateStats = statsArr.find(
|
|
||||||
(stat) => stat.id === candidatePairStats.remoteCandidateId
|
|
||||||
);
|
|
||||||
event.remoteCandidate = processCandidateStats(remoteCandidateStats);
|
|
||||||
|
|
||||||
const transportStats = statsArr.find(
|
|
||||||
(stat) => stat.id === candidatePairStats.transportId
|
|
||||||
);
|
|
||||||
event.transport = processTransportStats(transportStats);
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: candidatePairStats.state,
|
|
||||||
bytesSent: candidatePairStats.bytesSent,
|
|
||||||
bytesReceived: candidatePairStats.bytesReceived,
|
|
||||||
requestsSent: candidatePairStats.requestsSent,
|
|
||||||
requestsReceived: candidatePairStats.requestsReceived,
|
|
||||||
responsesSent: candidatePairStats.responsesSent,
|
|
||||||
responsesReceived: candidatePairStats.responsesReceived,
|
|
||||||
currentRoundTripTime: secToMs(
|
|
||||||
candidatePairStats.currentRoundTripTime
|
|
||||||
),
|
|
||||||
totalRoundTripTime: secToMs(candidatePairStats.totalRoundTripTime),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Firefox uses the deprecated "selected" property for the nominated ice candidate.
|
|
||||||
const selectedCandidatePair = statsArr.find(
|
|
||||||
(stat) =>
|
|
||||||
stat.type === "candidate-pair" && (stat.selected || stat.nominated)
|
|
||||||
);
|
|
||||||
|
|
||||||
event.selectedCandidatePair = processCandidatePair(selectedCandidatePair);
|
|
||||||
|
|
||||||
function processCodecStats(codecStats) {
|
|
||||||
if (!codecStats) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payload type enums and MIME types listed here:
|
|
||||||
// https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml
|
|
||||||
return {
|
|
||||||
mimeType: codecStats.mimeType,
|
|
||||||
clockRate: codecStats.clockRate,
|
|
||||||
payloadType: codecStats.payloadType,
|
|
||||||
channels: codecStats.channels,
|
|
||||||
sdpFmtpLine: codecStats.sdpFmtpLine,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function processRTPStreamStats(rtpStreamStats) {
|
|
||||||
const codecStats = statsArr.find(
|
|
||||||
(stat) => stat.id === rtpStreamStats.codecId
|
|
||||||
);
|
|
||||||
const codec = processCodecStats(codecStats);
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: rtpStreamStats.kind,
|
|
||||||
codec,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function processInboundRTPStats(inboundRTPStats) {
|
|
||||||
const rtpStreamStats = processRTPStreamStats(inboundRTPStats);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...rtpStreamStats,
|
|
||||||
decoderImplementation: inboundRTPStats.decoderImplementation,
|
|
||||||
bytesReceived: inboundRTPStats.bytesReceived,
|
|
||||||
packetsReceived: inboundRTPStats.packetsReceived,
|
|
||||||
packetsLost: inboundRTPStats.packetsLost,
|
|
||||||
jitter: secToMs(inboundRTPStats.jitter),
|
|
||||||
frameWidth: inboundRTPStats.frameWidth,
|
|
||||||
frameHeight: inboundRTPStats.frameHeight,
|
|
||||||
frameBitDepth: inboundRTPStats.frameBitDepth,
|
|
||||||
framesPerSecond:
|
|
||||||
inboundRTPStats.framesPerSecond &&
|
|
||||||
inboundRTPStats.framesPerSecond.toString(),
|
|
||||||
framesReceived: inboundRTPStats.framesReceived,
|
|
||||||
framesDecoded: inboundRTPStats.framesDecoded,
|
|
||||||
framesDropped: inboundRTPStats.framesDropped,
|
|
||||||
totalSamplesDecoded: inboundRTPStats.totalSamplesDecoded,
|
|
||||||
totalDecodeTime: secToMs(inboundRTPStats.totalDecodeTime),
|
|
||||||
totalProcessingDelay: secToMs(inboundRTPStats.totalProcessingDelay),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function processOutboundRTPStats(outboundRTPStats) {
|
|
||||||
const rtpStreamStats = processRTPStreamStats(outboundRTPStats);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...rtpStreamStats,
|
|
||||||
encoderImplementation: outboundRTPStats.encoderImplementation,
|
|
||||||
bytesSent: outboundRTPStats.bytesSent,
|
|
||||||
packetsSent: outboundRTPStats.packetsSent,
|
|
||||||
frameWidth: outboundRTPStats.frameWidth,
|
|
||||||
frameHeight: outboundRTPStats.frameHeight,
|
|
||||||
frameBitDepth: outboundRTPStats.frameBitDepth,
|
|
||||||
framesPerSecond:
|
|
||||||
outboundRTPStats.framesPerSecond &&
|
|
||||||
outboundRTPStats.framesPerSecond.toString(),
|
|
||||||
framesSent: outboundRTPStats.framesSent,
|
|
||||||
framesEncoded: outboundRTPStats.framesEncoded,
|
|
||||||
qualityLimitationReason: outboundRTPStats.qualityLimitationReason,
|
|
||||||
qualityLimitationResolutionChanges:
|
|
||||||
outboundRTPStats.qualityLimitationResolutionChanges,
|
|
||||||
totalEncodeTime: secToMs(outboundRTPStats.totalEncodeTime),
|
|
||||||
totalPacketSendDelay: secToMs(outboundRTPStats.totalPacketSendDelay),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function processRemoteInboundRTPStats(remoteInboundRTPStats) {
|
|
||||||
const rtpStreamStats = processRTPStreamStats(remoteInboundRTPStats);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...rtpStreamStats,
|
|
||||||
packetsReceived: remoteInboundRTPStats.packetsReceived,
|
|
||||||
packetsLost: remoteInboundRTPStats.packetsLost,
|
|
||||||
jitter: secToMs(remoteInboundRTPStats.jitter),
|
|
||||||
framesDropped: remoteInboundRTPStats.framesDropped,
|
|
||||||
roundTripTime: secToMs(remoteInboundRTPStats.roundTripTime),
|
|
||||||
totalRoundTripTime: secToMs(remoteInboundRTPStats.totalRoundTripTime),
|
|
||||||
fractionLost:
|
|
||||||
remoteInboundRTPStats.fractionLost !== undefined &&
|
|
||||||
remoteInboundRTPStats.fractionLost.toString(),
|
|
||||||
reportsReceived: remoteInboundRTPStats.reportsReceived,
|
|
||||||
roundTripTimeMeasurements:
|
|
||||||
remoteInboundRTPStats.roundTripTimeMeasurements,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function processRemoteOutboundRTPStats(remoteOutboundRTPStats) {
|
|
||||||
const rtpStreamStats = processRTPStreamStats(remoteOutboundRTPStats);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...rtpStreamStats,
|
|
||||||
encoderImplementation: remoteOutboundRTPStats.encoderImplementation,
|
|
||||||
bytesSent: remoteOutboundRTPStats.bytesSent,
|
|
||||||
packetsSent: remoteOutboundRTPStats.packetsSent,
|
|
||||||
roundTripTime: secToMs(remoteOutboundRTPStats.roundTripTime),
|
|
||||||
totalRoundTripTime: secToMs(
|
|
||||||
remoteOutboundRTPStats.totalRoundTripTime
|
|
||||||
),
|
|
||||||
reportsSent: remoteOutboundRTPStats.reportsSent,
|
|
||||||
roundTripTimeMeasurements:
|
|
||||||
remoteOutboundRTPStats.roundTripTimeMeasurements,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
event.inboundRTP = statsArr
|
|
||||||
.filter((stat) => stat.type === "inbound-rtp")
|
|
||||||
.map(processInboundRTPStats);
|
|
||||||
|
|
||||||
event.outboundRTP = statsArr
|
|
||||||
.filter((stat) => stat.type === "outbound-rtp")
|
|
||||||
.map(processOutboundRTPStats);
|
|
||||||
|
|
||||||
event.remoteInboundRTP = statsArr
|
|
||||||
.filter((stat) => stat.type === "remote-inbound-rtp")
|
|
||||||
.map(processRemoteInboundRTPStats);
|
|
||||||
|
|
||||||
event.remoteOutboundRTP = statsArr
|
|
||||||
.filter((stat) => stat.type === "remote-outbound-rtp")
|
|
||||||
.map(processRemoteOutboundRTPStats);
|
|
||||||
|
|
||||||
this.client.sendEvent(
|
|
||||||
this.groupCall.room.roomId,
|
|
||||||
"me.robertlong.call.info",
|
|
||||||
event
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
let statsTimeout;
|
|
||||||
|
|
||||||
const sendStats = () => {
|
|
||||||
if (
|
|
||||||
call.state === "ended" ||
|
|
||||||
peerConnection.connectionState === "closed"
|
|
||||||
) {
|
|
||||||
clearTimeout(statsTimeout);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendWebRTCInfoEvent("stats");
|
|
||||||
statsTimeout = setTimeout(sendStats, 30 * 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
setTimeout(sendStats, 30 * 1000);
|
|
||||||
|
|
||||||
peerConnection.addEventListener("iceconnectionstatechange", () => {
|
|
||||||
sendWebRTCInfoEvent("iceconnectionstatechange");
|
|
||||||
});
|
|
||||||
peerConnection.addEventListener("icegatheringstatechange", () => {
|
|
||||||
sendWebRTCInfoEvent("icegatheringstatechange");
|
|
||||||
});
|
|
||||||
peerConnection.addEventListener("negotiationneeded", () => {
|
|
||||||
sendWebRTCInfoEvent("negotiationneeded");
|
|
||||||
});
|
|
||||||
peerConnection.addEventListener("track", () => {
|
|
||||||
sendWebRTCInfoEvent("track");
|
|
||||||
});
|
|
||||||
// NOTE: Not available on Firefox
|
|
||||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1561441
|
|
||||||
peerConnection.addEventListener(
|
|
||||||
"icecandidateerror",
|
|
||||||
({ errorCode, url, errorText }) => {
|
|
||||||
this.client.sendEvent(
|
|
||||||
this.groupCall.room.roomId,
|
|
||||||
"me.robertlong.call.ice_error",
|
|
||||||
{
|
|
||||||
call_id: call.callId,
|
|
||||||
errorCode,
|
|
||||||
url,
|
|
||||||
errorText,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
peerConnection.addEventListener("signalingstatechange", () => {
|
|
||||||
sendWebRTCInfoEvent("signalingstatechange");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,720 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2021 New Vector Ltd
|
|
||||||
|
|
||||||
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 React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
createContext,
|
|
||||||
useMemo,
|
|
||||||
useContext,
|
|
||||||
} from "react";
|
|
||||||
import matrix from "matrix-js-sdk/src/browser-index";
|
|
||||||
import {
|
|
||||||
GroupCallIntent,
|
|
||||||
GroupCallType,
|
|
||||||
} from "matrix-js-sdk/src/browser-index";
|
|
||||||
import { useHistory } from "react-router-dom";
|
|
||||||
|
|
||||||
export const defaultHomeserver =
|
|
||||||
import.meta.env.VITE_DEFAULT_HOMESERVER ||
|
|
||||||
`${window.location.protocol}//${window.location.host}`;
|
|
||||||
|
|
||||||
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
|
|
||||||
|
|
||||||
const ClientContext = createContext();
|
|
||||||
|
|
||||||
function waitForSync(client) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const onSync = (state, _old, data) => {
|
|
||||||
if (state === "PREPARED") {
|
|
||||||
resolve();
|
|
||||||
client.removeListener("sync", onSync);
|
|
||||||
} else if (state === "ERROR") {
|
|
||||||
reject(data?.error);
|
|
||||||
client.removeListener("sync", onSync);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
client.on("sync", onSync);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initClient(clientOptions, guest) {
|
|
||||||
const client = matrix.createClient(clientOptions);
|
|
||||||
|
|
||||||
if (guest) {
|
|
||||||
client.setGuest(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.startClient({
|
|
||||||
// dirty hack to reduce chance of gappy syncs
|
|
||||||
// should be fixed by spotting gaps and backpaginating
|
|
||||||
initialSyncLimit: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForSync(client);
|
|
||||||
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchGroupCall(
|
|
||||||
client,
|
|
||||||
roomIdOrAlias,
|
|
||||||
viaServers = undefined,
|
|
||||||
timeout = 5000
|
|
||||||
) {
|
|
||||||
const { roomId } = await client.joinRoom(roomIdOrAlias, { viaServers });
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let timeoutId;
|
|
||||||
|
|
||||||
function onGroupCallIncoming(groupCall) {
|
|
||||||
if (groupCall && groupCall.room.roomId === roomId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
client.removeListener("GroupCall.incoming", onGroupCallIncoming);
|
|
||||||
resolve(groupCall);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupCall = client.getGroupCallForRoom(roomId);
|
|
||||||
|
|
||||||
if (groupCall) {
|
|
||||||
resolve(groupCall);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.on("GroupCall.incoming", onGroupCallIncoming);
|
|
||||||
|
|
||||||
if (timeout) {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
client.removeListener("GroupCall.incoming", onGroupCallIncoming);
|
|
||||||
reject(new Error("Fetching group call timed out."));
|
|
||||||
}, timeout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClientProvider({ children }) {
|
|
||||||
const history = useHistory();
|
|
||||||
const [
|
|
||||||
{ loading, isAuthenticated, isPasswordlessUser, isGuest, client, userName },
|
|
||||||
setState,
|
|
||||||
] = useState({
|
|
||||||
loading: true,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isPasswordlessUser: false,
|
|
||||||
isGuest: false,
|
|
||||||
client: undefined,
|
|
||||||
userName: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function restore() {
|
|
||||||
try {
|
|
||||||
const authStore = localStorage.getItem("matrix-auth-store");
|
|
||||||
|
|
||||||
if (authStore) {
|
|
||||||
const {
|
|
||||||
user_id,
|
|
||||||
device_id,
|
|
||||||
access_token,
|
|
||||||
guest,
|
|
||||||
passwordlessUser,
|
|
||||||
tempPassword,
|
|
||||||
} = JSON.parse(authStore);
|
|
||||||
|
|
||||||
const client = await initClient(
|
|
||||||
{
|
|
||||||
baseUrl: defaultHomeserver,
|
|
||||||
accessToken: access_token,
|
|
||||||
userId: user_id,
|
|
||||||
deviceId: device_id,
|
|
||||||
},
|
|
||||||
guest
|
|
||||||
);
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
"matrix-auth-store",
|
|
||||||
JSON.stringify({
|
|
||||||
user_id,
|
|
||||||
device_id,
|
|
||||||
access_token,
|
|
||||||
guest,
|
|
||||||
passwordlessUser,
|
|
||||||
tempPassword,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return { client, guest, passwordlessUser };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { client: undefined, guest: false };
|
|
||||||
} catch (err) {
|
|
||||||
localStorage.removeItem("matrix-auth-store");
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
restore()
|
|
||||||
.then(({ client, guest, passwordlessUser }) => {
|
|
||||||
setState({
|
|
||||||
client,
|
|
||||||
loading: false,
|
|
||||||
isAuthenticated: !!client,
|
|
||||||
isPasswordlessUser: !!passwordlessUser,
|
|
||||||
isGuest: guest,
|
|
||||||
userName: client?.getUserIdLocalpart(),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setState({
|
|
||||||
client: undefined,
|
|
||||||
loading: false,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isPasswordlessUser: false,
|
|
||||||
isGuest: false,
|
|
||||||
userName: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback(async (homeserver, username, password) => {
|
|
||||||
try {
|
|
||||||
let loginHomeserverUrl = homeserver.trim();
|
|
||||||
|
|
||||||
if (!loginHomeserverUrl.includes("://")) {
|
|
||||||
loginHomeserverUrl = "https://" + loginHomeserverUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const wellKnownUrl = new URL(
|
|
||||||
"/.well-known/matrix/client",
|
|
||||||
window.location
|
|
||||||
);
|
|
||||||
const response = await fetch(wellKnownUrl);
|
|
||||||
const config = await response.json();
|
|
||||||
|
|
||||||
if (config["m.homeserver"]) {
|
|
||||||
loginHomeserverUrl = config["m.homeserver"];
|
|
||||||
}
|
|
||||||
} catch (error) {}
|
|
||||||
|
|
||||||
const registrationClient = matrix.createClient(loginHomeserverUrl);
|
|
||||||
|
|
||||||
const { user_id, device_id, access_token } =
|
|
||||||
await registrationClient.loginWithPassword(username, password);
|
|
||||||
|
|
||||||
const client = await initClient({
|
|
||||||
baseUrl: loginHomeserverUrl,
|
|
||||||
accessToken: access_token,
|
|
||||||
userId: user_id,
|
|
||||||
deviceId: device_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
"matrix-auth-store",
|
|
||||||
JSON.stringify({ user_id, device_id, access_token })
|
|
||||||
);
|
|
||||||
|
|
||||||
setState({
|
|
||||||
client,
|
|
||||||
loading: false,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isPasswordlessUser: false,
|
|
||||||
isGuest: false,
|
|
||||||
userName: client.getUserIdLocalpart(),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
localStorage.removeItem("matrix-auth-store");
|
|
||||||
setState({
|
|
||||||
client: undefined,
|
|
||||||
loading: false,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isPasswordlessUser: false,
|
|
||||||
isGuest: false,
|
|
||||||
userName: null,
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const registerGuest = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const registrationClient = matrix.createClient(defaultHomeserver);
|
|
||||||
|
|
||||||
const { user_id, device_id, access_token } =
|
|
||||||
await registrationClient.registerGuest({});
|
|
||||||
|
|
||||||
const client = await initClient(
|
|
||||||
{
|
|
||||||
baseUrl: defaultHomeserver,
|
|
||||||
accessToken: access_token,
|
|
||||||
userId: user_id,
|
|
||||||
deviceId: device_id,
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.setProfileInfo("displayname", {
|
|
||||||
displayname: `Guest ${client.getUserIdLocalpart()}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
"matrix-auth-store",
|
|
||||||
JSON.stringify({ user_id, device_id, access_token, guest: true })
|
|
||||||
);
|
|
||||||
|
|
||||||
setState({
|
|
||||||
client,
|
|
||||||
loading: false,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isGuest: true,
|
|
||||||
isPasswordlessUser: false,
|
|
||||||
userName: client.getUserIdLocalpart(),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
localStorage.removeItem("matrix-auth-store");
|
|
||||||
setState({
|
|
||||||
client: undefined,
|
|
||||||
loading: false,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isPasswordlessUser: false,
|
|
||||||
isGuest: false,
|
|
||||||
userName: null,
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const register = useCallback(async (username, password, passwordlessUser) => {
|
|
||||||
try {
|
|
||||||
const registrationClient = matrix.createClient(defaultHomeserver);
|
|
||||||
|
|
||||||
const { user_id, device_id, access_token } =
|
|
||||||
await registrationClient.register(username, password, null, {
|
|
||||||
type: "m.login.dummy",
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = await initClient({
|
|
||||||
baseUrl: defaultHomeserver,
|
|
||||||
accessToken: access_token,
|
|
||||||
userId: user_id,
|
|
||||||
deviceId: device_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const session = { user_id, device_id, access_token, passwordlessUser };
|
|
||||||
|
|
||||||
if (passwordlessUser) {
|
|
||||||
session.tempPassword = password;
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
|
|
||||||
|
|
||||||
setState({
|
|
||||||
client,
|
|
||||||
loading: false,
|
|
||||||
isGuest: false,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isPasswordlessUser: passwordlessUser,
|
|
||||||
userName: client.getUserIdLocalpart(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return client;
|
|
||||||
} catch (err) {
|
|
||||||
localStorage.removeItem("matrix-auth-store");
|
|
||||||
setState({
|
|
||||||
client: undefined,
|
|
||||||
loading: false,
|
|
||||||
isGuest: false,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isPasswordlessUser: false,
|
|
||||||
userName: null,
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const changePassword = useCallback(
|
|
||||||
async (password) => {
|
|
||||||
const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse(
|
|
||||||
localStorage.getItem("matrix-auth-store")
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.setPassword(
|
|
||||||
{
|
|
||||||
type: "m.login.password",
|
|
||||||
identifier: {
|
|
||||||
type: "m.id.user",
|
|
||||||
user: existingSession.user_id,
|
|
||||||
},
|
|
||||||
user: existingSession.user_id,
|
|
||||||
password: tempPassword,
|
|
||||||
},
|
|
||||||
password
|
|
||||||
);
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
"matrix-auth-store",
|
|
||||||
JSON.stringify({
|
|
||||||
...existingSession,
|
|
||||||
passwordlessUser: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setState({
|
|
||||||
client,
|
|
||||||
loading: false,
|
|
||||||
isGuest: false,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isPasswordlessUser: false,
|
|
||||||
userName: client.getUserIdLocalpart(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[client]
|
|
||||||
);
|
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
|
||||||
localStorage.removeItem("matrix-auth-store");
|
|
||||||
window.location = "/";
|
|
||||||
}, [history]);
|
|
||||||
|
|
||||||
const context = useMemo(
|
|
||||||
() => ({
|
|
||||||
loading,
|
|
||||||
isAuthenticated,
|
|
||||||
isPasswordlessUser,
|
|
||||||
isGuest,
|
|
||||||
client,
|
|
||||||
login,
|
|
||||||
registerGuest,
|
|
||||||
register,
|
|
||||||
changePassword,
|
|
||||||
logout,
|
|
||||||
userName,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
loading,
|
|
||||||
isAuthenticated,
|
|
||||||
isPasswordlessUser,
|
|
||||||
isGuest,
|
|
||||||
client,
|
|
||||||
login,
|
|
||||||
registerGuest,
|
|
||||||
register,
|
|
||||||
changePassword,
|
|
||||||
logout,
|
|
||||||
userName,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useClient() {
|
|
||||||
return useContext(ClientContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function roomAliasFromRoomName(roomName) {
|
|
||||||
return roomName
|
|
||||||
.trim()
|
|
||||||
.replace(/\s/g, "-")
|
|
||||||
.replace(/[^\w-]/g, "")
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRoom(client, name) {
|
|
||||||
const { room_id, room_alias } = await client.createRoom({
|
|
||||||
visibility: "private",
|
|
||||||
preset: "public_chat",
|
|
||||||
name,
|
|
||||||
room_alias_name: roomAliasFromRoomName(name),
|
|
||||||
power_level_content_override: {
|
|
||||||
invite: 100,
|
|
||||||
kick: 100,
|
|
||||||
ban: 100,
|
|
||||||
redact: 50,
|
|
||||||
state_default: 0,
|
|
||||||
events_default: 0,
|
|
||||||
users_default: 0,
|
|
||||||
events: {
|
|
||||||
"m.room.power_levels": 100,
|
|
||||||
"m.room.history_visibility": 100,
|
|
||||||
"m.room.tombstone": 100,
|
|
||||||
"m.room.encryption": 100,
|
|
||||||
"m.room.name": 50,
|
|
||||||
"m.room.message": 0,
|
|
||||||
"m.room.encrypted": 50,
|
|
||||||
"m.sticker": 50,
|
|
||||||
"org.matrix.msc3401.call.member": 0,
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
[client.getUserId()]: 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.setGuestAccess(room_id, {
|
|
||||||
allowJoin: true,
|
|
||||||
allowRead: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.createGroupCall(
|
|
||||||
room_id,
|
|
||||||
GroupCallType.Video,
|
|
||||||
GroupCallIntent.Prompt
|
|
||||||
);
|
|
||||||
|
|
||||||
return room_alias || room_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLoadGroupCall(client, roomId, viaServers) {
|
|
||||||
const [state, setState] = useState({
|
|
||||||
loading: true,
|
|
||||||
error: undefined,
|
|
||||||
groupCall: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setState({ loading: true });
|
|
||||||
fetchGroupCall(client, roomId, viaServers, 30000)
|
|
||||||
.then((groupCall) => setState({ loading: false, groupCall }))
|
|
||||||
.catch((error) => setState({ loading: false, error }));
|
|
||||||
}, [client, roomId]);
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tsCache = {};
|
|
||||||
|
|
||||||
function getLastTs(client, r) {
|
|
||||||
if (tsCache[r.roomId]) {
|
|
||||||
return tsCache[r.roomId];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!r || !r.timeline) {
|
|
||||||
const ts = Number.MAX_SAFE_INTEGER;
|
|
||||||
tsCache[r.roomId] = ts;
|
|
||||||
return ts;
|
|
||||||
}
|
|
||||||
|
|
||||||
const myUserId = client.getUserId();
|
|
||||||
|
|
||||||
if (r.getMyMembership() !== "join") {
|
|
||||||
const membershipEvent = r.currentState.getStateEvents(
|
|
||||||
"m.room.member",
|
|
||||||
myUserId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (membershipEvent && !Array.isArray(membershipEvent)) {
|
|
||||||
const ts = membershipEvent.getTs();
|
|
||||||
tsCache[r.roomId] = ts;
|
|
||||||
return ts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = r.timeline.length - 1; i >= 0; --i) {
|
|
||||||
const ev = r.timeline[i];
|
|
||||||
const ts = ev.getTs();
|
|
||||||
|
|
||||||
if (ts) {
|
|
||||||
tsCache[r.roomId] = ts;
|
|
||||||
return ts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ts = Number.MAX_SAFE_INTEGER;
|
|
||||||
tsCache[r.roomId] = ts;
|
|
||||||
return ts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortRooms(client, rooms) {
|
|
||||||
return rooms.sort((a, b) => {
|
|
||||||
return getLastTs(client, b) - getLastTs(client, a);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGroupCallRooms(client) {
|
|
||||||
const [rooms, setRooms] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function updateRooms() {
|
|
||||||
const groupCalls = client.groupCallEventHandler.groupCalls.values();
|
|
||||||
const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room);
|
|
||||||
const sortedRooms = sortRooms(client, rooms);
|
|
||||||
const items = sortedRooms.map((room) => {
|
|
||||||
const groupCall = client.getGroupCallForRoom(room.roomId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
roomId: room.getCanonicalAlias() || room.roomId,
|
|
||||||
roomName: room.name,
|
|
||||||
avatarUrl: null,
|
|
||||||
room,
|
|
||||||
groupCall,
|
|
||||||
participants: [...groupCall.participants],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setRooms(items);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRooms();
|
|
||||||
|
|
||||||
client.on("GroupCall.incoming", updateRooms);
|
|
||||||
client.on("GroupCall.participants", updateRooms);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
client.removeListener("GroupCall.incoming", updateRooms);
|
|
||||||
client.removeListener("GroupCall.participants", updateRooms);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return rooms;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePublicRooms(client, publicSpaceRoomId, maxRooms = 50) {
|
|
||||||
const [rooms, setRooms] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (publicSpaceRoomId) {
|
|
||||||
client.getRoomHierarchy(publicSpaceRoomId, maxRooms).then(({ rooms }) => {
|
|
||||||
const filteredRooms = rooms
|
|
||||||
.filter((room) => room.room_type !== "m.space")
|
|
||||||
.map((room) => ({
|
|
||||||
roomId: room.room_alias || room.room_id,
|
|
||||||
roomName: room.name,
|
|
||||||
avatarUrl: null,
|
|
||||||
room,
|
|
||||||
participants: [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
setRooms(filteredRooms);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setRooms([]);
|
|
||||||
}
|
|
||||||
}, [publicSpaceRoomId]);
|
|
||||||
|
|
||||||
return rooms;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRoomUrl(roomId) {
|
|
||||||
if (roomId.startsWith("#")) {
|
|
||||||
const [localPart, host] = roomId.replace("#", "").split(":");
|
|
||||||
|
|
||||||
if (host !== defaultHomeserverHost) {
|
|
||||||
return `${window.location.host}/room/${roomId}`;
|
|
||||||
} else {
|
|
||||||
return `${window.location.host}/${localPart}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return `${window.location.host}/room/${roomId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAvatarUrl(client, mxcUrl, avatarSize = 96) {
|
|
||||||
const width = Math.floor(avatarSize * window.devicePixelRatio);
|
|
||||||
const height = Math.floor(avatarSize * window.devicePixelRatio);
|
|
||||||
return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, "crop");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useProfile(client) {
|
|
||||||
const [{ loading, displayName, avatarUrl, error, success }, setState] =
|
|
||||||
useState(() => {
|
|
||||||
const user = client?.getUser(client.getUserId());
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
loading: false,
|
|
||||||
displayName: user?.displayName,
|
|
||||||
avatarUrl: user && client && getAvatarUrl(client, user.avatarUrl),
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onChangeUser = (_event, { displayName, avatarUrl }) => {
|
|
||||||
setState({
|
|
||||||
success: false,
|
|
||||||
loading: false,
|
|
||||||
displayName,
|
|
||||||
avatarUrl: getAvatarUrl(client, avatarUrl),
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let user;
|
|
||||||
|
|
||||||
if (client) {
|
|
||||||
const userId = client.getUserId();
|
|
||||||
user = client.getUser(userId);
|
|
||||||
user.on("User.displayName", onChangeUser);
|
|
||||||
user.on("User.avatarUrl", onChangeUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (user) {
|
|
||||||
user.removeListener("User.displayName", onChangeUser);
|
|
||||||
user.removeListener("User.avatarUrl", onChangeUser);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
const saveProfile = useCallback(
|
|
||||||
async ({ displayName, avatar }) => {
|
|
||||||
if (client) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
loading: true,
|
|
||||||
error: null,
|
|
||||||
success: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.setDisplayName(displayName);
|
|
||||||
|
|
||||||
let mxcAvatarUrl;
|
|
||||||
|
|
||||||
if (avatar) {
|
|
||||||
mxcAvatarUrl = await client.uploadContent(avatar);
|
|
||||||
await client.setAvatarUrl(mxcAvatarUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
displayName,
|
|
||||||
avatarUrl: mxcAvatarUrl
|
|
||||||
? getAvatarUrl(client, mxcAvatarUrl)
|
|
||||||
: prev.avatarUrl,
|
|
||||||
loading: false,
|
|
||||||
success: true,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
loading: false,
|
|
||||||
error,
|
|
||||||
success: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error("Client not initialized before calling saveProfile");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[client]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { loading, error, displayName, avatarUrl, saveProfile, success };
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import styles from "./Facepile.module.css";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { Avatar } from "./Avatar";
|
|
||||||
import { getAvatarUrl } from "./ConferenceCallManagerHooks";
|
|
||||||
|
|
||||||
export function Facepile({ className, client, participants, ...rest }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.facepile, className)}
|
|
||||||
title={participants.map((member) => member.name).join(", ")}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{participants.slice(0, 3).map((member, i) => {
|
|
||||||
const avatarUrl = member.user?.avatarUrl;
|
|
||||||
return (
|
|
||||||
<Avatar
|
|
||||||
key={member.userId}
|
|
||||||
size="xs"
|
|
||||||
src={avatarUrl && getAvatarUrl(client, avatarUrl, 22)}
|
|
||||||
fallback={member.name.slice(0, 1).toUpperCase()}
|
|
||||||
className={styles.avatar}
|
|
||||||
style={{ left: i * 22 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{participants.length > 3 && (
|
|
||||||
<Avatar
|
|
||||||
key="additional"
|
|
||||||
size="xs"
|
|
||||||
fallback={`+${participants.length - 3}`}
|
|
||||||
className={styles.avatar}
|
|
||||||
style={{ left: 3 * 22 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
.facepile {
|
.facepile {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.facepile.xs {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facepile.sm {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facepile.md {
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
.facepile .avatar {
|
.facepile .avatar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
border: 1px solid var(--bgColor2);
|
border: 1px solid var(--system);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facepile.md .avatar {
|
||||||
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
85
src/Facepile.tsx
Normal file
85
src/Facepile.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 New Vector Ltd
|
||||||
|
|
||||||
|
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 React, { HTMLAttributes } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
|
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||||
|
|
||||||
|
import styles from "./Facepile.module.css";
|
||||||
|
import { Avatar, Size, sizes } from "./Avatar";
|
||||||
|
|
||||||
|
const overlapMap: Partial<Record<Size, number>> = {
|
||||||
|
[Size.XS]: 2,
|
||||||
|
[Size.SM]: 4,
|
||||||
|
[Size.MD]: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
className: string;
|
||||||
|
client: MatrixClient;
|
||||||
|
participants: RoomMember[];
|
||||||
|
max?: number;
|
||||||
|
size?: Size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Facepile({
|
||||||
|
className,
|
||||||
|
client,
|
||||||
|
participants,
|
||||||
|
max = 3,
|
||||||
|
size = Size.XS,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
const _size = sizes.get(size);
|
||||||
|
const _overlap = overlapMap[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.facepile, styles[size], className)}
|
||||||
|
title={participants.map((member) => member.name).join(", ")}
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
Math.min(participants.length, max + 1) * (_size - _overlap) +
|
||||||
|
_overlap,
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{participants.slice(0, max).map((member, i) => {
|
||||||
|
const avatarUrl = member.user?.avatarUrl;
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
key={member.userId}
|
||||||
|
size={size}
|
||||||
|
src={avatarUrl}
|
||||||
|
fallback={member.name.slice(0, 1).toUpperCase()}
|
||||||
|
className={styles.avatar}
|
||||||
|
style={{ left: i * (_size - _overlap) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{participants.length > max && (
|
||||||
|
<Avatar
|
||||||
|
key="additional"
|
||||||
|
size={size}
|
||||||
|
fallback={`+${participants.length - max}`}
|
||||||
|
className={styles.avatar}
|
||||||
|
style={{ left: max * (_size - _overlap) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import React, { useCallback, useEffect } from "react";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import styles from "./FullScreenView.module.css";
|
|
||||||
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { LinkButton, Button } from "./button";
|
|
||||||
|
|
||||||
export function FullScreenView({ className, children }) {
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.page, className)}>
|
|
||||||
<Header>
|
|
||||||
<LeftNav>
|
|
||||||
<HeaderLogo />
|
|
||||||
</LeftNav>
|
|
||||||
<RightNav />
|
|
||||||
</Header>
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.content}>{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorView({ error }) {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.error(error);
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
const onReload = useCallback(() => {
|
|
||||||
window.location = "/";
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullScreenView>
|
|
||||||
<h1>Error</h1>
|
|
||||||
<p>{error.message}</p>
|
|
||||||
{location.pathname === "/" ? (
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="default"
|
|
||||||
className={styles.homeLink}
|
|
||||||
onPress={onReload}
|
|
||||||
>
|
|
||||||
Return to home screen
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<LinkButton
|
|
||||||
size="lg"
|
|
||||||
variant="default"
|
|
||||||
className={styles.homeLink}
|
|
||||||
to="/"
|
|
||||||
>
|
|
||||||
Return to home screen
|
|
||||||
</LinkButton>
|
|
||||||
)}
|
|
||||||
</FullScreenView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadingView() {
|
|
||||||
return (
|
|
||||||
<FullScreenView>
|
|
||||||
<h1>Loading...</h1>
|
|
||||||
</FullScreenView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -36,6 +36,12 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.homeLink {
|
/* Make the buttons the same width */
|
||||||
|
.wideButton {
|
||||||
width: 291px;
|
width: 291px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fixed height to avoid content jumping around*/
|
||||||
|
.sendLogsSection {
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|||||||
130
src/FullScreenView.tsx
Normal file
130
src/FullScreenView.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { ReactNode, useCallback, useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
|
||||||
|
import { LinkButton, Button } from "./button";
|
||||||
|
import { useSubmitRageshake } from "./settings/submit-rageshake";
|
||||||
|
import { ErrorMessage } from "./input/Input";
|
||||||
|
import styles from "./FullScreenView.module.css";
|
||||||
|
|
||||||
|
interface FullScreenViewProps {
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FullScreenView({ className, children }: FullScreenViewProps) {
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles.page, className)}>
|
||||||
|
<Header>
|
||||||
|
<LeftNav>
|
||||||
|
<HeaderLogo />
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav />
|
||||||
|
</Header>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.content}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorViewProps {
|
||||||
|
error: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorView({ error }: ErrorViewProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
const onReload = useCallback(() => {
|
||||||
|
window.location.href = "/";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FullScreenView>
|
||||||
|
<h1>Error</h1>
|
||||||
|
<p>{error.message}</p>
|
||||||
|
{location.pathname === "/" ? (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="default"
|
||||||
|
className={styles.homeLink}
|
||||||
|
onPress={onReload}
|
||||||
|
>
|
||||||
|
Return to home screen
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<LinkButton
|
||||||
|
size="lg"
|
||||||
|
variant="default"
|
||||||
|
className={styles.homeLink}
|
||||||
|
to="/"
|
||||||
|
>
|
||||||
|
Return to home screen
|
||||||
|
</LinkButton>
|
||||||
|
)}
|
||||||
|
</FullScreenView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrashView() {
|
||||||
|
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
|
||||||
|
|
||||||
|
const sendDebugLogs = useCallback(() => {
|
||||||
|
submitRageshake({
|
||||||
|
description: "**Soft Crash**",
|
||||||
|
sendLogs: true,
|
||||||
|
});
|
||||||
|
}, [submitRageshake]);
|
||||||
|
|
||||||
|
const onReload = useCallback(() => {
|
||||||
|
window.location.href = "/";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let logsComponent;
|
||||||
|
if (sent) {
|
||||||
|
logsComponent = <div>Thanks! We'll get right on it.</div>;
|
||||||
|
} else if (sending) {
|
||||||
|
logsComponent = <div>Sending...</div>;
|
||||||
|
} else {
|
||||||
|
logsComponent = (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="default"
|
||||||
|
onPress={sendDebugLogs}
|
||||||
|
className={styles.wideButton}
|
||||||
|
>
|
||||||
|
Send debug logs
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FullScreenView>
|
||||||
|
<h1>Oops, something's gone wrong.</h1>
|
||||||
|
<p>Submitting debug logs will help us track down the problem.</p>
|
||||||
|
<div className={styles.sendLogsSection}>{logsComponent}</div>
|
||||||
|
{error && <ErrorMessage>Couldn't send debug logs!</ErrorMessage>}
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="default"
|
||||||
|
className={styles.wideButton}
|
||||||
|
onPress={onReload}
|
||||||
|
>
|
||||||
|
Return to home screen
|
||||||
|
</Button>
|
||||||
|
</FullScreenView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingView() {
|
||||||
|
return (
|
||||||
|
<FullScreenView>
|
||||||
|
<h1>Loading...</h1>
|
||||||
|
</FullScreenView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Button } from "./button";
|
|
||||||
import { PopoverMenuTrigger } from "./PopoverMenu";
|
|
||||||
import { ReactComponent as SpotlightIcon } from "./icons/Spotlight.svg";
|
|
||||||
import { ReactComponent as FreedomIcon } from "./icons/Freedom.svg";
|
|
||||||
import { ReactComponent as CheckIcon } from "./icons/Check.svg";
|
|
||||||
import styles from "./GridLayoutMenu.module.css";
|
|
||||||
import { Menu } from "./Menu";
|
|
||||||
import { Item } from "@react-stately/collections";
|
|
||||||
import { Tooltip, TooltipTrigger } from "./Tooltip";
|
|
||||||
|
|
||||||
export function GridLayoutMenu({ layout, setLayout }) {
|
|
||||||
return (
|
|
||||||
<PopoverMenuTrigger placement="bottom right">
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button variant="icon">
|
|
||||||
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
|
|
||||||
</Button>
|
|
||||||
{(props) => (
|
|
||||||
<Tooltip position="bottom" {...props}>
|
|
||||||
Layout Type
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
{(props) => (
|
|
||||||
<Menu {...props} label="Grid layout menu" onAction={setLayout}>
|
|
||||||
<Item key="freedom" textValue="Freedom">
|
|
||||||
<FreedomIcon />
|
|
||||||
<span>Freedom</span>
|
|
||||||
{layout === "freedom" && <CheckIcon className={styles.checkIcon} />}
|
|
||||||
</Item>
|
|
||||||
<Item key="spotlight" textValue="Spotlight">
|
|
||||||
<SpotlightIcon />
|
|
||||||
<span>Spotlight</span>
|
|
||||||
{layout === "spotlight" && (
|
|
||||||
<CheckIcon className={styles.checkIcon} />
|
|
||||||
)}
|
|
||||||
</Item>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</PopoverMenuTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
.checkIcon {
|
|
||||||
position: absolute;
|
|
||||||
right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkIcon * {
|
|
||||||
stroke: var(--textColor1);
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import { Resizable } from "re-resizable";
|
|
||||||
import React, { useEffect, useState, useMemo } from "react";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import ReactJson from "react-json-view";
|
|
||||||
|
|
||||||
function getCallUserId(call) {
|
|
||||||
return call.getOpponentMember()?.userId || call.invitee || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCallState(call) {
|
|
||||||
return {
|
|
||||||
id: call.callId,
|
|
||||||
opponentMemberId: getCallUserId(call),
|
|
||||||
state: call.state,
|
|
||||||
direction: call.direction,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHangupCallState(call) {
|
|
||||||
return {
|
|
||||||
...getCallState(call),
|
|
||||||
hangupReason: call.hangupReason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GroupCallInspector({ client, groupCall, show }) {
|
|
||||||
const [roomStateEvents, setRoomStateEvents] = useState([]);
|
|
||||||
const [toDeviceEvents, setToDeviceEvents] = useState([]);
|
|
||||||
const [state, setState] = useState({
|
|
||||||
userId: client.getUserId(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateState = useCallback(
|
|
||||||
(next) => setState((prev) => ({ ...prev, ...next })),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onUpdateRoomState(event) {
|
|
||||||
if (event) {
|
|
||||||
setRoomStateEvents((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
eventType: event.getType(),
|
|
||||||
stateKey: event.getStateKey(),
|
|
||||||
content: event.getContent(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomEvent = groupCall.room.currentState
|
|
||||||
.getStateEvents("org.matrix.msc3401.call", groupCall.groupCallId)
|
|
||||||
.getContent();
|
|
||||||
|
|
||||||
const memberEvents = Object.fromEntries(
|
|
||||||
groupCall.room.currentState
|
|
||||||
.getStateEvents("org.matrix.msc3401.call.member")
|
|
||||||
.map((event) => [event.getStateKey(), event.getContent()])
|
|
||||||
);
|
|
||||||
|
|
||||||
updateState({
|
|
||||||
["org.matrix.msc3401.call"]: roomEvent,
|
|
||||||
["org.matrix.msc3401.call.member"]: memberEvents,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCallsChanged() {
|
|
||||||
const calls = groupCall.calls.reduce((obj, call) => {
|
|
||||||
obj[
|
|
||||||
`${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
|
|
||||||
] = getCallState(call);
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
updateState({ calls });
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCallHangup(call) {
|
|
||||||
setState(({ hangupCalls, ...rest }) => ({
|
|
||||||
...rest,
|
|
||||||
hangupCalls: {
|
|
||||||
...hangupCalls,
|
|
||||||
[`${call.callId} (${
|
|
||||||
call.getOpponentMember()?.userId || call.sender
|
|
||||||
})`]: getHangupCallState(call),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function onToDeviceEvent(event) {
|
|
||||||
const eventType = event.getType();
|
|
||||||
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
eventType.startsWith("m.call.") ||
|
|
||||||
eventType.startsWith("org.matrix.call.")
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = event.getContent();
|
|
||||||
|
|
||||||
if (content.conf_id && content.conf_id !== groupCall.groupCallId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setToDeviceEvents((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ eventType, content, sender: event.getSender() },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.on("RoomState.events", onUpdateRoomState);
|
|
||||||
groupCall.on("calls_changed", onCallsChanged);
|
|
||||||
client.on("state", onCallsChanged);
|
|
||||||
client.on("hangup", onCallHangup);
|
|
||||||
client.on("toDeviceEvent", onToDeviceEvent);
|
|
||||||
|
|
||||||
onUpdateRoomState();
|
|
||||||
}, [client, groupCall]);
|
|
||||||
|
|
||||||
const toDeviceEventsByCall = useMemo(() => {
|
|
||||||
const result = {};
|
|
||||||
|
|
||||||
for (const event of toDeviceEvents) {
|
|
||||||
const callId = event.content.call_id;
|
|
||||||
const key = `${callId} (${event.sender})`;
|
|
||||||
result[key] = result[key] || [];
|
|
||||||
result[key].push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [toDeviceEvents]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timeout;
|
|
||||||
|
|
||||||
async function updateCallStats() {
|
|
||||||
const callIds = groupCall.calls.map(
|
|
||||||
(call) =>
|
|
||||||
`${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
|
|
||||||
);
|
|
||||||
const stats = await Promise.all(
|
|
||||||
groupCall.calls.map((call) =>
|
|
||||||
call.peerConn
|
|
||||||
? call.peerConn
|
|
||||||
.getStats(null)
|
|
||||||
.then((stats) =>
|
|
||||||
Object.fromEntries(
|
|
||||||
Array.from(stats).map(([_id, report], i) => [
|
|
||||||
report.type + i,
|
|
||||||
report,
|
|
||||||
])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: Promise.resolve(null)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const callStats = {};
|
|
||||||
|
|
||||||
for (let i = 0; i < groupCall.calls.length; i++) {
|
|
||||||
callStats[callIds[i]] = stats[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
updateState({ callStats });
|
|
||||||
timeout = setTimeout(updateCallStats, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (show) {
|
|
||||||
updateCallStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}, [show]);
|
|
||||||
|
|
||||||
if (!show) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Resizable enable={{ top: true }} defaultSize={{ height: 200 }}>
|
|
||||||
<ReactJson
|
|
||||||
theme="monokai"
|
|
||||||
src={{
|
|
||||||
...state,
|
|
||||||
roomStateEvents,
|
|
||||||
toDeviceEvents,
|
|
||||||
toDeviceEventsByCall,
|
|
||||||
}}
|
|
||||||
name={null}
|
|
||||||
indentWidth={2}
|
|
||||||
collapsed={1}
|
|
||||||
displayDataTypes={false}
|
|
||||||
displayObjectSize={false}
|
|
||||||
enableClipboard={false}
|
|
||||||
style={{ height: "100%", overflowY: "scroll" }}
|
|
||||||
/>
|
|
||||||
</Resizable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import React, { useRef } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import styles from "./Header.module.css";
|
|
||||||
import { ReactComponent as Logo } from "./icons/Logo.svg";
|
|
||||||
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
|
|
||||||
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
|
|
||||||
import { useButton } from "@react-aria/button";
|
|
||||||
|
|
||||||
export function Header({ children, className, ...rest }) {
|
|
||||||
return (
|
|
||||||
<header className={classNames(styles.header, className)} {...rest}>
|
|
||||||
{children}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LeftNav({ children, className, ...rest }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.nav, styles.leftNav, className)}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RightNav({ children, className, ...rest }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.nav, styles.rightNav, className)}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeaderLogo() {
|
|
||||||
return (
|
|
||||||
<Link className={styles.logo} to="/">
|
|
||||||
<Logo />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoomHeaderInfo({ roomName }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.roomAvatar}>
|
|
||||||
<VideoIcon width={16} height={16} />
|
|
||||||
</div>
|
|
||||||
<h3>{roomName}</h3>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoomSetupHeaderInfo({ roomName, ...rest }) {
|
|
||||||
const ref = useRef();
|
|
||||||
const { buttonProps } = useButton(rest, ref);
|
|
||||||
return (
|
|
||||||
<button className={styles.backButton} ref={ref} {...buttonProps}>
|
|
||||||
<ArrowLeftIcon width={16} height={16} />
|
|
||||||
<RoomHeaderInfo roomName={roomName} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,16 +16,24 @@
|
|||||||
height: 64px;
|
height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.headerLogo {
|
||||||
display: flex;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leftNav.hideMobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.leftNav > * {
|
.leftNav > * {
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leftNav h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.rightNav {
|
.rightNav {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
@@ -34,13 +42,17 @@
|
|||||||
margin-right: 24px;
|
margin-right: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rightNav.hideMobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.nav > :last-child {
|
.nav > :last-child {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.roomAvatar {
|
.roomAvatar {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: none;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
@@ -58,7 +70,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
color: var(--textColor1);
|
color: var(--primary-content);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -92,8 +104,37 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.versionMismatchWarning {
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versionMismatchWarning::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
mask-image: url("./icons/AlertTriangleFilled.svg");
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
background-color: var(--alert);
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
@media (min-width: 800px) {
|
||||||
|
.headerLogo,
|
||||||
|
.roomAvatar,
|
||||||
|
.leftNav.hideMobile,
|
||||||
|
.rightNav.hideMobile {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftNav h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
height: 98px;
|
height: 76px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
src/Header.stories.jsx
Normal file
106
src/Header.stories.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { GridLayoutMenu } from "./room/GridLayoutMenu";
|
||||||
|
import {
|
||||||
|
Header,
|
||||||
|
HeaderLogo,
|
||||||
|
LeftNav,
|
||||||
|
RightNav,
|
||||||
|
RoomHeaderInfo,
|
||||||
|
} from "./Header";
|
||||||
|
import { UserMenu } from "./UserMenu";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "Header",
|
||||||
|
component: Header,
|
||||||
|
parameters: {
|
||||||
|
layout: "fullscreen",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HomeAnonymous = () => (
|
||||||
|
<Header>
|
||||||
|
<LeftNav>
|
||||||
|
<HeaderLogo />
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav>
|
||||||
|
<UserMenu />
|
||||||
|
</RightNav>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const HomeNamedGuest = () => (
|
||||||
|
<Header>
|
||||||
|
<LeftNav>
|
||||||
|
<HeaderLogo />
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav>
|
||||||
|
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
|
||||||
|
</RightNav>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const HomeLoggedIn = () => (
|
||||||
|
<Header>
|
||||||
|
<LeftNav>
|
||||||
|
<HeaderLogo />
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav>
|
||||||
|
<UserMenu isAuthenticated displayName="Yara" />
|
||||||
|
</RightNav>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LobbyNamedGuest = () => (
|
||||||
|
<Header>
|
||||||
|
<LeftNav>
|
||||||
|
<RoomHeaderInfo roomName="Q4Roadmap" />
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav>
|
||||||
|
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
|
||||||
|
</RightNav>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LobbyLoggedIn = () => (
|
||||||
|
<Header>
|
||||||
|
<LeftNav>
|
||||||
|
<RoomHeaderInfo roomName="Q4Roadmap" />
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav>
|
||||||
|
<UserMenu isAuthenticated displayName="Yara" />
|
||||||
|
</RightNav>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InRoomNamedGuest = () => (
|
||||||
|
<Header>
|
||||||
|
<LeftNav>
|
||||||
|
<RoomHeaderInfo roomName="Q4Roadmap" />
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav>
|
||||||
|
<GridLayoutMenu layout="freedom" />
|
||||||
|
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
|
||||||
|
</RightNav>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InRoomLoggedIn = () => (
|
||||||
|
<Header>
|
||||||
|
<LeftNav>
|
||||||
|
<RoomHeaderInfo roomName="Q4Roadmap" />
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav>
|
||||||
|
<GridLayoutMenu layout="freedom" />
|
||||||
|
<UserMenu isAuthenticated displayName="Yara" />
|
||||||
|
</RightNav>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CreateAccount = () => (
|
||||||
|
<Header>
|
||||||
|
<LeftNav>
|
||||||
|
<HeaderLogo />
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav></RightNav>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
178
src/Header.tsx
Normal file
178
src/Header.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import React, { HTMLAttributes, ReactNode, useCallback, useRef } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useButton } from "@react-aria/button";
|
||||||
|
import { AriaButtonProps } from "@react-types/button";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
|
||||||
|
import styles from "./Header.module.css";
|
||||||
|
import { useModalTriggerState } from "./Modal";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import { ReactComponent as Logo } from "./icons/Logo.svg";
|
||||||
|
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
|
||||||
|
import { Subtitle } from "./typography/Typography";
|
||||||
|
import { Avatar, Size } from "./Avatar";
|
||||||
|
import { IncompatibleVersionModal } from "./IncompatibleVersionModal";
|
||||||
|
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
|
||||||
|
|
||||||
|
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ children, className, ...rest }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className={classNames(styles.header, className)} {...rest}>
|
||||||
|
{children}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
hideMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeftNav({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
hideMobile,
|
||||||
|
...rest
|
||||||
|
}: LeftNavProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
styles.nav,
|
||||||
|
styles.leftNav,
|
||||||
|
{ [styles.hideMobile]: hideMobile },
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RightNavProps extends HTMLAttributes<HTMLElement> {
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
hideMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RightNav({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
hideMobile,
|
||||||
|
...rest
|
||||||
|
}: RightNavProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
styles.nav,
|
||||||
|
styles.rightNav,
|
||||||
|
{ [styles.hideMobile]: hideMobile },
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderLogoProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderLogo({ className }: HeaderLogoProps) {
|
||||||
|
return (
|
||||||
|
<Link className={classNames(styles.headerLogo, className)} to="/">
|
||||||
|
<Logo />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomHeaderInfo {
|
||||||
|
roomName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.roomAvatar}>
|
||||||
|
<Avatar
|
||||||
|
size={Size.MD}
|
||||||
|
src={avatarUrl}
|
||||||
|
bgKey={roomName}
|
||||||
|
fallback={roomName.slice(0, 1).toUpperCase()}
|
||||||
|
/>
|
||||||
|
<VideoIcon width={16} height={16} />
|
||||||
|
</div>
|
||||||
|
<Subtitle fontWeight="semiBold">{roomName}</Subtitle>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomSetupHeaderInfoProps extends AriaButtonProps<"button"> {
|
||||||
|
roomName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
isEmbedded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomSetupHeaderInfo({
|
||||||
|
roomName,
|
||||||
|
avatarUrl,
|
||||||
|
isEmbedded,
|
||||||
|
...rest
|
||||||
|
}: RoomSetupHeaderInfoProps) {
|
||||||
|
const ref = useRef();
|
||||||
|
const { buttonProps } = useButton(rest, ref);
|
||||||
|
|
||||||
|
if (isEmbedded) {
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={styles.backButton} ref={ref} {...buttonProps}>
|
||||||
|
<ArrowLeftIcon width={16} height={16} />
|
||||||
|
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionMismatchWarningProps {
|
||||||
|
users: Set<string>;
|
||||||
|
room: Room;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionMismatchWarning({
|
||||||
|
users,
|
||||||
|
room,
|
||||||
|
}: VersionMismatchWarningProps) {
|
||||||
|
const { modalState, modalProps } = useModalTriggerState();
|
||||||
|
|
||||||
|
const onDetailsClick = useCallback(() => {
|
||||||
|
modalState.open();
|
||||||
|
}, [modalState]);
|
||||||
|
|
||||||
|
if (users.size === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.versionMismatchWarning}>
|
||||||
|
Incomaptible versions!
|
||||||
|
<Button variant="link" onClick={onDetailsClick}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
{modalState.isOpen && (
|
||||||
|
<IncompatibleVersionModal userIds={users} room={room} {...modalProps} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
src/Home.jsx
352
src/Home.jsx
@@ -1,352 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2021 New Vector Ltd
|
|
||||||
|
|
||||||
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 React, { useCallback, useState } from "react";
|
|
||||||
import { useHistory, Link } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
useClient,
|
|
||||||
useGroupCallRooms,
|
|
||||||
usePublicRooms,
|
|
||||||
createRoom,
|
|
||||||
roomAliasFromRoomName,
|
|
||||||
} from "./ConferenceCallManagerHooks";
|
|
||||||
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
|
|
||||||
import styles from "./Home.module.css";
|
|
||||||
import { FieldRow, InputField, ErrorMessage } from "./Input";
|
|
||||||
import { UserMenu } from "./UserMenu";
|
|
||||||
import { Button } from "./button";
|
|
||||||
import { CallList } from "./CallList";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { ErrorView, LoadingView } from "./FullScreenView";
|
|
||||||
import { useModalTriggerState } from "./Modal";
|
|
||||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
|
||||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
|
||||||
|
|
||||||
export function Home() {
|
|
||||||
const {
|
|
||||||
isAuthenticated,
|
|
||||||
isGuest,
|
|
||||||
isPasswordlessUser,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
client,
|
|
||||||
register,
|
|
||||||
} = useClient();
|
|
||||||
|
|
||||||
const history = useHistory();
|
|
||||||
const [creatingRoom, setCreatingRoom] = useState(false);
|
|
||||||
const [createRoomError, setCreateRoomError] = useState();
|
|
||||||
const { modalState, modalProps } = useModalTriggerState();
|
|
||||||
const [existingRoomId, setExistingRoomId] = useState();
|
|
||||||
|
|
||||||
const onCreateRoom = useCallback(
|
|
||||||
(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = new FormData(e.target);
|
|
||||||
const roomName = data.get("roomName");
|
|
||||||
const userName = data.get("userName");
|
|
||||||
|
|
||||||
async function onCreateRoom() {
|
|
||||||
let _client = client;
|
|
||||||
|
|
||||||
if (!_client || isGuest) {
|
|
||||||
_client = await register(userName, randomString(16), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomIdOrAlias = await createRoom(_client, roomName);
|
|
||||||
|
|
||||||
if (roomIdOrAlias) {
|
|
||||||
history.push(`/room/${roomIdOrAlias}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreateRoomError(undefined);
|
|
||||||
setCreatingRoom(true);
|
|
||||||
|
|
||||||
return onCreateRoom().catch((error) => {
|
|
||||||
if (error.errcode === "M_ROOM_IN_USE") {
|
|
||||||
setExistingRoomId(roomAliasFromRoomName(roomName));
|
|
||||||
setCreateRoomError(undefined);
|
|
||||||
modalState.open();
|
|
||||||
} else {
|
|
||||||
setCreateRoomError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreatingRoom(false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[client, history, register, isGuest]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onJoinRoom = useCallback(
|
|
||||||
(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = new FormData(e.target);
|
|
||||||
const roomId = data.get("roomId");
|
|
||||||
history.push(`/${roomId}`);
|
|
||||||
},
|
|
||||||
[history]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onJoinExistingRoom = useCallback(() => {
|
|
||||||
history.push(`/${existingRoomId}`);
|
|
||||||
}, [history, existingRoomId]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <LoadingView />;
|
|
||||||
} else if (error) {
|
|
||||||
return <ErrorView error={error} />;
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!isAuthenticated || isGuest ? (
|
|
||||||
<UnregisteredView
|
|
||||||
onCreateRoom={onCreateRoom}
|
|
||||||
createRoomError={createRoomError}
|
|
||||||
creatingRoom={creatingRoom}
|
|
||||||
onJoinRoom={onJoinRoom}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<RegisteredView
|
|
||||||
client={client}
|
|
||||||
isPasswordlessUser={isPasswordlessUser}
|
|
||||||
isGuest={isGuest}
|
|
||||||
onCreateRoom={onCreateRoom}
|
|
||||||
createRoomError={createRoomError}
|
|
||||||
creatingRoom={creatingRoom}
|
|
||||||
onJoinRoom={onJoinRoom}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{modalState.isOpen && (
|
|
||||||
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnregisteredView({
|
|
||||||
onCreateRoom,
|
|
||||||
createRoomError,
|
|
||||||
creatingRoom,
|
|
||||||
onJoinRoom,
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.home, styles.fullWidth)}>
|
|
||||||
<Header className={styles.header}>
|
|
||||||
<LeftNav>
|
|
||||||
<HeaderLogo />
|
|
||||||
</LeftNav>
|
|
||||||
<RightNav>
|
|
||||||
<UserMenu />
|
|
||||||
</RightNav>
|
|
||||||
</Header>
|
|
||||||
<div className={styles.splitContainer}>
|
|
||||||
<div className={styles.left}>
|
|
||||||
<div className={styles.content}>
|
|
||||||
<div className={styles.centered}>
|
|
||||||
<form onSubmit={onJoinRoom}>
|
|
||||||
<h1>Join a call</h1>
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<InputField
|
|
||||||
id="roomId"
|
|
||||||
name="roomId"
|
|
||||||
label="Call ID"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Call ID"
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<Button className={styles.button} type="submit">
|
|
||||||
Join call
|
|
||||||
</Button>
|
|
||||||
</FieldRow>
|
|
||||||
</form>
|
|
||||||
<hr />
|
|
||||||
<form onSubmit={onCreateRoom}>
|
|
||||||
<h1>Create a call</h1>
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<InputField
|
|
||||||
id="userName"
|
|
||||||
name="userName"
|
|
||||||
label="Username"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Username"
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<InputField
|
|
||||||
id="roomName"
|
|
||||||
name="roomName"
|
|
||||||
label="Room Name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Room Name"
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
{createRoomError && (
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<ErrorMessage>{createRoomError.message}</ErrorMessage>
|
|
||||||
</FieldRow>
|
|
||||||
)}
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<Button
|
|
||||||
className={styles.button}
|
|
||||||
type="submit"
|
|
||||||
disabled={creatingRoom}
|
|
||||||
>
|
|
||||||
{creatingRoom ? "Creating call..." : "Create call"}
|
|
||||||
</Button>
|
|
||||||
</FieldRow>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className={styles.authLinks}>
|
|
||||||
<p>
|
|
||||||
Not registered yet?{" "}
|
|
||||||
<Link to="/register">Create an account</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RegisteredView({
|
|
||||||
client,
|
|
||||||
isPasswordlessUser,
|
|
||||||
isGuest,
|
|
||||||
onCreateRoom,
|
|
||||||
createRoomError,
|
|
||||||
creatingRoom,
|
|
||||||
onJoinRoom,
|
|
||||||
}) {
|
|
||||||
const publicRooms = usePublicRooms(
|
|
||||||
client,
|
|
||||||
import.meta.env.VITE_PUBLIC_SPACE_ROOM_ID
|
|
||||||
);
|
|
||||||
const recentRooms = useGroupCallRooms(client);
|
|
||||||
|
|
||||||
const hideCallList = publicRooms.length === 0 && recentRooms.length === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.home, {
|
|
||||||
[styles.fullWidth]: hideCallList,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Header className={styles.header}>
|
|
||||||
<LeftNav className={styles.leftNav}>
|
|
||||||
<HeaderLogo />
|
|
||||||
</LeftNav>
|
|
||||||
<RightNav>
|
|
||||||
<UserMenu />
|
|
||||||
</RightNav>
|
|
||||||
</Header>
|
|
||||||
<div className={styles.splitContainer}>
|
|
||||||
<div className={styles.left}>
|
|
||||||
<div className={styles.content}>
|
|
||||||
<div className={styles.centered}>
|
|
||||||
<form onSubmit={onJoinRoom}>
|
|
||||||
<h1>Join a call</h1>
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<InputField
|
|
||||||
id="roomId"
|
|
||||||
name="roomId"
|
|
||||||
label="Call ID"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Call ID"
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<Button className={styles.button} type="submit">
|
|
||||||
Join call
|
|
||||||
</Button>
|
|
||||||
</FieldRow>
|
|
||||||
</form>
|
|
||||||
<hr />
|
|
||||||
<form onSubmit={onCreateRoom}>
|
|
||||||
<h1>Create a call</h1>
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<InputField
|
|
||||||
id="roomName"
|
|
||||||
name="roomName"
|
|
||||||
label="Room Name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Room Name"
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
{createRoomError && (
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<ErrorMessage>{createRoomError.message}</ErrorMessage>
|
|
||||||
</FieldRow>
|
|
||||||
)}
|
|
||||||
<FieldRow className={styles.fieldRow}>
|
|
||||||
<Button
|
|
||||||
className={styles.button}
|
|
||||||
type="submit"
|
|
||||||
disabled={creatingRoom}
|
|
||||||
>
|
|
||||||
{creatingRoom ? "Creating call..." : "Create call"}
|
|
||||||
</Button>
|
|
||||||
</FieldRow>
|
|
||||||
</form>
|
|
||||||
{(isPasswordlessUser || isGuest) && (
|
|
||||||
<div className={styles.authLinks}>
|
|
||||||
<p>
|
|
||||||
Not registered yet?{" "}
|
|
||||||
<Link to="/register">Create an account</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!hideCallList && (
|
|
||||||
<div className={styles.right}>
|
|
||||||
<div className={styles.content}>
|
|
||||||
{publicRooms.length > 0 && (
|
|
||||||
<CallList
|
|
||||||
title="Public Calls"
|
|
||||||
rooms={publicRooms}
|
|
||||||
client={client}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{recentRooms.length > 0 && (
|
|
||||||
<CallList
|
|
||||||
title="Recent Calls"
|
|
||||||
rooms={recentRooms}
|
|
||||||
client={client}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
.home {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitContainer {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left,
|
|
||||||
.right {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullWidth {
|
|
||||||
background-color: var(--bgColor1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullWidth .header {
|
|
||||||
background-color: var(--bgColor1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.centered {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 512px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left .content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left .content form > * {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left .content form > :last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left .content hr:after {
|
|
||||||
background-color: var(--bgColor1);
|
|
||||||
content: "OR";
|
|
||||||
padding: 0 12px;
|
|
||||||
position: relative;
|
|
||||||
top: -12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left .content form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 40px 92px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldRow {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
height: 40px;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left .content form:first-child {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left .content form:last-child {
|
|
||||||
padding-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right .content {
|
|
||||||
padding: 0 40px 40px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right .content h3:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.authLinks {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.authLinks {
|
|
||||||
margin-bottom: 100px;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.authLinks a {
|
|
||||||
color: #0dbd8b;
|
|
||||||
font-weight: normal;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
|
||||||
.left {
|
|
||||||
background-color: var(--bgColor2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home:not(.fullWidth) .left {
|
|
||||||
max-width: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home:not(.fullWidth) .leftNav {
|
|
||||||
background-color: var(--bgColor2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.splitContainer {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullWidth .content hr:after,
|
|
||||||
.left .content hr:after,
|
|
||||||
.fullWidth .header {
|
|
||||||
background-color: var(--bgColor2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
48
src/IncompatibleVersionModal.tsx
Normal file
48
src/IncompatibleVersionModal.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 New Vector Ltd
|
||||||
|
|
||||||
|
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 { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { Modal, ModalContent } from "./Modal";
|
||||||
|
import { Body } from "./typography/Typography";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userIds: Set<string>;
|
||||||
|
room: Room;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncompatibleVersionModal: React.FC<Props> = ({
|
||||||
|
userIds,
|
||||||
|
room,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const userLis = Array.from(userIds).map((u) => (
|
||||||
|
<li>{room.getMember(u).name}</li>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Incompatible Versions" isDismissable {...rest}>
|
||||||
|
<ModalContent>
|
||||||
|
<Body>
|
||||||
|
Other users are trying to join this call from incompatible versions.
|
||||||
|
These users should ensure that they have refreshed their browsers:
|
||||||
|
<ul>{userLis}</ul>
|
||||||
|
</Body>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
6
src/IndexedDBWorker.ts
Normal file
6
src/IndexedDBWorker.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IndexedDBStoreWorker } from "matrix-js-sdk/src/indexeddb-worker";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const remoteWorker = new IndexedDBStoreWorker((self as any).postMessage);
|
||||||
|
|
||||||
|
self.onmessage = remoteWorker.onMessage;
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import React, { forwardRef } from "react";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import styles from "./Input.module.css";
|
|
||||||
import { ReactComponent as CheckIcon } from "./icons/Check.svg";
|
|
||||||
|
|
||||||
export function FieldRow({ children, rightAlign, className, ...rest }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
styles.fieldRow,
|
|
||||||
{ [styles.rightAlign]: rightAlign },
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Field({ children, className, ...rest }) {
|
|
||||||
return <div className={classNames(styles.field, className)}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InputField = forwardRef(
|
|
||||||
(
|
|
||||||
{ id, label, className, type, checked, prefix, suffix, disabled, ...rest },
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<Field
|
|
||||||
className={classNames(
|
|
||||||
type === "checkbox" ? styles.checkboxField : styles.inputField,
|
|
||||||
{
|
|
||||||
[styles.prefix]: !!prefix,
|
|
||||||
[styles.disabled]: disabled,
|
|
||||||
},
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{prefix && <span>{prefix}</span>}
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
{...rest}
|
|
||||||
ref={ref}
|
|
||||||
type={type}
|
|
||||||
checked={checked}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<label htmlFor={id}>
|
|
||||||
{type === "checkbox" && (
|
|
||||||
<div className={styles.checkbox}>
|
|
||||||
<CheckIcon />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{suffix && <span>{suffix}</span>}
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export function ErrorMessage({ children }) {
|
|
||||||
return <p className={styles.errorMessage}>{children}</p>;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Modal, ModalContent } from "./Modal";
|
|
||||||
import { CopyButton } from "./button";
|
|
||||||
import { getRoomUrl } from "./ConferenceCallManagerHooks";
|
|
||||||
import styles from "./InviteModal.module.css";
|
|
||||||
|
|
||||||
export function InviteModal({ roomId, ...rest }) {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title="Invite People"
|
|
||||||
isDismissable
|
|
||||||
className={styles.inviteModal}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<ModalContent>
|
|
||||||
<p>Copy and share this meeting link</p>
|
|
||||||
<CopyButton className={styles.copyButton} value={getRoomUrl(roomId)} />
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Modal, ModalContent } from "./Modal";
|
|
||||||
import { Button } from "./button";
|
|
||||||
import { FieldRow } from "./Input";
|
|
||||||
import styles from "./JoinExistingCallModal.module.css";
|
|
||||||
|
|
||||||
export function JoinExistingCallModal({ onJoin, ...rest }) {
|
|
||||||
return (
|
|
||||||
<Modal title="Join existing call?" isDismissable {...rest}>
|
|
||||||
<ModalContent>
|
|
||||||
<p>This call already exists, would you like to join?</p>
|
|
||||||
<FieldRow rightAlign className={styles.buttons}>
|
|
||||||
<Button onPress={rest.onClose}>No</Button>
|
|
||||||
<Button onPress={onJoin}>Yes, join call</Button>
|
|
||||||
</FieldRow>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
90
src/LazyEventEmitter.ts
Normal file
90
src/LazyEventEmitter.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 EventEmitter from "events";
|
||||||
|
|
||||||
|
type NonEmptyArray<T> = [T, ...T[]];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event emitter that lets events pile up in a backlog until a listener is
|
||||||
|
* present, at which point any events that were missed are re-emitted.
|
||||||
|
*/
|
||||||
|
export class LazyEventEmitter extends EventEmitter {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private eventBacklogs = new Map<string | symbol, NonEmptyArray<any[]>>();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
public emit(type: string | symbol, ...args: any[]): boolean {
|
||||||
|
const hasListeners = super.emit(type, ...args);
|
||||||
|
|
||||||
|
if (!hasListeners) {
|
||||||
|
// The event was missed, so add it to the backlog
|
||||||
|
const backlog = this.eventBacklogs.get(type);
|
||||||
|
if (backlog) {
|
||||||
|
backlog.push(args);
|
||||||
|
} else {
|
||||||
|
// Start a new backlog
|
||||||
|
this.eventBacklogs.set(type, [args]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasListeners;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
public on(type: string | symbol, listener: (...args: any[]) => void): this {
|
||||||
|
super.on(type, listener);
|
||||||
|
|
||||||
|
const backlog = this.eventBacklogs.get(type);
|
||||||
|
if (backlog) {
|
||||||
|
// That was the first listener for this type, so let's send it all the
|
||||||
|
// events that have piled up
|
||||||
|
for (const args of backlog) super.emit(type, ...args);
|
||||||
|
// Backlog is now clear
|
||||||
|
this.eventBacklogs.delete(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addListener(
|
||||||
|
type: string | symbol,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
listener: (...args: any[]) => void
|
||||||
|
): this {
|
||||||
|
return this.on(type, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
public once(type: string | symbol, listener: (...args: any[]) => void): this {
|
||||||
|
super.once(type, listener);
|
||||||
|
|
||||||
|
const backlog = this.eventBacklogs.get(type);
|
||||||
|
if (backlog) {
|
||||||
|
// That was the first listener for this type, so let's send it the first
|
||||||
|
// of the events that have piled up
|
||||||
|
super.emit(type, ...backlog[0]);
|
||||||
|
// Clear the event from the backlog
|
||||||
|
if (backlog.length === 1) {
|
||||||
|
this.eventBacklogs.delete(type);
|
||||||
|
} else {
|
||||||
|
backlog.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import React, { useRef } from "react";
|
|
||||||
import { useListBox, useOption } from "@react-aria/listbox";
|
|
||||||
import styles from "./ListBox.module.css";
|
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
export function ListBox(props) {
|
|
||||||
const ref = useRef();
|
|
||||||
let { listBoxRef = ref, state } = props;
|
|
||||||
const { listBoxProps } = useListBox(props, state, listBoxRef);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul
|
|
||||||
{...listBoxProps}
|
|
||||||
ref={listBoxRef}
|
|
||||||
className={classNames(styles.listBox, props.className)}
|
|
||||||
>
|
|
||||||
{[...state.collection].map((item) => (
|
|
||||||
<Option
|
|
||||||
key={item.key}
|
|
||||||
item={item}
|
|
||||||
state={state}
|
|
||||||
className={props.optionClassName}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Option({ item, state, className }) {
|
|
||||||
const ref = useRef();
|
|
||||||
const { optionProps, isSelected, isFocused, isDisabled } = useOption(
|
|
||||||
{ key: item.key },
|
|
||||||
state,
|
|
||||||
ref
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
{...optionProps}
|
|
||||||
ref={ref}
|
|
||||||
className={classNames(styles.option, className, {
|
|
||||||
[styles.selected]: isSelected,
|
|
||||||
[styles.focused]: isFocused,
|
|
||||||
[styles.disables]: isDisabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{item.rendered}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid var(--inputBorderColor);
|
border: 1px solid var(--quinary-content);
|
||||||
background-color: var(--bgColor1);
|
background-color: var(--background);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--textColor1);
|
color: var(--primary-content);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -23,15 +23,11 @@
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option.selected {
|
|
||||||
color: #0dbd8b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option.focused {
|
.option.focused {
|
||||||
background-color: rgba(111, 120, 130, 0.2);
|
background-color: rgba(111, 120, 130, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.option.disabled {
|
.option.disabled {
|
||||||
color: var(--textColor2);
|
color: var(--quaternary-content);
|
||||||
background-color: var(--bgColor3);
|
background-color: var(--bgColor3);
|
||||||
}
|
}
|
||||||
|
|||||||
89
src/ListBox.tsx
Normal file
89
src/ListBox.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 New Vector Ltd
|
||||||
|
|
||||||
|
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 React, { useRef } from "react";
|
||||||
|
import { useListBox, useOption, AriaListBoxOptions } from "@react-aria/listbox";
|
||||||
|
import { ListState } from "@react-stately/list";
|
||||||
|
import { Node } from "@react-types/shared";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import styles from "./ListBox.module.css";
|
||||||
|
|
||||||
|
interface ListBoxProps<T> extends AriaListBoxOptions<T> {
|
||||||
|
optionClassName: string;
|
||||||
|
state: ListState<T>;
|
||||||
|
className?: string;
|
||||||
|
listBoxRef?: React.MutableRefObject<HTMLUListElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListBox<T>({
|
||||||
|
state,
|
||||||
|
optionClassName,
|
||||||
|
className,
|
||||||
|
listBoxRef,
|
||||||
|
...rest
|
||||||
|
}: ListBoxProps<T>) {
|
||||||
|
const ref = useRef<HTMLUListElement>();
|
||||||
|
if (!listBoxRef) listBoxRef = ref;
|
||||||
|
|
||||||
|
const { listBoxProps } = useListBox(rest, state, listBoxRef);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
{...listBoxProps}
|
||||||
|
ref={listBoxRef}
|
||||||
|
className={classNames(styles.listBox, className)}
|
||||||
|
>
|
||||||
|
{[...state.collection].map((item) => (
|
||||||
|
<Option
|
||||||
|
key={item.key}
|
||||||
|
item={item}
|
||||||
|
state={state}
|
||||||
|
className={optionClassName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionProps<T> {
|
||||||
|
className: string;
|
||||||
|
state: ListState<T>;
|
||||||
|
item: Node<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Option<T>({ item, state, className }: OptionProps<T>) {
|
||||||
|
const ref = useRef();
|
||||||
|
const { optionProps, isSelected, isFocused, isDisabled } = useOption(
|
||||||
|
{ key: item.key },
|
||||||
|
state,
|
||||||
|
ref
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
{...optionProps}
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(styles.option, className, {
|
||||||
|
[styles.selected]: isSelected,
|
||||||
|
[styles.focused]: isFocused,
|
||||||
|
[styles.disables]: isDisabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{item.rendered}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
color: var(--textColor1);
|
color: var(--primary-content);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuItem > * {
|
.menuItem > * {
|
||||||
margin-right: 10px;
|
margin: 0 10px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuItem > :last-child {
|
.menuItem > :last-child {
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
.menuItem.focused,
|
.menuItem.focused,
|
||||||
.menuItem:hover {
|
.menuItem:hover {
|
||||||
background-color: var(--bgColor4);
|
background-color: var(--quinary-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuItem.focused:first-child,
|
.menuItem.focused:first-child,
|
||||||
@@ -39,3 +39,12 @@
|
|||||||
border-bottom-left-radius: 8px;
|
border-bottom-left-radius: 8px;
|
||||||
border-bottom-right-radius: 8px;
|
border-bottom-right-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkIcon {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkIcon * {
|
||||||
|
stroke: var(--primary-content);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import React, { useRef, useState } from "react";
|
import React, { Key, useRef, useState } from "react";
|
||||||
import styles from "./Menu.module.css";
|
import { AriaMenuOptions, useMenu, useMenuItem } from "@react-aria/menu";
|
||||||
import { useMenu, useMenuItem } from "@react-aria/menu";
|
import { TreeState, useTreeState } from "@react-stately/tree";
|
||||||
import { useTreeState } from "@react-stately/tree";
|
|
||||||
import { mergeProps } from "@react-aria/utils";
|
import { mergeProps } from "@react-aria/utils";
|
||||||
import { useFocus } from "@react-aria/interactions";
|
import { useFocus } from "@react-aria/interactions";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { Node } from "@react-types/shared";
|
||||||
|
|
||||||
export function Menu({ className, onAction, ...rest }) {
|
import styles from "./Menu.module.css";
|
||||||
const state = useTreeState({ ...rest, selectionMode: "none" });
|
|
||||||
|
interface MenuProps<T> extends AriaMenuOptions<T> {
|
||||||
|
className?: String;
|
||||||
|
onClose?: () => void;
|
||||||
|
onAction: (value: Key) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Menu<T extends object>({
|
||||||
|
className,
|
||||||
|
onAction,
|
||||||
|
onClose,
|
||||||
|
label,
|
||||||
|
...rest
|
||||||
|
}: MenuProps<T>) {
|
||||||
|
const state = useTreeState<T>({ ...rest, selectionMode: "none" });
|
||||||
const menuRef = useRef();
|
const menuRef = useRef();
|
||||||
const { menuProps } = useMenu(rest, state, menuRef);
|
const { menuProps } = useMenu<T>(rest, state, menuRef);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
@@ -23,19 +38,25 @@ export function Menu({ className, onAction, ...rest }) {
|
|||||||
item={item}
|
item={item}
|
||||||
state={state}
|
state={state}
|
||||||
onAction={onAction}
|
onAction={onAction}
|
||||||
onClose={rest.onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuItem({ item, state, onAction, onClose }) {
|
interface MenuItemProps<T> {
|
||||||
|
item: Node<T>;
|
||||||
|
state: TreeState<T>;
|
||||||
|
onAction: (value: Key) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem<T>({ item, state, onAction, onClose }: MenuItemProps<T>) {
|
||||||
const ref = useRef();
|
const ref = useRef();
|
||||||
const { menuItemProps } = useMenuItem(
|
const { menuItemProps } = useMenuItem(
|
||||||
{
|
{
|
||||||
key: item.key,
|
key: item.key,
|
||||||
isDisabled: item.isDisabled,
|
|
||||||
onAction,
|
onAction,
|
||||||
onClose,
|
onClose,
|
||||||
},
|
},
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modalHeader h3 {
|
.modalHeader h3 {
|
||||||
|
font-weight: 600;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 799px) {
|
@media (max-width: 799px) {
|
||||||
|
.modalHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 24px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal.mobileFullScreen {
|
.modal.mobileFullScreen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -1,29 +1,73 @@
|
|||||||
import React, { useRef, useMemo } from "react";
|
/*
|
||||||
|
Copyright 2022 New Vector Ltd
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable jsx-a11y/no-autofocus */
|
||||||
|
|
||||||
|
import React, { useRef, useMemo, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
useOverlay,
|
useOverlay,
|
||||||
usePreventScroll,
|
usePreventScroll,
|
||||||
useModal,
|
useModal,
|
||||||
OverlayContainer,
|
OverlayContainer,
|
||||||
|
OverlayProps,
|
||||||
} from "@react-aria/overlays";
|
} from "@react-aria/overlays";
|
||||||
import { useOverlayTriggerState } from "@react-stately/overlays";
|
import {
|
||||||
|
OverlayTriggerState,
|
||||||
|
useOverlayTriggerState,
|
||||||
|
} from "@react-stately/overlays";
|
||||||
import { useDialog } from "@react-aria/dialog";
|
import { useDialog } from "@react-aria/dialog";
|
||||||
import { FocusScope } from "@react-aria/focus";
|
import { FocusScope } from "@react-aria/focus";
|
||||||
import { useButton } from "@react-aria/button";
|
import { ButtonAria, useButton } from "@react-aria/button";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { AriaDialogProps } from "@react-types/dialog";
|
||||||
|
|
||||||
import { ReactComponent as CloseIcon } from "./icons/Close.svg";
|
import { ReactComponent as CloseIcon } from "./icons/Close.svg";
|
||||||
import styles from "./Modal.module.css";
|
import styles from "./Modal.module.css";
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
export function Modal(props) {
|
export interface ModalProps extends OverlayProps, AriaDialogProps {
|
||||||
const { title, children, className, mobileFullScreen } = props;
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
mobileFullScreen?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
mobileFullScreen,
|
||||||
|
onClose,
|
||||||
|
...rest
|
||||||
|
}: ModalProps) {
|
||||||
const modalRef = useRef();
|
const modalRef = useRef();
|
||||||
const { overlayProps, underlayProps } = useOverlay(props, modalRef);
|
const { overlayProps, underlayProps } = useOverlay(
|
||||||
|
{ ...rest, onClose },
|
||||||
|
modalRef
|
||||||
|
);
|
||||||
usePreventScroll();
|
usePreventScroll();
|
||||||
const { modalProps } = useModal();
|
const { modalProps } = useModal();
|
||||||
const { dialogProps, titleProps } = useDialog(props, modalRef);
|
const { dialogProps, titleProps } = useDialog(rest, modalRef);
|
||||||
const closeButtonRef = useRef();
|
const closeButtonRef = useRef();
|
||||||
const { buttonProps: closeButtonProps } = useButton({
|
const { buttonProps: closeButtonProps } = useButton(
|
||||||
onPress: () => props.onClose(),
|
{
|
||||||
});
|
onPress: () => onClose(),
|
||||||
|
},
|
||||||
|
closeButtonRef
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OverlayContainer>
|
<OverlayContainer>
|
||||||
@@ -58,7 +102,16 @@ export function Modal(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModalContent({ children, className, ...rest }) {
|
interface ModalContentProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalContent({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: ModalContentProps) {
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.content, className)} {...rest}>
|
<div className={classNames(styles.content, className)} {...rest}>
|
||||||
{children}
|
{children}
|
||||||
@@ -66,7 +119,10 @@ export function ModalContent({ children, className, ...rest }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useModalTriggerState() {
|
export function useModalTriggerState(): {
|
||||||
|
modalState: OverlayTriggerState;
|
||||||
|
modalProps: { isOpen: boolean; onClose: () => void };
|
||||||
|
} {
|
||||||
const modalState = useOverlayTriggerState({});
|
const modalState = useOverlayTriggerState({});
|
||||||
const modalProps = useMemo(
|
const modalProps = useMemo(
|
||||||
() => ({ isOpen: modalState.isOpen, onClose: modalState.close }),
|
() => ({ isOpen: modalState.isOpen, onClose: modalState.close }),
|
||||||
@@ -75,7 +131,10 @@ export function useModalTriggerState() {
|
|||||||
return { modalState, modalProps };
|
return { modalState, modalProps };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useToggleModalButton(modalState, ref) {
|
export function useToggleModalButton(
|
||||||
|
modalState: OverlayTriggerState,
|
||||||
|
ref: React.RefObject<HTMLButtonElement>
|
||||||
|
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
||||||
return useButton(
|
return useButton(
|
||||||
{
|
{
|
||||||
onPress: () => modalState.toggle(),
|
onPress: () => modalState.toggle(),
|
||||||
@@ -84,7 +143,10 @@ export function useToggleModalButton(modalState, ref) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOpenModalButton(modalState, ref) {
|
export function useOpenModalButton(
|
||||||
|
modalState: OverlayTriggerState,
|
||||||
|
ref: React.RefObject<HTMLButtonElement>
|
||||||
|
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
||||||
return useButton(
|
return useButton(
|
||||||
{
|
{
|
||||||
onPress: () => modalState.open(),
|
onPress: () => modalState.open(),
|
||||||
@@ -93,7 +155,10 @@ export function useOpenModalButton(modalState, ref) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCloseModalButton(modalState, ref) {
|
export function useCloseModalButton(
|
||||||
|
modalState: OverlayTriggerState,
|
||||||
|
ref: React.RefObject<HTMLButtonElement>
|
||||||
|
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
||||||
return useButton(
|
return useButton(
|
||||||
{
|
{
|
||||||
onPress: () => modalState.close(),
|
onPress: () => modalState.close(),
|
||||||
@@ -102,8 +167,12 @@ export function useCloseModalButton(modalState, ref) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModalTrigger({ children }) {
|
interface ModalTriggerProps {
|
||||||
const { modalState, modalProps } = useModalState();
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalTrigger({ children }: ModalTriggerProps) {
|
||||||
|
const { modalState, modalProps } = useModalTriggerState();
|
||||||
const buttonRef = useRef();
|
const buttonRef = useRef();
|
||||||
const { buttonProps } = useToggleModalButton(modalState, buttonRef);
|
const { buttonProps } = useToggleModalButton(modalState, buttonRef);
|
||||||
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { Button } from "./button";
|
|
||||||
import { Menu } from "./Menu";
|
|
||||||
import { PopoverMenuTrigger } from "./PopoverMenu";
|
|
||||||
import { Item } from "@react-stately/collections";
|
|
||||||
import { ReactComponent as SettingsIcon } from "./icons/Settings.svg";
|
|
||||||
import { ReactComponent as AddUserIcon } from "./icons/AddUser.svg";
|
|
||||||
import { ReactComponent as OverflowIcon } from "./icons/Overflow.svg";
|
|
||||||
import { useModalTriggerState } from "./Modal";
|
|
||||||
import { SettingsModal } from "./SettingsModal";
|
|
||||||
import { InviteModal } from "./InviteModal";
|
|
||||||
import { Tooltip, TooltipTrigger } from "./Tooltip";
|
|
||||||
|
|
||||||
export function OverflowMenu({
|
|
||||||
roomId,
|
|
||||||
setShowInspector,
|
|
||||||
showInspector,
|
|
||||||
client,
|
|
||||||
}) {
|
|
||||||
const { modalState: inviteModalState, modalProps: inviteModalProps } =
|
|
||||||
useModalTriggerState();
|
|
||||||
const { modalState: settingsModalState, modalProps: settingsModalProps } =
|
|
||||||
useModalTriggerState();
|
|
||||||
|
|
||||||
// TODO: On closing modal, focus should be restored to the trigger button
|
|
||||||
// https://github.com/adobe/react-spectrum/issues/2444
|
|
||||||
const onAction = useCallback((key) => {
|
|
||||||
switch (key) {
|
|
||||||
case "invite":
|
|
||||||
inviteModalState.open();
|
|
||||||
break;
|
|
||||||
case "settings":
|
|
||||||
settingsModalState.open();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PopoverMenuTrigger disableOnState>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button variant="toolbar">
|
|
||||||
<OverflowIcon />
|
|
||||||
</Button>
|
|
||||||
{(props) => (
|
|
||||||
<Tooltip position="top" {...props}>
|
|
||||||
More
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
{(props) => (
|
|
||||||
<Menu {...props} label="More menu" onAction={onAction}>
|
|
||||||
<Item key="invite" textValue="Invite people">
|
|
||||||
<AddUserIcon />
|
|
||||||
<span>Invite people</span>
|
|
||||||
</Item>
|
|
||||||
<Item key="settings" textValue="Settings">
|
|
||||||
<SettingsIcon />
|
|
||||||
<span>Settings</span>
|
|
||||||
</Item>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</PopoverMenuTrigger>
|
|
||||||
{settingsModalState.isOpen && (
|
|
||||||
<SettingsModal
|
|
||||||
{...settingsModalProps}
|
|
||||||
setShowInspector={setShowInspector}
|
|
||||||
showInspector={showInspector}
|
|
||||||
client={client}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{inviteModalState.isOpen && (
|
|
||||||
<InviteModal roomId={roomId} {...inviteModalProps} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import React, { forwardRef, useRef } from "react";
|
|
||||||
import { DismissButton, useOverlay } from "@react-aria/overlays";
|
|
||||||
import { FocusScope } from "@react-aria/focus";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import styles from "./Popover.module.css";
|
|
||||||
|
|
||||||
export const Popover = forwardRef(
|
|
||||||
({ isOpen = true, onClose, className, children, ...rest }, ref) => {
|
|
||||||
const fallbackRef = useRef();
|
|
||||||
const popoverRef = ref || fallbackRef;
|
|
||||||
|
|
||||||
const { overlayProps } = useOverlay(
|
|
||||||
{
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
shouldCloseOnBlur: true,
|
|
||||||
isDismissable: true,
|
|
||||||
},
|
|
||||||
popoverRef
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FocusScope restoreFocus>
|
|
||||||
<div
|
|
||||||
{...overlayProps}
|
|
||||||
{...rest}
|
|
||||||
className={classNames(styles.popover, className)}
|
|
||||||
ref={popoverRef}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<DismissButton onDismiss={onClose} />
|
|
||||||
</div>
|
|
||||||
</FocusScope>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
|
||||||
import { Button } from "./button";
|
|
||||||
import { useProfile } from "./ConferenceCallManagerHooks";
|
|
||||||
import { FieldRow, InputField, ErrorMessage } from "./Input";
|
|
||||||
import { Modal, ModalContent } from "./Modal";
|
|
||||||
|
|
||||||
export function ProfileModal({
|
|
||||||
client,
|
|
||||||
isAuthenticated,
|
|
||||||
isPasswordlessUser,
|
|
||||||
isGuest,
|
|
||||||
...rest
|
|
||||||
}) {
|
|
||||||
const { onClose } = rest;
|
|
||||||
const {
|
|
||||||
success,
|
|
||||||
error,
|
|
||||||
loading,
|
|
||||||
displayName: initialDisplayName,
|
|
||||||
saveProfile,
|
|
||||||
} = useProfile(client);
|
|
||||||
const [displayName, setDisplayName] = useState(initialDisplayName || "");
|
|
||||||
|
|
||||||
const onChangeDisplayName = useCallback(
|
|
||||||
(e) => {
|
|
||||||
setDisplayName(e.target.value);
|
|
||||||
},
|
|
||||||
[setDisplayName]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
|
||||||
(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = new FormData(e.target);
|
|
||||||
const displayName = data.get("displayName");
|
|
||||||
const avatar = data.get("avatar");
|
|
||||||
|
|
||||||
saveProfile({
|
|
||||||
displayName,
|
|
||||||
avatar,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[saveProfile]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (success) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [success, onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal title="Profile" isDismissable {...rest}>
|
|
||||||
<ModalContent>
|
|
||||||
<form onSubmit={onSubmit}>
|
|
||||||
<FieldRow>
|
|
||||||
<InputField
|
|
||||||
id="displayName"
|
|
||||||
name="displayName"
|
|
||||||
label="Display Name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="Display Name"
|
|
||||||
value={displayName}
|
|
||||||
onChange={onChangeDisplayName}
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
{isAuthenticated && !isGuest && !isPasswordlessUser && (
|
|
||||||
<FieldRow>
|
|
||||||
<InputField
|
|
||||||
type="file"
|
|
||||||
id="avatar"
|
|
||||||
name="avatar"
|
|
||||||
label="Avatar"
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<FieldRow>
|
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
|
||||||
</FieldRow>
|
|
||||||
)}
|
|
||||||
<FieldRow rightAlign>
|
|
||||||
<Button type="button" variant="secondary" onPress={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={loading}>
|
|
||||||
{loading ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</FieldRow>
|
|
||||||
</form>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2021 New Vector Ltd
|
|
||||||
|
|
||||||
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 React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useHistory, useLocation, Link } from "react-router-dom";
|
|
||||||
import { FieldRow, InputField, ErrorMessage } from "./Input";
|
|
||||||
import { Button } from "./button";
|
|
||||||
import { useClient, defaultHomeserverHost } from "./ConferenceCallManagerHooks";
|
|
||||||
import styles from "./LoginPage.module.css";
|
|
||||||
import { ReactComponent as Logo } from "./icons/LogoLarge.svg";
|
|
||||||
import { LoadingView } from "./FullScreenView";
|
|
||||||
|
|
||||||
export function RegisterPage() {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
client,
|
|
||||||
register,
|
|
||||||
changePassword,
|
|
||||||
isAuthenticated,
|
|
||||||
isPasswordlessUser,
|
|
||||||
} = useClient();
|
|
||||||
const confirmPasswordRef = useRef();
|
|
||||||
const history = useHistory();
|
|
||||||
const location = useLocation();
|
|
||||||
const [registering, setRegistering] = useState(false);
|
|
||||||
const [error, setError] = useState();
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
|
||||||
|
|
||||||
const onSubmitRegisterForm = useCallback(
|
|
||||||
(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = new FormData(e.target);
|
|
||||||
const userName = data.get("userName");
|
|
||||||
const password = data.get("password");
|
|
||||||
const passwordConfirmation = data.get("passwordConfirmation");
|
|
||||||
|
|
||||||
if (password !== passwordConfirmation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRegistering(true);
|
|
||||||
|
|
||||||
if (isPasswordlessUser) {
|
|
||||||
changePassword(password)
|
|
||||||
.then(() => {
|
|
||||||
if (location.state && location.state.from) {
|
|
||||||
history.push(location.state.from);
|
|
||||||
} else {
|
|
||||||
history.push("/");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
setError(error);
|
|
||||||
setRegistering(false);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
register(userName, password)
|
|
||||||
.then(() => {
|
|
||||||
if (location.state && location.state.from) {
|
|
||||||
history.push(location.state.from);
|
|
||||||
} else {
|
|
||||||
history.push("/");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
setError(error);
|
|
||||||
setRegistering(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[register, changePassword, location, history, isPasswordlessUser]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!confirmPasswordRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password && passwordConfirmation && password !== passwordConfirmation) {
|
|
||||||
confirmPasswordRef.current.setCustomValidity("Passwords must match");
|
|
||||||
} else {
|
|
||||||
confirmPasswordRef.current.setCustomValidity("");
|
|
||||||
}
|
|
||||||
}, [password, passwordConfirmation]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading && isAuthenticated && !isPasswordlessUser) {
|
|
||||||
history.push("/");
|
|
||||||
}
|
|
||||||
}, [history, isAuthenticated, isPasswordlessUser]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <LoadingView />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.content}>
|
|
||||||
<div className={styles.formContainer}>
|
|
||||||
<Logo width="auto" height="auto" className={styles.logo} />
|
|
||||||
<h2>Create your account</h2>
|
|
||||||
<form onSubmit={onSubmitRegisterForm}>
|
|
||||||
<FieldRow>
|
|
||||||
<InputField
|
|
||||||
type="text"
|
|
||||||
name="userName"
|
|
||||||
placeholder="Username"
|
|
||||||
label="Username"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="none"
|
|
||||||
prefix="@"
|
|
||||||
suffix={`:${defaultHomeserverHost}`}
|
|
||||||
value={
|
|
||||||
isAuthenticated && isPasswordlessUser
|
|
||||||
? client.getUserIdLocalpart()
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
disabled={isAuthenticated && isPasswordlessUser}
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
<FieldRow>
|
|
||||||
<InputField
|
|
||||||
required
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
value={password}
|
|
||||||
placeholder="Password"
|
|
||||||
label="Password"
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
<FieldRow>
|
|
||||||
<InputField
|
|
||||||
required
|
|
||||||
type="password"
|
|
||||||
name="passwordConfirmation"
|
|
||||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
|
||||||
value={passwordConfirmation}
|
|
||||||
placeholder="Confirm Password"
|
|
||||||
label="Confirm Password"
|
|
||||||
ref={confirmPasswordRef}
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
{error && (
|
|
||||||
<FieldRow>
|
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
|
||||||
</FieldRow>
|
|
||||||
)}
|
|
||||||
<FieldRow>
|
|
||||||
<Button type="submit" disabled={registering}>
|
|
||||||
{registering ? "Registering..." : "Register"}
|
|
||||||
</Button>
|
|
||||||
</FieldRow>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div className={styles.authLinks}>
|
|
||||||
<p>Already have an account?</p>
|
|
||||||
<p>
|
|
||||||
<Link to="/login">Log in</Link>
|
|
||||||
{" Or "}
|
|
||||||
<Link to="/">Access as a guest</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
574
src/Room.jsx
574
src/Room.jsx
@@ -1,574 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2021 New Vector Ltd
|
|
||||||
|
|
||||||
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 React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import styles from "./Room.module.css";
|
|
||||||
import { useLocation, useParams, useHistory, Link } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
CopyButton,
|
|
||||||
HangupButton,
|
|
||||||
MicButton,
|
|
||||||
VideoButton,
|
|
||||||
ScreenshareButton,
|
|
||||||
LinkButton,
|
|
||||||
} from "./button";
|
|
||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "./Header";
|
|
||||||
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
|
||||||
import VideoGrid, {
|
|
||||||
useVideoGridLayout,
|
|
||||||
} from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoGrid";
|
|
||||||
import SimpleVideoGrid from "matrix-react-sdk/src/components/views/voip/GroupCallView/SimpleVideoGrid";
|
|
||||||
import "matrix-react-sdk/res/css/views/voip/GroupCallView/_VideoGrid.scss";
|
|
||||||
import { useGroupCall } from "matrix-react-sdk/src/hooks/useGroupCall";
|
|
||||||
import { useCallFeed } from "matrix-react-sdk/src/hooks/useCallFeed";
|
|
||||||
import { useMediaStream } from "matrix-react-sdk/src/hooks/useMediaStream";
|
|
||||||
import {
|
|
||||||
getAvatarUrl,
|
|
||||||
getRoomUrl,
|
|
||||||
useClient,
|
|
||||||
useLoadGroupCall,
|
|
||||||
useProfile,
|
|
||||||
} from "./ConferenceCallManagerHooks";
|
|
||||||
import { ErrorView, LoadingView, FullScreenView } from "./FullScreenView";
|
|
||||||
import { GroupCallInspector } from "./GroupCallInspector";
|
|
||||||
import * as Sentry from "@sentry/react";
|
|
||||||
import { OverflowMenu } from "./OverflowMenu";
|
|
||||||
import { GridLayoutMenu } from "./GridLayoutMenu";
|
|
||||||
import { UserMenu } from "./UserMenu";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { Avatar } from "./Avatar";
|
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
|
|
||||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
|
||||||
// or with getUsermedia and getDisplaymedia being used within the same session.
|
|
||||||
// For now we can disable screensharing in Safari.
|
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
|
||||||
|
|
||||||
export function Room() {
|
|
||||||
const [registeringGuest, setRegisteringGuest] = useState(false);
|
|
||||||
const [registrationError, setRegistrationError] = useState();
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
isAuthenticated,
|
|
||||||
error,
|
|
||||||
client,
|
|
||||||
registerGuest,
|
|
||||||
isGuest,
|
|
||||||
isPasswordlessUser,
|
|
||||||
} = useClient();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading && !isAuthenticated) {
|
|
||||||
setRegisteringGuest(true);
|
|
||||||
|
|
||||||
registerGuest()
|
|
||||||
.then(() => {
|
|
||||||
setRegisteringGuest(false);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
setRegistrationError(error);
|
|
||||||
setRegisteringGuest(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [loading, isAuthenticated]);
|
|
||||||
|
|
||||||
if (loading || registeringGuest) {
|
|
||||||
return <LoadingView />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (registrationError || error) {
|
|
||||||
return <ErrorView error={registrationError || error} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GroupCall
|
|
||||||
client={client}
|
|
||||||
isGuest={isGuest}
|
|
||||||
isPasswordlessUser={isPasswordlessUser}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GroupCall({ client, isGuest, isPasswordlessUser }) {
|
|
||||||
const { roomId: maybeRoomId } = useParams();
|
|
||||||
const { hash, search } = useLocation();
|
|
||||||
const [simpleGrid, viaServers] = useMemo(() => {
|
|
||||||
const params = new URLSearchParams(search);
|
|
||||||
return [params.has("simple"), params.getAll("via")];
|
|
||||||
}, [search]);
|
|
||||||
const roomId = maybeRoomId || hash;
|
|
||||||
const { loading, error, groupCall } = useLoadGroupCall(
|
|
||||||
client,
|
|
||||||
roomId,
|
|
||||||
viaServers
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.groupCall = groupCall;
|
|
||||||
}, [groupCall]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <LoadingRoomView />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <ErrorView error={error} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GroupCallView
|
|
||||||
isGuest={isGuest}
|
|
||||||
isPasswordlessUser={isPasswordlessUser}
|
|
||||||
client={client}
|
|
||||||
roomId={roomId}
|
|
||||||
groupCall={groupCall}
|
|
||||||
simpleGrid={simpleGrid}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GroupCallView({
|
|
||||||
client,
|
|
||||||
isGuest,
|
|
||||||
isPasswordlessUser,
|
|
||||||
roomId,
|
|
||||||
groupCall,
|
|
||||||
simpleGrid,
|
|
||||||
}) {
|
|
||||||
const [showInspector, setShowInspector] = useState(false);
|
|
||||||
const {
|
|
||||||
state,
|
|
||||||
error,
|
|
||||||
activeSpeaker,
|
|
||||||
userMediaFeeds,
|
|
||||||
microphoneMuted,
|
|
||||||
localVideoMuted,
|
|
||||||
localCallFeed,
|
|
||||||
initLocalCallFeed,
|
|
||||||
enter,
|
|
||||||
leave,
|
|
||||||
toggleLocalVideoMuted,
|
|
||||||
toggleMicrophoneMuted,
|
|
||||||
toggleScreensharing,
|
|
||||||
isScreensharing,
|
|
||||||
localScreenshareFeed,
|
|
||||||
screenshareFeeds,
|
|
||||||
hasLocalParticipant,
|
|
||||||
} = useGroupCall(groupCall);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onHangup(call) {
|
|
||||||
if (call.hangupReason === "ice_failed") {
|
|
||||||
Sentry.captureException(new Error("Call hangup due to ICE failure."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onError(error) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groupCall) {
|
|
||||||
groupCall.on("hangup", onHangup);
|
|
||||||
groupCall.on("error", onError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (groupCall) {
|
|
||||||
groupCall.removeListener("hangup", onHangup);
|
|
||||||
groupCall.removeListener("error", onError);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [groupCall]);
|
|
||||||
|
|
||||||
const [left, setLeft] = useState(false);
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const onLeave = useCallback(() => {
|
|
||||||
leave();
|
|
||||||
|
|
||||||
if (!isGuest && !isPasswordlessUser) {
|
|
||||||
history.push("/");
|
|
||||||
} else {
|
|
||||||
setLeft(true);
|
|
||||||
}
|
|
||||||
}, [leave, history, isGuest]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <ErrorView error={error} />;
|
|
||||||
} else if (state === GroupCallState.Entered) {
|
|
||||||
return (
|
|
||||||
<InRoomView
|
|
||||||
groupCall={groupCall}
|
|
||||||
client={client}
|
|
||||||
isGuest={isGuest}
|
|
||||||
roomName={groupCall.room.name}
|
|
||||||
microphoneMuted={microphoneMuted}
|
|
||||||
localVideoMuted={localVideoMuted}
|
|
||||||
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
|
||||||
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
|
||||||
userMediaFeeds={userMediaFeeds}
|
|
||||||
activeSpeaker={activeSpeaker}
|
|
||||||
onLeave={onLeave}
|
|
||||||
toggleScreensharing={toggleScreensharing}
|
|
||||||
isScreensharing={isScreensharing}
|
|
||||||
localScreenshareFeed={localScreenshareFeed}
|
|
||||||
screenshareFeeds={screenshareFeeds}
|
|
||||||
simpleGrid={simpleGrid}
|
|
||||||
setShowInspector={setShowInspector}
|
|
||||||
showInspector={showInspector}
|
|
||||||
roomId={roomId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (state === GroupCallState.Entering) {
|
|
||||||
return <EnteringRoomView />;
|
|
||||||
} else if (left) {
|
|
||||||
if (isPasswordlessUser) {
|
|
||||||
return <PasswordlessUserCallEndedScreen client={client} />;
|
|
||||||
} else {
|
|
||||||
return <GuestCallEndedScreen />;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<RoomSetupView
|
|
||||||
isGuest={isGuest}
|
|
||||||
client={client}
|
|
||||||
hasLocalParticipant={hasLocalParticipant}
|
|
||||||
roomName={groupCall.room.name}
|
|
||||||
state={state}
|
|
||||||
onInitLocalCallFeed={initLocalCallFeed}
|
|
||||||
localCallFeed={localCallFeed}
|
|
||||||
onEnter={enter}
|
|
||||||
microphoneMuted={microphoneMuted}
|
|
||||||
localVideoMuted={localVideoMuted}
|
|
||||||
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
|
||||||
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
|
||||||
setShowInspector={setShowInspector}
|
|
||||||
showInspector={showInspector}
|
|
||||||
roomId={roomId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadingRoomView() {
|
|
||||||
return (
|
|
||||||
<FullScreenView>
|
|
||||||
<h1>Loading room...</h1>
|
|
||||||
</FullScreenView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnteringRoomView() {
|
|
||||||
return (
|
|
||||||
<FullScreenView>
|
|
||||||
<h1>Entering room...</h1>
|
|
||||||
</FullScreenView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoomSetupView({
|
|
||||||
client,
|
|
||||||
roomName,
|
|
||||||
state,
|
|
||||||
onInitLocalCallFeed,
|
|
||||||
onEnter,
|
|
||||||
localCallFeed,
|
|
||||||
microphoneMuted,
|
|
||||||
localVideoMuted,
|
|
||||||
toggleLocalVideoMuted,
|
|
||||||
toggleMicrophoneMuted,
|
|
||||||
setShowInspector,
|
|
||||||
showInspector,
|
|
||||||
roomId,
|
|
||||||
}) {
|
|
||||||
const { stream } = useCallFeed(localCallFeed);
|
|
||||||
const videoRef = useMediaStream(stream, true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onInitLocalCallFeed();
|
|
||||||
}, [onInitLocalCallFeed]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.room}>
|
|
||||||
<Header>
|
|
||||||
<LeftNav>
|
|
||||||
<RoomHeaderInfo roomName={roomName} />
|
|
||||||
</LeftNav>
|
|
||||||
<RightNav>
|
|
||||||
<UserMenu />
|
|
||||||
</RightNav>
|
|
||||||
</Header>
|
|
||||||
<div className={styles.joinRoom}>
|
|
||||||
<div className={styles.joinRoomContent}>
|
|
||||||
<div className={styles.preview}>
|
|
||||||
<video ref={videoRef} muted playsInline disablePictureInPicture />
|
|
||||||
{state === GroupCallState.LocalCallFeedUninitialized && (
|
|
||||||
<p className={styles.webcamPermissions}>
|
|
||||||
Webcam/microphone permissions needed to join the call.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{state === GroupCallState.InitializingLocalCallFeed && (
|
|
||||||
<p className={styles.webcamPermissions}>
|
|
||||||
Accept webcam/microphone permissions to join the call.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{state === GroupCallState.LocalCallFeedInitialized && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
className={styles.joinCallButton}
|
|
||||||
disabled={state !== GroupCallState.LocalCallFeedInitialized}
|
|
||||||
onPress={onEnter}
|
|
||||||
>
|
|
||||||
Join call now
|
|
||||||
</Button>
|
|
||||||
<div className={styles.previewButtons}>
|
|
||||||
<MicButton
|
|
||||||
muted={microphoneMuted}
|
|
||||||
onPress={toggleMicrophoneMuted}
|
|
||||||
/>
|
|
||||||
<VideoButton
|
|
||||||
muted={localVideoMuted}
|
|
||||||
onPress={toggleLocalVideoMuted}
|
|
||||||
/>
|
|
||||||
<OverflowMenu
|
|
||||||
roomId={roomId}
|
|
||||||
setShowInspector={setShowInspector}
|
|
||||||
showInspector={showInspector}
|
|
||||||
client={client}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p>Or</p>
|
|
||||||
<CopyButton
|
|
||||||
value={getRoomUrl(roomId)}
|
|
||||||
className={styles.copyButton}
|
|
||||||
copiedMessage="Call link copied"
|
|
||||||
>
|
|
||||||
Copy call link and join later
|
|
||||||
</CopyButton>
|
|
||||||
</div>
|
|
||||||
<div className={styles.joinRoomFooter}>
|
|
||||||
<Link className={styles.homeLink} to="/">
|
|
||||||
Take me Home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InRoomView({
|
|
||||||
client,
|
|
||||||
isGuest,
|
|
||||||
groupCall,
|
|
||||||
roomName,
|
|
||||||
microphoneMuted,
|
|
||||||
localVideoMuted,
|
|
||||||
toggleLocalVideoMuted,
|
|
||||||
toggleMicrophoneMuted,
|
|
||||||
userMediaFeeds,
|
|
||||||
activeSpeaker,
|
|
||||||
onLeave,
|
|
||||||
toggleScreensharing,
|
|
||||||
isScreensharing,
|
|
||||||
screenshareFeeds,
|
|
||||||
simpleGrid,
|
|
||||||
setShowInspector,
|
|
||||||
showInspector,
|
|
||||||
roomId,
|
|
||||||
}) {
|
|
||||||
const [layout, setLayout] = useVideoGridLayout();
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
const participants = [];
|
|
||||||
|
|
||||||
for (const callFeed of userMediaFeeds) {
|
|
||||||
participants.push({
|
|
||||||
id: callFeed.stream.id,
|
|
||||||
usermediaCallFeed: callFeed,
|
|
||||||
isActiveSpeaker:
|
|
||||||
screenshareFeeds.length === 0
|
|
||||||
? callFeed.userId === activeSpeaker
|
|
||||||
: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const callFeed of screenshareFeeds) {
|
|
||||||
const participant = participants.find(
|
|
||||||
(p) => p.usermediaCallFeed.userId === callFeed.userId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (participant) {
|
|
||||||
participant.screenshareCallFeed = callFeed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return participants;
|
|
||||||
}, [userMediaFeeds, activeSpeaker, screenshareFeeds]);
|
|
||||||
|
|
||||||
const onFocusTile = useCallback(
|
|
||||||
(tiles, focusedTile) => {
|
|
||||||
if (layout === "freedom") {
|
|
||||||
return tiles.map((tile) => {
|
|
||||||
if (tile === focusedTile) {
|
|
||||||
return { ...tile, presenter: !tile.presenter };
|
|
||||||
}
|
|
||||||
|
|
||||||
return tile;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setLayout("spotlight");
|
|
||||||
|
|
||||||
return tiles.map((tile) => {
|
|
||||||
if (tile === focusedTile) {
|
|
||||||
return { ...tile, presenter: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...tile, presenter: false };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[layout, setLayout]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderAvatar = useCallback(
|
|
||||||
(roomMember, width, height) => {
|
|
||||||
const avatarUrl = roomMember.user?.avatarUrl;
|
|
||||||
const size = Math.round(Math.min(width, height) / 2);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar
|
|
||||||
key={roomMember.userId}
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
borderRadius: size,
|
|
||||||
fontSize: Math.round(size / 2),
|
|
||||||
}}
|
|
||||||
src={avatarUrl && getAvatarUrl(client, avatarUrl, 96)}
|
|
||||||
fallback={roomMember.name.slice(0, 1).toUpperCase()}
|
|
||||||
className={styles.avatar}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[client]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.room, styles.inRoom)}>
|
|
||||||
<Header>
|
|
||||||
<LeftNav>
|
|
||||||
<RoomHeaderInfo roomName={roomName} />
|
|
||||||
</LeftNav>
|
|
||||||
<RightNav>
|
|
||||||
<GridLayoutMenu layout={layout} setLayout={setLayout} />
|
|
||||||
{!isGuest && <UserMenu disableLogout />}
|
|
||||||
</RightNav>
|
|
||||||
</Header>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className={styles.centerMessage}>
|
|
||||||
<p>Waiting for other participants...</p>
|
|
||||||
</div>
|
|
||||||
) : simpleGrid ? (
|
|
||||||
<SimpleVideoGrid items={items} />
|
|
||||||
) : (
|
|
||||||
<VideoGrid
|
|
||||||
items={items}
|
|
||||||
layout={layout}
|
|
||||||
getAvatar={renderAvatar}
|
|
||||||
onFocusTile={onFocusTile}
|
|
||||||
disableAnimations={isSafari}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className={styles.footer}>
|
|
||||||
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
|
|
||||||
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
|
|
||||||
{canScreenshare && !isSafari && (
|
|
||||||
<ScreenshareButton
|
|
||||||
enabled={isScreensharing}
|
|
||||||
onPress={toggleScreensharing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<OverflowMenu
|
|
||||||
roomId={roomId}
|
|
||||||
setShowInspector={setShowInspector}
|
|
||||||
showInspector={showInspector}
|
|
||||||
client={client}
|
|
||||||
/>
|
|
||||||
<HangupButton onPress={onLeave} />
|
|
||||||
</div>
|
|
||||||
<GroupCallInspector
|
|
||||||
client={client}
|
|
||||||
groupCall={groupCall}
|
|
||||||
show={showInspector}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GuestCallEndedScreen() {
|
|
||||||
return (
|
|
||||||
<FullScreenView className={styles.callEndedScreen}>
|
|
||||||
<h1>Your call is now ended</h1>
|
|
||||||
<div className={styles.callEndedContent}>
|
|
||||||
<p>Why not finish by creating an account?</p>
|
|
||||||
<p>You'll be able to:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Easily access all your previous call links</li>
|
|
||||||
<li>Set a username and avatar</li>
|
|
||||||
</ul>
|
|
||||||
<LinkButton
|
|
||||||
className={styles.callEndedButton}
|
|
||||||
size="lg"
|
|
||||||
variant="default"
|
|
||||||
to="/register"
|
|
||||||
>
|
|
||||||
Create account
|
|
||||||
</LinkButton>
|
|
||||||
</div>
|
|
||||||
<Link to="/">Not now, return to home screen</Link>
|
|
||||||
</FullScreenView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PasswordlessUserCallEndedScreen({ client }) {
|
|
||||||
const { displayName } = useProfile(client);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullScreenView className={styles.callEndedScreen}>
|
|
||||||
<h1>{displayName}, your call is now ended</h1>
|
|
||||||
<div className={styles.callEndedContent}>
|
|
||||||
<p>Why not finish by setting up a password to keep your account?</p>
|
|
||||||
<p>
|
|
||||||
You'll be able to keep your name and set an avatar for use on future
|
|
||||||
calls
|
|
||||||
</p>
|
|
||||||
<LinkButton
|
|
||||||
className={styles.callEndedButton}
|
|
||||||
size="lg"
|
|
||||||
variant="default"
|
|
||||||
to="/register"
|
|
||||||
>
|
|
||||||
Create account
|
|
||||||
</LinkButton>
|
|
||||||
</div>
|
|
||||||
<Link to="/">Not now, return to home screen</Link>
|
|
||||||
</FullScreenView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2021 New Vector Ltd
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.room {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inRoom {
|
|
||||||
position: fixed;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.joinRoom {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.joinRoomContent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.joinRoomContent h1 {
|
|
||||||
display: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.joinRoomFooter {
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.homeLink {
|
|
||||||
margin-top: 50px;
|
|
||||||
color: #0dbd8b;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: normal;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview {
|
|
||||||
position: relative;
|
|
||||||
min-height: 280px;
|
|
||||||
height: 50vh;
|
|
||||||
border-radius: 24px;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--bgColor3);
|
|
||||||
margin: 40px 20px 20px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
background-color: black;
|
|
||||||
transform: scaleX(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.webcamPermissions {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewButtons {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 66px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: rgba(23, 25, 28, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.joinCallButton {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 222px;
|
|
||||||
height: 40px;
|
|
||||||
bottom: 86px;
|
|
||||||
left: 50%;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.copyButton {
|
|
||||||
width: 320px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewButtons > * {
|
|
||||||
margin-right: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewButtons > :last-child {
|
|
||||||
margin-right: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.centerMessage {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.centerMessage p {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roomContainer {
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer > * {
|
|
||||||
margin-right: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer > :last-child {
|
|
||||||
margin-right: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callEndedScreen h1 {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callEndedScreen h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callEndedScreen p {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callEndedScreen ul {
|
|
||||||
padding: 0;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
text-align: initial;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callEndedButton {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.callEndedContent {
|
|
||||||
text-align: center;
|
|
||||||
max-width: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
|
||||||
.roomContainer {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
height: 118px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.joinRoomContent h1 {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
67
src/SequenceDiagramViewerPage.tsx
Normal file
67
src/SequenceDiagramViewerPage.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 New Vector Ltd
|
||||||
|
|
||||||
|
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 React, { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SequenceDiagramViewer,
|
||||||
|
SequenceDiagramMatrixEvent,
|
||||||
|
} from "./room/GroupCallInspector";
|
||||||
|
import { FieldRow, InputField } from "./input/Input";
|
||||||
|
import { usePageTitle } from "./usePageTitle";
|
||||||
|
|
||||||
|
interface DebugLog {
|
||||||
|
localUserId: string;
|
||||||
|
eventsByUserId: { [userId: string]: SequenceDiagramMatrixEvent[] };
|
||||||
|
remoteUserIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SequenceDiagramViewerPage() {
|
||||||
|
usePageTitle("Inspector");
|
||||||
|
|
||||||
|
const [debugLog, setDebugLog] = useState<DebugLog>();
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string>();
|
||||||
|
const onChangeDebugLog = useCallback((e) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
e.target.files[0].text().then((text: string) => {
|
||||||
|
setDebugLog(JSON.parse(text));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<FieldRow>
|
||||||
|
<InputField
|
||||||
|
type="file"
|
||||||
|
id="debugLog"
|
||||||
|
name="debugLog"
|
||||||
|
label="Debug Log"
|
||||||
|
onChange={onChangeDebugLog}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
{debugLog && (
|
||||||
|
<SequenceDiagramViewer
|
||||||
|
localUserId={debugLog.localUserId}
|
||||||
|
selectedUserId={selectedUserId}
|
||||||
|
onSelectUserId={setSelectedUserId}
|
||||||
|
remoteUserIds={debugLog.remoteUserIds}
|
||||||
|
events={debugLog.eventsByUserId[selectedUserId]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Modal } from "./Modal";
|
|
||||||
import styles from "./SettingsModal.module.css";
|
|
||||||
import { TabContainer, TabItem } from "./Tabs";
|
|
||||||
import { ReactComponent as AudioIcon } from "./icons/Audio.svg";
|
|
||||||
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
|
|
||||||
import { ReactComponent as DeveloperIcon } from "./icons/Developer.svg";
|
|
||||||
import { SelectInput } from "./SelectInput";
|
|
||||||
import { Item } from "@react-stately/collections";
|
|
||||||
import { useMediaHandler } from "./useMediaHandler";
|
|
||||||
import { FieldRow, InputField } from "./Input";
|
|
||||||
|
|
||||||
export function SettingsModal({
|
|
||||||
client,
|
|
||||||
setShowInspector,
|
|
||||||
showInspector,
|
|
||||||
...rest
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
audioInput,
|
|
||||||
audioInputs,
|
|
||||||
setAudioInput,
|
|
||||||
videoInput,
|
|
||||||
videoInputs,
|
|
||||||
setVideoInput,
|
|
||||||
} = useMediaHandler(client);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title="Settings"
|
|
||||||
isDismissable
|
|
||||||
mobileFullScreen
|
|
||||||
className={styles.settingsModal}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<TabContainer className={styles.tabContainer}>
|
|
||||||
<TabItem
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
<AudioIcon width={16} height={16} />
|
|
||||||
<span>Audio</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectInput
|
|
||||||
label="Microphone"
|
|
||||||
selectedKey={audioInput}
|
|
||||||
onSelectionChange={setAudioInput}
|
|
||||||
>
|
|
||||||
{audioInputs.map(({ deviceId, label }) => (
|
|
||||||
<Item key={deviceId}>{label}</Item>
|
|
||||||
))}
|
|
||||||
</SelectInput>
|
|
||||||
</TabItem>
|
|
||||||
<TabItem
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
<VideoIcon width={16} height={16} />
|
|
||||||
<span>Video</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectInput
|
|
||||||
label="Webcam"
|
|
||||||
selectedKey={videoInput}
|
|
||||||
onSelectionChange={setVideoInput}
|
|
||||||
>
|
|
||||||
{videoInputs.map(({ deviceId, label }) => (
|
|
||||||
<Item key={deviceId}>{label}</Item>
|
|
||||||
))}
|
|
||||||
</SelectInput>
|
|
||||||
</TabItem>
|
|
||||||
<TabItem
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
<DeveloperIcon width={16} height={16} />
|
|
||||||
<span>Developer</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FieldRow>
|
|
||||||
<InputField
|
|
||||||
id="showInspector"
|
|
||||||
name="inspector"
|
|
||||||
label="Show Call Inspector"
|
|
||||||
type="checkbox"
|
|
||||||
checked={showInspector}
|
|
||||||
onChange={(e) => setShowInspector(e.target.checked)}
|
|
||||||
/>
|
|
||||||
</FieldRow>
|
|
||||||
</TabItem>
|
|
||||||
</TabContainer>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
53
src/Tabs.jsx
53
src/Tabs.jsx
@@ -1,53 +0,0 @@
|
|||||||
import React, { useRef } from "react";
|
|
||||||
import { useTabList, useTab, useTabPanel } from "@react-aria/tabs";
|
|
||||||
import { Item } from "@react-stately/collections";
|
|
||||||
import { useTabListState } from "@react-stately/tabs";
|
|
||||||
import styles from "./Tabs.module.css";
|
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
export function TabContainer(props) {
|
|
||||||
const state = useTabListState(props);
|
|
||||||
const ref = useRef();
|
|
||||||
const { tabListProps } = useTabList(props, state, ref);
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.tabContainer, props.className)}>
|
|
||||||
<ul {...tabListProps} ref={ref} className={styles.tabList}>
|
|
||||||
{[...state.collection].map((item) => (
|
|
||||||
<Tab key={item.key} item={item} state={state} />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<TabPanel key={state.selectedItem?.key} state={state} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Tab({ item, state }) {
|
|
||||||
const { key, rendered } = item;
|
|
||||||
const ref = useRef();
|
|
||||||
const { tabProps } = useTab({ key }, state, ref);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
{...tabProps}
|
|
||||||
ref={ref}
|
|
||||||
className={classNames(styles.tab, {
|
|
||||||
[styles.selected]: state.selectedKey === key,
|
|
||||||
[styles.disabled]: state.disabledKeys.has(key),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{rendered}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabPanel({ state, ...props }) {
|
|
||||||
const ref = useRef();
|
|
||||||
const { tabPanelProps } = useTabPanel(props, state, ref);
|
|
||||||
return (
|
|
||||||
<div {...tabPanelProps} ref={ref} className={styles.tabPanel}>
|
|
||||||
{state.selectedItem?.props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TabItem = Item;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import React, { forwardRef, useRef } from "react";
|
|
||||||
import { useTooltipTriggerState } from "@react-stately/tooltip";
|
|
||||||
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
|
|
||||||
import { mergeProps } from "@react-aria/utils";
|
|
||||||
import styles from "./Tooltip.module.css";
|
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
export function Tooltip({ position, state, ...props }) {
|
|
||||||
let { tooltipProps } = useTooltip(props, state);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.tooltip, styles[position || "bottom"])}
|
|
||||||
{...mergeProps(props, tooltipProps)}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => {
|
|
||||||
const tooltipState = useTooltipTriggerState(rest);
|
|
||||||
const fallbackRef = useRef();
|
|
||||||
const triggerRef = ref || fallbackRef;
|
|
||||||
const { triggerProps, tooltipProps } = useTooltipTrigger(
|
|
||||||
rest,
|
|
||||||
tooltipState,
|
|
||||||
triggerRef
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!Array.isArray(children) ||
|
|
||||||
children.length > 2 ||
|
|
||||||
typeof children[1] !== "function"
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"TooltipTrigger must have two props. The first being a button and the second being a render prop."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [tooltipTrigger, tooltip] = children;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.tooltipContainer}>
|
|
||||||
<tooltipTrigger.type
|
|
||||||
{...mergeProps(triggerProps, tooltipTrigger.props, rest)}
|
|
||||||
ref={triggerRef}
|
|
||||||
/>
|
|
||||||
{tooltipState.isOpen && tooltip({ state: tooltipState, ...tooltipProps })}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
TooltipTrigger.defaultProps = {
|
|
||||||
delay: 250,
|
|
||||||
};
|
|
||||||
@@ -1,33 +1,12 @@
|
|||||||
.tooltip {
|
.tooltip {
|
||||||
background-color: var(--bgColor2);
|
background-color: var(--system);
|
||||||
position: absolute;
|
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
color: var(--textColor1);
|
color: var(--primary-content);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-width: 135px;
|
max-width: 135px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
z-index: 1;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip.top {
|
|
||||||
bottom: calc(100% + 6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip.bottom {
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip.bottomLeft {
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
left: -25%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltipContainer {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|||||||
114
src/Tooltip.tsx
Normal file
114
src/Tooltip.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 New Vector Ltd
|
||||||
|
|
||||||
|
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 React, {
|
||||||
|
ForwardedRef,
|
||||||
|
forwardRef,
|
||||||
|
ReactElement,
|
||||||
|
ReactNode,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
TooltipTriggerState,
|
||||||
|
useTooltipTriggerState,
|
||||||
|
} from "@react-stately/tooltip";
|
||||||
|
import { FocusableProvider } from "@react-aria/focus";
|
||||||
|
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
|
||||||
|
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
|
||||||
|
import { Placement } from "@react-types/overlays";
|
||||||
|
|
||||||
|
import styles from "./Tooltip.module.css";
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
className?: string;
|
||||||
|
state: TooltipTriggerState;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
|
||||||
|
(
|
||||||
|
{ state, className, children, ...rest }: TooltipProps,
|
||||||
|
ref: ForwardedRef<HTMLDivElement>
|
||||||
|
) => {
|
||||||
|
const { tooltipProps } = useTooltip(rest, state);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.tooltip, className)}
|
||||||
|
{...mergeProps(rest, tooltipProps)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TooltipTriggerProps {
|
||||||
|
children: ReactElement;
|
||||||
|
placement?: Placement;
|
||||||
|
delay?: number;
|
||||||
|
tooltip: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TooltipTrigger = forwardRef<HTMLElement, TooltipTriggerProps>(
|
||||||
|
(
|
||||||
|
{ children, placement, tooltip, ...rest }: TooltipTriggerProps,
|
||||||
|
ref: ForwardedRef<HTMLElement>
|
||||||
|
) => {
|
||||||
|
const tooltipTriggerProps = { delay: 250, ...rest };
|
||||||
|
const tooltipState = useTooltipTriggerState(tooltipTriggerProps);
|
||||||
|
const triggerRef = useObjectRef<HTMLElement>(ref);
|
||||||
|
const overlayRef = useRef();
|
||||||
|
const { triggerProps, tooltipProps } = useTooltipTrigger(
|
||||||
|
tooltipTriggerProps,
|
||||||
|
tooltipState,
|
||||||
|
triggerRef
|
||||||
|
);
|
||||||
|
|
||||||
|
const { overlayProps } = useOverlayPosition({
|
||||||
|
placement: placement || "top",
|
||||||
|
targetRef: triggerRef,
|
||||||
|
overlayRef,
|
||||||
|
isOpen: tooltipState.isOpen,
|
||||||
|
offset: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusableProvider ref={triggerRef} {...triggerProps}>
|
||||||
|
<children.type
|
||||||
|
{...mergeProps<typeof children.props | typeof rest>(
|
||||||
|
children.props,
|
||||||
|
rest
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{tooltipState.isOpen && (
|
||||||
|
<OverlayContainer>
|
||||||
|
<Tooltip
|
||||||
|
state={tooltipState}
|
||||||
|
ref={overlayRef}
|
||||||
|
{...mergeProps(tooltipProps, overlayProps)}
|
||||||
|
>
|
||||||
|
{tooltip()}
|
||||||
|
</Tooltip>
|
||||||
|
</OverlayContainer>
|
||||||
|
)}
|
||||||
|
</FocusableProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
129
src/UserMenu.jsx
129
src/UserMenu.jsx
@@ -1,129 +0,0 @@
|
|||||||
import React, { useCallback, useMemo } from "react";
|
|
||||||
import { Button, LinkButton } from "./button";
|
|
||||||
import { PopoverMenuTrigger } from "./PopoverMenu";
|
|
||||||
import { ReactComponent as UserIcon } from "./icons/User.svg";
|
|
||||||
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
|
|
||||||
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
|
|
||||||
import styles from "./UserMenu.module.css";
|
|
||||||
import { Item } from "@react-stately/collections";
|
|
||||||
import { Menu } from "./Menu";
|
|
||||||
import { useHistory, useLocation } from "react-router-dom";
|
|
||||||
import { useClient, useProfile } from "./ConferenceCallManagerHooks";
|
|
||||||
import { useModalTriggerState } from "./Modal";
|
|
||||||
import { ProfileModal } from "./ProfileModal";
|
|
||||||
import { Tooltip, TooltipTrigger } from "./Tooltip";
|
|
||||||
import { Avatar } from "./Avatar";
|
|
||||||
|
|
||||||
export function UserMenu({ disableLogout }) {
|
|
||||||
const location = useLocation();
|
|
||||||
const history = useHistory();
|
|
||||||
const {
|
|
||||||
isAuthenticated,
|
|
||||||
isGuest,
|
|
||||||
isPasswordlessUser,
|
|
||||||
logout,
|
|
||||||
userName,
|
|
||||||
client,
|
|
||||||
} = useClient();
|
|
||||||
const { displayName, avatarUrl } = useProfile(client);
|
|
||||||
const { modalState, modalProps } = useModalTriggerState();
|
|
||||||
|
|
||||||
const onAction = useCallback(
|
|
||||||
(value) => {
|
|
||||||
switch (value) {
|
|
||||||
case "user":
|
|
||||||
modalState.open();
|
|
||||||
break;
|
|
||||||
case "logout":
|
|
||||||
logout();
|
|
||||||
break;
|
|
||||||
case "login":
|
|
||||||
history.push("/login", { state: { from: location } });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[history, location, logout, modalState]
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
const arr = [];
|
|
||||||
|
|
||||||
if (isAuthenticated && !isGuest) {
|
|
||||||
arr.push({
|
|
||||||
key: "user",
|
|
||||||
icon: UserIcon,
|
|
||||||
label: displayName || userName,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPasswordlessUser) {
|
|
||||||
arr.push({
|
|
||||||
key: "login",
|
|
||||||
label: "Sign In",
|
|
||||||
icon: LoginIcon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPasswordlessUser && !disableLogout) {
|
|
||||||
arr.push({
|
|
||||||
key: "logout",
|
|
||||||
label: "Sign Out",
|
|
||||||
icon: LogoutIcon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return arr;
|
|
||||||
}, [isAuthenticated, isGuest, userName, displayName]);
|
|
||||||
|
|
||||||
if (isGuest || !isAuthenticated) {
|
|
||||||
return (
|
|
||||||
<LinkButton to={{ pathname: "/login", state: { from: location } }}>
|
|
||||||
Log in
|
|
||||||
</LinkButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PopoverMenuTrigger placement="bottom right">
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button variant="icon" className={styles.userButton}>
|
|
||||||
{isAuthenticated && !isGuest && !isPasswordlessUser ? (
|
|
||||||
<Avatar
|
|
||||||
size="sm"
|
|
||||||
src={avatarUrl}
|
|
||||||
fallback={(displayName || userName).slice(0, 1).toUpperCase()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<UserIcon />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{(props) => (
|
|
||||||
<Tooltip position="bottomLeft" {...props}>
|
|
||||||
Profile
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
{(props) => (
|
|
||||||
<Menu {...props} label="User menu" onAction={onAction}>
|
|
||||||
{items.map(({ key, icon: Icon, label }) => (
|
|
||||||
<Item key={key} textValue={label}>
|
|
||||||
<Icon />
|
|
||||||
<span>{label}</span>
|
|
||||||
</Item>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</PopoverMenuTrigger>
|
|
||||||
{modalState.isOpen && (
|
|
||||||
<ProfileModal
|
|
||||||
client={client}
|
|
||||||
isAuthenticated={isAuthenticated}
|
|
||||||
isGuest={isGuest}
|
|
||||||
isPasswordlessUser={isPasswordlessUser}
|
|
||||||
{...modalProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,22 @@
|
|||||||
.userButton svg * {
|
.menuIcon {
|
||||||
fill: var(--textColor1);
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userButton svg * {
|
||||||
|
fill: var(--primary-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/UserMenu.tsx
Normal file
101
src/UserMenu.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Item } from "@react-stately/collections";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button, LinkButton } from "./button";
|
||||||
|
import { PopoverMenuTrigger } from "./popover/PopoverMenu";
|
||||||
|
import { Menu } from "./Menu";
|
||||||
|
import { TooltipTrigger } from "./Tooltip";
|
||||||
|
import { Avatar, Size } from "./Avatar";
|
||||||
|
import { ReactComponent as UserIcon } from "./icons/User.svg";
|
||||||
|
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
|
||||||
|
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
|
||||||
|
import { Body } from "./typography/Typography";
|
||||||
|
import styles from "./UserMenu.module.css";
|
||||||
|
|
||||||
|
interface UserMenuProps {
|
||||||
|
preventNavigation: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isPasswordlessUser: boolean;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
onAction: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu({
|
||||||
|
preventNavigation,
|
||||||
|
isAuthenticated,
|
||||||
|
isPasswordlessUser,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
onAction,
|
||||||
|
}: UserMenuProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const arr = [];
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
arr.push({
|
||||||
|
key: "user",
|
||||||
|
icon: UserIcon,
|
||||||
|
label: displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPasswordlessUser && !preventNavigation) {
|
||||||
|
arr.push({
|
||||||
|
key: "login",
|
||||||
|
label: "Sign In",
|
||||||
|
icon: LoginIcon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPasswordlessUser && !preventNavigation) {
|
||||||
|
arr.push({
|
||||||
|
key: "logout",
|
||||||
|
label: "Sign Out",
|
||||||
|
icon: LogoutIcon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr;
|
||||||
|
}, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation]);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<LinkButton to={{ pathname: "/login", state: { from: location } }}>
|
||||||
|
Log in
|
||||||
|
</LinkButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverMenuTrigger placement="bottom right">
|
||||||
|
<TooltipTrigger tooltip={() => "Profile"} placement="bottom left">
|
||||||
|
<Button variant="icon" className={styles.userButton}>
|
||||||
|
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
|
||||||
|
<Avatar
|
||||||
|
size={Size.SM}
|
||||||
|
className={styles.avatar}
|
||||||
|
src={avatarUrl}
|
||||||
|
fallback={displayName.slice(0, 1).toUpperCase()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserIcon />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{(props) => (
|
||||||
|
<Menu {...props} label="User menu" onAction={onAction}>
|
||||||
|
{items.map(({ key, icon: Icon, label }) => (
|
||||||
|
<Item key={key} textValue={label}>
|
||||||
|
<Icon width={24} height={24} className={styles.menuIcon} />
|
||||||
|
<Body overflowEllipsis>{label}</Body>
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</PopoverMenuTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/UserMenuContainer.tsx
Normal file
54
src/UserMenuContainer.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { useHistory, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useClient } from "./ClientContext";
|
||||||
|
import { useProfile } from "./profile/useProfile";
|
||||||
|
import { useModalTriggerState } from "./Modal";
|
||||||
|
import { ProfileModal } from "./profile/ProfileModal";
|
||||||
|
import { UserMenu } from "./UserMenu";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
preventNavigation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenuContainer({ preventNavigation = false }: Props) {
|
||||||
|
const location = useLocation();
|
||||||
|
const history = useHistory();
|
||||||
|
const { isAuthenticated, isPasswordlessUser, logout, userName, client } =
|
||||||
|
useClient();
|
||||||
|
const { displayName, avatarUrl } = useProfile(client);
|
||||||
|
const { modalState, modalProps } = useModalTriggerState();
|
||||||
|
|
||||||
|
const onAction = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
switch (value) {
|
||||||
|
case "user":
|
||||||
|
modalState.open();
|
||||||
|
break;
|
||||||
|
case "logout":
|
||||||
|
logout();
|
||||||
|
break;
|
||||||
|
case "login":
|
||||||
|
history.push("/login", { state: { from: location } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[history, location, logout, modalState]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UserMenu
|
||||||
|
preventNavigation={preventNavigation}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
isPasswordlessUser={isPasswordlessUser}
|
||||||
|
avatarUrl={avatarUrl}
|
||||||
|
onAction={onAction}
|
||||||
|
displayName={
|
||||||
|
displayName || (userName ? userName.replace("@", "") : undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{modalState.isOpen && <ProfileModal client={client} {...modalProps} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
.logo {
|
.logo {
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
margin: 80px 0;
|
margin: 80px 0;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.authLinks a {
|
.authLinks a {
|
||||||
color: #0dbd8b;
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2021 New Vector Ltd
|
Copyright 2021-2022 New Vector Ltd
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -14,37 +14,49 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useRef, useState, useMemo } from "react";
|
import React, {
|
||||||
|
FC,
|
||||||
|
FormEvent,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
import { useHistory, useLocation, Link } from "react-router-dom";
|
import { useHistory, useLocation, Link } from "react-router-dom";
|
||||||
import { ReactComponent as Logo } from "./icons/LogoLarge.svg";
|
|
||||||
import { FieldRow, InputField, ErrorMessage } from "./Input";
|
|
||||||
import { Button } from "./button";
|
|
||||||
import {
|
|
||||||
useClient,
|
|
||||||
defaultHomeserver,
|
|
||||||
defaultHomeserverHost,
|
|
||||||
} from "./ConferenceCallManagerHooks";
|
|
||||||
import styles from "./LoginPage.module.css";
|
|
||||||
|
|
||||||
export function LoginPage() {
|
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
|
||||||
const { login } = useClient();
|
import { useClient } from "../ClientContext";
|
||||||
const [homeserver, setHomeServer] = useState(defaultHomeserver);
|
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||||
const usernameRef = useRef();
|
import { Button } from "../button";
|
||||||
const passwordRef = useRef();
|
import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils";
|
||||||
|
import styles from "./LoginPage.module.css";
|
||||||
|
import { useInteractiveLogin } from "./useInteractiveLogin";
|
||||||
|
import { usePageTitle } from "../usePageTitle";
|
||||||
|
|
||||||
|
export const LoginPage: FC = () => {
|
||||||
|
usePageTitle("Login");
|
||||||
|
|
||||||
|
const { setClient } = useClient();
|
||||||
|
const login = useInteractiveLogin();
|
||||||
|
const homeserver = defaultHomeserver; // TODO: Make this configurable
|
||||||
|
const usernameRef = useRef<HTMLInputElement>();
|
||||||
|
const passwordRef = useRef<HTMLInputElement>();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState();
|
const [error, setError] = useState<Error>();
|
||||||
|
|
||||||
// TODO: Handle hitting login page with authenticated client
|
// TODO: Handle hitting login page with authenticated client
|
||||||
|
|
||||||
const onSubmitLoginForm = useCallback(
|
const onSubmitLoginForm = useCallback(
|
||||||
(e) => {
|
(e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
login(homeserver, usernameRef.current.value, passwordRef.current.value)
|
login(homeserver, usernameRef.current.value, passwordRef.current.value)
|
||||||
.then(() => {
|
.then(([client, session]) => {
|
||||||
|
setClient(client, session);
|
||||||
|
|
||||||
if (location.state && location.state.from) {
|
if (location.state && location.state.from) {
|
||||||
history.push(location.state.from);
|
history.push(location.state.from);
|
||||||
} else {
|
} else {
|
||||||
@@ -56,13 +68,13 @@ export function LoginPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[login, location, history, homeserver]
|
[login, location, history, homeserver, setClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
const homeserverHost = useMemo(() => {
|
const homeserverHost = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return new URL(homeserver).host;
|
return new URL(homeserver).host;
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
return defaultHomeserverHost;
|
return defaultHomeserverHost;
|
||||||
}
|
}
|
||||||
}, [homeserver]);
|
}, [homeserver]);
|
||||||
@@ -121,4 +133,4 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
231
src/auth/RegisterPage.tsx
Normal file
231
src/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2021-2022 New Vector Ltd
|
||||||
|
|
||||||
|
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 React, {
|
||||||
|
ChangeEvent,
|
||||||
|
FC,
|
||||||
|
FormEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useHistory, useLocation } from "react-router-dom";
|
||||||
|
import { captureException } from "@sentry/react";
|
||||||
|
import { sleep } from "matrix-js-sdk/src/utils";
|
||||||
|
|
||||||
|
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||||
|
import { Button } from "../button";
|
||||||
|
import { useClient } from "../ClientContext";
|
||||||
|
import { defaultHomeserverHost } from "../matrix-utils";
|
||||||
|
import { useInteractiveRegistration } from "./useInteractiveRegistration";
|
||||||
|
import styles from "./LoginPage.module.css";
|
||||||
|
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
|
||||||
|
import { LoadingView } from "../FullScreenView";
|
||||||
|
import { useRecaptcha } from "./useRecaptcha";
|
||||||
|
import { Caption, Link } from "../typography/Typography";
|
||||||
|
import { usePageTitle } from "../usePageTitle";
|
||||||
|
|
||||||
|
export const RegisterPage: FC = () => {
|
||||||
|
usePageTitle("Register");
|
||||||
|
|
||||||
|
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
|
||||||
|
useClient();
|
||||||
|
const confirmPasswordRef = useRef<HTMLInputElement>();
|
||||||
|
const history = useHistory();
|
||||||
|
const location = useLocation();
|
||||||
|
const [registering, setRegistering] = useState(false);
|
||||||
|
const [error, setError] = useState<Error>();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
||||||
|
const [privacyPolicyUrl, recaptchaKey, register] =
|
||||||
|
useInteractiveRegistration();
|
||||||
|
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||||
|
|
||||||
|
const onSubmitRegisterForm = useCallback(
|
||||||
|
(e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = new FormData(e.target as HTMLFormElement);
|
||||||
|
const userName = data.get("userName") as string;
|
||||||
|
const password = data.get("password") as string;
|
||||||
|
const passwordConfirmation = data.get("passwordConfirmation") as string;
|
||||||
|
|
||||||
|
if (password !== passwordConfirmation) return;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setRegistering(true);
|
||||||
|
|
||||||
|
const recaptchaResponse = await execute();
|
||||||
|
const [newClient, session] = await register(
|
||||||
|
userName,
|
||||||
|
password,
|
||||||
|
userName,
|
||||||
|
recaptchaResponse
|
||||||
|
);
|
||||||
|
|
||||||
|
if (client && isPasswordlessUser) {
|
||||||
|
// Migrate the user's rooms
|
||||||
|
for (const groupCall of client.groupCallEventHandler.groupCalls.values()) {
|
||||||
|
const roomId = groupCall.room.roomId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await newClient.joinRoom(roomId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.errcode === "M_LIMIT_EXCEEDED") {
|
||||||
|
await sleep(error.data.retry_after_ms);
|
||||||
|
await newClient.joinRoom(roomId);
|
||||||
|
} else {
|
||||||
|
captureException(error);
|
||||||
|
console.error(`Couldn't join room ${roomId}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setClient(newClient, session);
|
||||||
|
};
|
||||||
|
|
||||||
|
submit()
|
||||||
|
.then(() => {
|
||||||
|
if (location.state?.from) {
|
||||||
|
history.push(location.state?.from);
|
||||||
|
} else {
|
||||||
|
history.push("/");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setError(error);
|
||||||
|
setRegistering(false);
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
register,
|
||||||
|
location,
|
||||||
|
history,
|
||||||
|
isPasswordlessUser,
|
||||||
|
reset,
|
||||||
|
execute,
|
||||||
|
client,
|
||||||
|
setClient,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (password && passwordConfirmation && password !== passwordConfirmation) {
|
||||||
|
confirmPasswordRef.current?.setCustomValidity("Passwords must match");
|
||||||
|
} else {
|
||||||
|
confirmPasswordRef.current?.setCustomValidity("");
|
||||||
|
}
|
||||||
|
}, [password, passwordConfirmation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
|
||||||
|
history.push("/");
|
||||||
|
}
|
||||||
|
}, [loading, history, isAuthenticated, isPasswordlessUser, registering]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingView />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.formContainer}>
|
||||||
|
<Logo width="auto" height="auto" className={styles.logo} />
|
||||||
|
<h2>Create your account</h2>
|
||||||
|
<form onSubmit={onSubmitRegisterForm}>
|
||||||
|
<FieldRow>
|
||||||
|
<InputField
|
||||||
|
type="text"
|
||||||
|
name="userName"
|
||||||
|
placeholder="Username"
|
||||||
|
label="Username"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
prefix="@"
|
||||||
|
suffix={`:${defaultHomeserverHost}`}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<InputField
|
||||||
|
required
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setPassword(e.target.value)
|
||||||
|
}
|
||||||
|
value={password}
|
||||||
|
placeholder="Password"
|
||||||
|
label="Password"
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<InputField
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
name="passwordConfirmation"
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setPasswordConfirmation(e.target.value)
|
||||||
|
}
|
||||||
|
value={passwordConfirmation}
|
||||||
|
placeholder="Confirm Password"
|
||||||
|
label="Confirm Password"
|
||||||
|
ref={confirmPasswordRef}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<Caption>
|
||||||
|
This site is protected by ReCAPTCHA and the Google{" "}
|
||||||
|
<Link href="https://www.google.com/policies/privacy/">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link href="https://policies.google.com/terms">
|
||||||
|
Terms of Service
|
||||||
|
</Link>{" "}
|
||||||
|
apply.
|
||||||
|
<br />
|
||||||
|
By clicking "Register", you agree to our{" "}
|
||||||
|
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
||||||
|
</Caption>
|
||||||
|
{error && (
|
||||||
|
<FieldRow>
|
||||||
|
<ErrorMessage>{error.message}</ErrorMessage>
|
||||||
|
</FieldRow>
|
||||||
|
)}
|
||||||
|
<FieldRow>
|
||||||
|
<Button type="submit" disabled={registering}>
|
||||||
|
{registering ? "Registering..." : "Register"}
|
||||||
|
</Button>
|
||||||
|
</FieldRow>
|
||||||
|
<div id={recaptchaId} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className={styles.authLinks}>
|
||||||
|
<p>Already have an account?</p>
|
||||||
|
<p>
|
||||||
|
<Link to="/login">Log in</Link>
|
||||||
|
{" Or "}
|
||||||
|
<Link to="/">Access as a guest</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
152
src/auth/generateRandomName.ts
Normal file
152
src/auth/generateRandomName.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 {
|
||||||
|
uniqueNamesGenerator,
|
||||||
|
adjectives,
|
||||||
|
colors,
|
||||||
|
animals,
|
||||||
|
} from "unique-names-generator";
|
||||||
|
|
||||||
|
const elements = [
|
||||||
|
"hydrogen",
|
||||||
|
"helium",
|
||||||
|
"lithium",
|
||||||
|
"beryllium",
|
||||||
|
"boron",
|
||||||
|
"carbon",
|
||||||
|
"nitrogen",
|
||||||
|
"oxygen",
|
||||||
|
"fluorine",
|
||||||
|
"neon",
|
||||||
|
"sodium",
|
||||||
|
"magnesium",
|
||||||
|
"aluminum",
|
||||||
|
"silicon",
|
||||||
|
"phosphorus",
|
||||||
|
"sulfur",
|
||||||
|
"chlorine",
|
||||||
|
"argon",
|
||||||
|
"potassium",
|
||||||
|
"calcium",
|
||||||
|
"scandium",
|
||||||
|
"titanium",
|
||||||
|
"vanadium",
|
||||||
|
"chromium",
|
||||||
|
"manganese",
|
||||||
|
"iron",
|
||||||
|
"cobalt",
|
||||||
|
"nickel",
|
||||||
|
"copper",
|
||||||
|
"zinc",
|
||||||
|
"gallium",
|
||||||
|
"germanium",
|
||||||
|
"arsenic",
|
||||||
|
"selenium",
|
||||||
|
"bromine",
|
||||||
|
"krypton",
|
||||||
|
"rubidium",
|
||||||
|
"strontium",
|
||||||
|
"yttrium",
|
||||||
|
"zirconium",
|
||||||
|
"niobium",
|
||||||
|
"molybdenum",
|
||||||
|
"technetium",
|
||||||
|
"ruthenium",
|
||||||
|
"rhodium",
|
||||||
|
"palladium",
|
||||||
|
"silver",
|
||||||
|
"cadmium",
|
||||||
|
"indium",
|
||||||
|
"tin",
|
||||||
|
"antimony",
|
||||||
|
"tellurium",
|
||||||
|
"iodine",
|
||||||
|
"xenon",
|
||||||
|
"cesium",
|
||||||
|
"barium",
|
||||||
|
"lanthanum",
|
||||||
|
"cerium",
|
||||||
|
"praseodymium",
|
||||||
|
"neodymium",
|
||||||
|
"promethium",
|
||||||
|
"samarium",
|
||||||
|
"europium",
|
||||||
|
"gadolinium",
|
||||||
|
"terbium",
|
||||||
|
"dysprosium",
|
||||||
|
"holmium",
|
||||||
|
"erbium",
|
||||||
|
"thulium",
|
||||||
|
"ytterbium",
|
||||||
|
"lutetium",
|
||||||
|
"hafnium",
|
||||||
|
"tantalum",
|
||||||
|
"wolfram",
|
||||||
|
"rhenium",
|
||||||
|
"osmium",
|
||||||
|
"iridium",
|
||||||
|
"platinum",
|
||||||
|
"gold",
|
||||||
|
"mercury",
|
||||||
|
"thallium",
|
||||||
|
"lead",
|
||||||
|
"bismuth",
|
||||||
|
"polonium",
|
||||||
|
"astatine",
|
||||||
|
"radon",
|
||||||
|
"francium",
|
||||||
|
"radium",
|
||||||
|
"actinium",
|
||||||
|
"thorium",
|
||||||
|
"protactinium",
|
||||||
|
"uranium",
|
||||||
|
"neptunium",
|
||||||
|
"plutonium",
|
||||||
|
"americium",
|
||||||
|
"curium",
|
||||||
|
"berkelium",
|
||||||
|
"californium",
|
||||||
|
"einsteinium",
|
||||||
|
"fermium",
|
||||||
|
"mendelevium",
|
||||||
|
"nobelium",
|
||||||
|
"lawrencium",
|
||||||
|
"rutherfordium",
|
||||||
|
"dubnium",
|
||||||
|
"seaborgium",
|
||||||
|
"bohrium",
|
||||||
|
"hassium",
|
||||||
|
"meitnerium",
|
||||||
|
"darmstadtium",
|
||||||
|
"roentgenium",
|
||||||
|
"copernicium",
|
||||||
|
"nihonium",
|
||||||
|
"flerovium",
|
||||||
|
"moscovium",
|
||||||
|
"livermorium",
|
||||||
|
"tennessine",
|
||||||
|
"oganesson",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function generateRandomName(): string {
|
||||||
|
return uniqueNamesGenerator({
|
||||||
|
dictionaries: [colors, adjectives, animals, elements],
|
||||||
|
style: "lowerCase",
|
||||||
|
length: 3,
|
||||||
|
separator: "-",
|
||||||
|
});
|
||||||
|
}
|
||||||
72
src/auth/useInteractiveLogin.ts
Normal file
72
src/auth/useInteractiveLogin.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 { useCallback } from "react";
|
||||||
|
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||||
|
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { initClient, defaultHomeserver } from "../matrix-utils";
|
||||||
|
import { Session } from "../ClientContext";
|
||||||
|
|
||||||
|
export const useInteractiveLogin = () =>
|
||||||
|
useCallback<
|
||||||
|
(
|
||||||
|
homeserver: string,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
) => Promise<[MatrixClient, Session]>
|
||||||
|
>(async (homeserver: string, username: string, password: string) => {
|
||||||
|
const authClient = createClient(homeserver);
|
||||||
|
|
||||||
|
const interactiveAuth = new InteractiveAuth({
|
||||||
|
matrixClient: authClient,
|
||||||
|
doRequest: () =>
|
||||||
|
authClient.login("m.login.password", {
|
||||||
|
identifier: {
|
||||||
|
type: "m.id.user",
|
||||||
|
user: username,
|
||||||
|
},
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
stateUpdated: null,
|
||||||
|
requestEmailToken: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// XXX: This claims to return an IAuthData which contains none of these
|
||||||
|
// things - the js-sdk types may be wrong?
|
||||||
|
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||||
|
const { user_id, access_token, device_id } =
|
||||||
|
(await interactiveAuth.attemptAuth()) as any;
|
||||||
|
const session = {
|
||||||
|
user_id,
|
||||||
|
access_token,
|
||||||
|
device_id,
|
||||||
|
passwordlessUser: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = await initClient(
|
||||||
|
{
|
||||||
|
baseUrl: defaultHomeserver,
|
||||||
|
accessToken: access_token,
|
||||||
|
userId: user_id,
|
||||||
|
deviceId: device_id,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
/* eslint-enable camelcase */
|
||||||
|
|
||||||
|
return [client, session];
|
||||||
|
}, []);
|
||||||
127
src/auth/useInteractiveRegistration.ts
Normal file
127
src/auth/useInteractiveRegistration.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||||
|
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { initClient, defaultHomeserver } from "../matrix-utils";
|
||||||
|
import { Session } from "../ClientContext";
|
||||||
|
|
||||||
|
export const useInteractiveRegistration = (): [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
displayName: string,
|
||||||
|
recaptchaResponse: string,
|
||||||
|
passwordlessUser?: boolean
|
||||||
|
) => Promise<[MatrixClient, Session]>
|
||||||
|
] => {
|
||||||
|
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string>();
|
||||||
|
const [recaptchaKey, setRecaptchaKey] = useState<string>();
|
||||||
|
|
||||||
|
const authClient = useRef<MatrixClient>();
|
||||||
|
if (!authClient.current) {
|
||||||
|
authClient.current = createClient(defaultHomeserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authClient.current.registerRequest({}).catch((error) => {
|
||||||
|
setPrivacyPolicyUrl(
|
||||||
|
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url
|
||||||
|
);
|
||||||
|
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
displayName: string,
|
||||||
|
recaptchaResponse: string,
|
||||||
|
passwordlessUser?: boolean
|
||||||
|
): Promise<[MatrixClient, Session]> => {
|
||||||
|
const interactiveAuth = new InteractiveAuth({
|
||||||
|
matrixClient: authClient.current,
|
||||||
|
doRequest: (auth) =>
|
||||||
|
authClient.current.registerRequest({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
auth: auth || undefined,
|
||||||
|
}),
|
||||||
|
stateUpdated: (nextStage, status) => {
|
||||||
|
if (status.error) {
|
||||||
|
throw new Error(status.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStage === "m.login.terms") {
|
||||||
|
interactiveAuth.submitAuthDict({
|
||||||
|
type: "m.login.terms",
|
||||||
|
});
|
||||||
|
} else if (nextStage === "m.login.recaptcha") {
|
||||||
|
interactiveAuth.submitAuthDict({
|
||||||
|
type: "m.login.recaptcha",
|
||||||
|
response: recaptchaResponse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestEmailToken: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// XXX: This claims to return an IAuthData which contains none of these
|
||||||
|
// things - the js-sdk types may be wrong?
|
||||||
|
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||||
|
const { user_id, access_token, device_id } =
|
||||||
|
(await interactiveAuth.attemptAuth()) as any;
|
||||||
|
|
||||||
|
const client = await initClient(
|
||||||
|
{
|
||||||
|
baseUrl: defaultHomeserver,
|
||||||
|
accessToken: access_token,
|
||||||
|
userId: user_id,
|
||||||
|
deviceId: device_id,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.setDisplayName(displayName);
|
||||||
|
|
||||||
|
const session: Session = {
|
||||||
|
user_id,
|
||||||
|
device_id,
|
||||||
|
access_token,
|
||||||
|
passwordlessUser,
|
||||||
|
};
|
||||||
|
/* eslint-enable camelcase */
|
||||||
|
|
||||||
|
if (passwordlessUser) {
|
||||||
|
session.tempPassword = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = client.getUser(client.getUserId());
|
||||||
|
user.setRawDisplayName(displayName);
|
||||||
|
user.setDisplayName(displayName);
|
||||||
|
|
||||||
|
return [client, session];
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [privacyPolicyUrl, recaptchaKey, register];
|
||||||
|
};
|
||||||
118
src/auth/useRecaptcha.ts
Normal file
118
src/auth/useRecaptcha.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 { useEffect, useCallback, useRef, useState } from "react";
|
||||||
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
mxOnRecaptchaLoaded: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECAPTCHA_SCRIPT_URL =
|
||||||
|
"https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit";
|
||||||
|
|
||||||
|
interface RecaptchaPromiseRef {
|
||||||
|
resolve: (response: string) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRecaptcha = (sitekey: string) => {
|
||||||
|
const [recaptchaId] = useState(() => randomString(16));
|
||||||
|
const promiseRef = useRef<RecaptchaPromiseRef>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sitekey) return;
|
||||||
|
|
||||||
|
const onRecaptchaLoaded = () => {
|
||||||
|
if (!document.getElementById(recaptchaId)) return;
|
||||||
|
|
||||||
|
window.grecaptcha.render(recaptchaId, {
|
||||||
|
sitekey,
|
||||||
|
size: "invisible",
|
||||||
|
callback: (response: string) => promiseRef.current?.resolve(response),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
"error-callback": () => promiseRef.current?.reject(new Error()),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window.grecaptcha?.render === "function") {
|
||||||
|
onRecaptchaLoaded();
|
||||||
|
} else {
|
||||||
|
window.mxOnRecaptchaLoaded = onRecaptchaLoaded;
|
||||||
|
|
||||||
|
if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) {
|
||||||
|
const scriptTag = document.createElement("script") as HTMLScriptElement;
|
||||||
|
scriptTag.src = RECAPTCHA_SCRIPT_URL;
|
||||||
|
scriptTag.async = true;
|
||||||
|
document.body.appendChild(scriptTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [recaptchaId, sitekey]);
|
||||||
|
|
||||||
|
const execute = useCallback(() => {
|
||||||
|
if (!sitekey) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.grecaptcha) {
|
||||||
|
console.log("Recaptcha not loaded");
|
||||||
|
return Promise.reject(new Error("Recaptcha not loaded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const observer = new MutationObserver((mutationsList) => {
|
||||||
|
for (const item of mutationsList) {
|
||||||
|
if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
|
||||||
|
reject(new Error("Recaptcha dismissed"));
|
||||||
|
observer.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
promiseRef.current = {
|
||||||
|
resolve: (value) => {
|
||||||
|
resolve(value);
|
||||||
|
observer.disconnect();
|
||||||
|
},
|
||||||
|
reject: (error) => {
|
||||||
|
reject(error);
|
||||||
|
observer.disconnect();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.grecaptcha.execute();
|
||||||
|
|
||||||
|
const iframe = document.querySelector<HTMLIFrameElement>(
|
||||||
|
'iframe[src*="recaptcha/api2/bframe"]'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (iframe?.parentNode?.parentNode) {
|
||||||
|
observer.observe(iframe?.parentNode?.parentNode, {
|
||||||
|
attributes: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [sitekey]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
window.grecaptcha?.reset();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { execute, reset, recaptchaId };
|
||||||
|
};
|
||||||
59
src/auth/useRegisterPasswordlessUser.ts
Normal file
59
src/auth/useRegisterPasswordlessUser.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 { useCallback } from "react";
|
||||||
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
|
||||||
|
import { useClient } from "../ClientContext";
|
||||||
|
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||||
|
import { generateRandomName } from "../auth/generateRandomName";
|
||||||
|
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||||
|
|
||||||
|
export interface UseRegisterPasswordlessUserType {
|
||||||
|
privacyPolicyUrl: string;
|
||||||
|
registerPasswordlessUser: (displayName: string) => Promise<void>;
|
||||||
|
recaptchaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||||
|
const { setClient } = useClient();
|
||||||
|
const [privacyPolicyUrl, recaptchaKey, register] =
|
||||||
|
useInteractiveRegistration();
|
||||||
|
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||||
|
|
||||||
|
const registerPasswordlessUser = useCallback(
|
||||||
|
async (displayName: string) => {
|
||||||
|
try {
|
||||||
|
const recaptchaResponse = await execute();
|
||||||
|
const userName = generateRandomName();
|
||||||
|
const [client, session] = await register(
|
||||||
|
userName,
|
||||||
|
randomString(16),
|
||||||
|
displayName,
|
||||||
|
recaptchaResponse,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
setClient(client, session);
|
||||||
|
} catch (e) {
|
||||||
|
reset();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[execute, reset, register, setClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
||||||
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import React, { forwardRef } from "react";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import styles from "./Button.module.css";
|
|
||||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
|
||||||
import { ReactComponent as MuteMicIcon } from "../icons/MuteMic.svg";
|
|
||||||
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
|
|
||||||
import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
|
|
||||||
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
|
|
||||||
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
|
|
||||||
import { useButton } from "@react-aria/button";
|
|
||||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
|
||||||
import { Tooltip, TooltipTrigger } from "../Tooltip";
|
|
||||||
|
|
||||||
export const variantToClassName = {
|
|
||||||
default: [styles.button],
|
|
||||||
toolbar: [styles.toolbarButton],
|
|
||||||
icon: [styles.iconButton],
|
|
||||||
secondary: [styles.secondary],
|
|
||||||
copy: [styles.copyButton],
|
|
||||||
iconCopy: [styles.iconCopyButton],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sizeToClassName = {
|
|
||||||
lg: [styles.lg],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Button = forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
variant = "default",
|
|
||||||
size,
|
|
||||||
on,
|
|
||||||
off,
|
|
||||||
iconStyle,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...rest
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const buttonRef = useObjectRef(ref);
|
|
||||||
const { buttonProps } = useButton(rest, buttonRef);
|
|
||||||
|
|
||||||
// TODO: react-aria's useButton hook prevents form submission via keyboard
|
|
||||||
// Remove the hack below after this is merged https://github.com/adobe/react-spectrum/pull/904
|
|
||||||
let filteredButtonProps = buttonProps;
|
|
||||||
|
|
||||||
if (rest.type === "submit" && !rest.onPress) {
|
|
||||||
const { onKeyDown, onKeyUp, ...filtered } = buttonProps;
|
|
||||||
filteredButtonProps = filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={classNames(
|
|
||||||
variantToClassName[variant],
|
|
||||||
sizeToClassName[size],
|
|
||||||
styles[iconStyle],
|
|
||||||
className,
|
|
||||||
{
|
|
||||||
[styles.on]: on,
|
|
||||||
[styles.off]: off,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
{...filteredButtonProps}
|
|
||||||
ref={buttonRef}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export function ButtonTooltip({ className, children }) {
|
|
||||||
return (
|
|
||||||
<div className={classNames(styles.buttonTooltip, className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MicButton({ muted, ...rest }) {
|
|
||||||
return (
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button variant="toolbar" {...rest} off={muted}>
|
|
||||||
{muted ? <MuteMicIcon /> : <MicIcon />}
|
|
||||||
</Button>
|
|
||||||
{(props) => (
|
|
||||||
<Tooltip position="top" {...props}>
|
|
||||||
{muted ? "Unmute microphone" : "Mute microphone"}
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoButton({ muted, ...rest }) {
|
|
||||||
return (
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button variant="toolbar" {...rest} off={muted}>
|
|
||||||
{muted ? <DisableVideoIcon /> : <VideoIcon />}
|
|
||||||
</Button>
|
|
||||||
{(props) => (
|
|
||||||
<Tooltip position="top" {...props}>
|
|
||||||
{muted ? "Turn on camera" : "Turn off camera"}
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScreenshareButton({ enabled, className, ...rest }) {
|
|
||||||
return (
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button variant="toolbar" {...rest} on={enabled}>
|
|
||||||
<ScreenshareIcon />
|
|
||||||
</Button>
|
|
||||||
{(props) => (
|
|
||||||
<Tooltip position="top" {...props}>
|
|
||||||
{enabled ? "Stop sharing screen" : "Share screen"}
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HangupButton({ className, ...rest }) {
|
|
||||||
return (
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button
|
|
||||||
variant="toolbar"
|
|
||||||
className={classNames(styles.hangupButton, className)}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<HangupIcon />
|
|
||||||
</Button>
|
|
||||||
{(props) => (
|
|
||||||
<Tooltip position="top" {...props}>
|
|
||||||
Leave
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,10 +16,13 @@ limitations under the License.
|
|||||||
|
|
||||||
.button,
|
.button,
|
||||||
.toolbarButton,
|
.toolbarButton,
|
||||||
|
.toolbarButtonSecondary,
|
||||||
.iconButton,
|
.iconButton,
|
||||||
.iconCopyButton,
|
.iconCopyButton,
|
||||||
.secondary,
|
.secondary,
|
||||||
.copyButton {
|
.secondaryHangup,
|
||||||
|
.copyButton,
|
||||||
|
.dropdownButton {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -33,6 +36,7 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.secondary,
|
.secondary,
|
||||||
|
.secondaryHangup,
|
||||||
.button,
|
.button,
|
||||||
.copyButton {
|
.copyButton {
|
||||||
padding: 7px 15px;
|
padding: 7px 15px;
|
||||||
@@ -42,91 +46,94 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
color: #fff;
|
color: var(--primary-content);
|
||||||
background-color: var(--primaryColor);
|
background-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbarButton {
|
.button:focus,
|
||||||
|
.toolbarButton:focus,
|
||||||
|
.toolbarButtonSecondary:focus,
|
||||||
|
.iconButton:focus,
|
||||||
|
.iconCopyButton:focus,
|
||||||
|
.secondary:focus,
|
||||||
|
.secondaryHangup:focus,
|
||||||
|
.copyButton:focus {
|
||||||
|
outline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButton,
|
||||||
|
.toolbarButtonSecondary {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
background-color: var(--bgColor2);
|
background-color: var(--system);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbarButton:hover {
|
.toolbarButton:hover,
|
||||||
background-color: var(--bgColor4);
|
.toolbarButtonSecondary:hover {
|
||||||
|
background-color: var(--quinary-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbarButton.on,
|
.toolbarButton.on,
|
||||||
.toolbarButton.off {
|
.toolbarButton.off {
|
||||||
background-color: #ffffff;
|
background-color: var(--primary-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarButtonSecondary.on {
|
||||||
|
background-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconButton:not(.stroke) svg * {
|
.iconButton:not(.stroke) svg * {
|
||||||
fill: #ffffff;
|
fill: var(--primary-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconButton:not(.stroke):hover svg * {
|
.iconButton:not(.stroke):hover svg * {
|
||||||
fill: #0dbd8b;
|
fill: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconButton.on:not(.stroke) svg * {
|
.iconButton.on:not(.stroke) svg * {
|
||||||
fill: #0dbd8b;
|
fill: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconButton.on.stroke svg * {
|
.iconButton.on.stroke svg * {
|
||||||
stroke: #0dbd8b;
|
stroke: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hangupButton,
|
.hangupButton,
|
||||||
.hangupButton:hover {
|
.hangupButton:hover {
|
||||||
background-color: #ff5b55;
|
background-color: var(--alert);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbarButton.on svg * {
|
.toolbarButton.on svg * {
|
||||||
fill: #0dbd8b;
|
fill: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbarButton.off svg * {
|
.toolbarButton.off svg * {
|
||||||
fill: #21262c;
|
fill: #21262c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonTooltip {
|
.toolbarButtonSecondary.on svg * {
|
||||||
display: none;
|
fill: var(--primary-content);
|
||||||
background-color: var(--bgColor2);
|
|
||||||
position: absolute;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 10px;
|
|
||||||
color: var(--textColor1);
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 135px;
|
|
||||||
width: max-content;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonTooltip.bottomRight {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbarButton:hover .buttonTooltip {
|
|
||||||
display: flex;
|
|
||||||
bottom: calc(100% + 6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconButton:hover .buttonTooltip {
|
|
||||||
display: flex;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary,
|
.secondary,
|
||||||
.copyButton {
|
.copyButton {
|
||||||
color: #0dbd8b;
|
color: var(--accent);
|
||||||
border: 2px solid #0dbd8b;
|
border: 2px solid var(--accent);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondaryHangup {
|
||||||
|
color: var(--alert);
|
||||||
|
border: 2px solid var(--alert);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyButton.secondaryCopy {
|
||||||
|
color: var(--primary-content);
|
||||||
|
border-color: var(--primary-content);
|
||||||
|
}
|
||||||
|
|
||||||
.copyButton {
|
.copyButton {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -147,12 +154,12 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.copyButton:not(.on) svg * {
|
.copyButton:not(.on) svg * {
|
||||||
fill: #0dbd8b;
|
fill: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.copyButton.on {
|
.copyButton.on {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background-color: #0dbd8b;
|
background-color: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +167,50 @@ limitations under the License.
|
|||||||
stroke: white;
|
stroke: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copyButton.secondaryCopy:not(.on) svg * {
|
||||||
|
fill: var(--primary-content);
|
||||||
|
}
|
||||||
|
|
||||||
.iconCopyButton svg * {
|
.iconCopyButton svg * {
|
||||||
fill: var(--textColor3);
|
fill: var(--tertiary-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconCopyButton:hover svg * {
|
.iconCopyButton:hover svg * {
|
||||||
fill: #0dbd8b;
|
fill: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconCopyButton.on svg *,
|
.iconCopyButton.on svg *,
|
||||||
.iconCopyButton.on:hover svg * {
|
.iconCopyButton.on:hover svg * {
|
||||||
fill: transparent;
|
fill: transparent;
|
||||||
stroke: #0dbd8b;
|
stroke: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownButton {
|
||||||
|
color: var(--primary-content);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownButton:hover,
|
||||||
|
.dropdownButton.on {
|
||||||
|
background-color: var(--quinary-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownButton svg {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownButton svg * {
|
||||||
|
fill: var(--primary-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg {
|
.lg {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.linkButton {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
287
src/button/Button.tsx
Normal file
287
src/button/Button.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 React, { forwardRef, useCallback } from "react";
|
||||||
|
import { PressEvent } from "@react-types/shared";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useButton } from "@react-aria/button";
|
||||||
|
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||||
|
|
||||||
|
import styles from "./Button.module.css";
|
||||||
|
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||||
|
import { ReactComponent as MuteMicIcon } from "../icons/MuteMic.svg";
|
||||||
|
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
|
||||||
|
import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
|
||||||
|
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
|
||||||
|
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
|
||||||
|
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
|
||||||
|
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
|
||||||
|
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
|
||||||
|
import { ReactComponent as Fullscreen } from "../icons/Fullscreen.svg";
|
||||||
|
import { ReactComponent as FullscreenExit } from "../icons/FullscreenExit.svg";
|
||||||
|
import { TooltipTrigger } from "../Tooltip";
|
||||||
|
import { VolumeIcon } from "./VolumeIcon";
|
||||||
|
|
||||||
|
export type ButtonVariant =
|
||||||
|
| "default"
|
||||||
|
| "toolbar"
|
||||||
|
| "toolbarSecondary"
|
||||||
|
| "icon"
|
||||||
|
| "secondary"
|
||||||
|
| "copy"
|
||||||
|
| "secondaryCopy"
|
||||||
|
| "iconCopy"
|
||||||
|
| "secondaryHangup"
|
||||||
|
| "dropdown"
|
||||||
|
| "link";
|
||||||
|
|
||||||
|
export const variantToClassName = {
|
||||||
|
default: [styles.button],
|
||||||
|
toolbar: [styles.toolbarButton],
|
||||||
|
toolbarSecondary: [styles.toolbarButtonSecondary],
|
||||||
|
icon: [styles.iconButton],
|
||||||
|
secondary: [styles.secondary],
|
||||||
|
copy: [styles.copyButton],
|
||||||
|
secondaryCopy: [styles.secondaryCopy, styles.copyButton],
|
||||||
|
iconCopy: [styles.iconCopyButton],
|
||||||
|
secondaryHangup: [styles.secondaryHangup],
|
||||||
|
dropdown: [styles.dropdownButton],
|
||||||
|
link: [styles.linkButton],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ButtonSize = "lg";
|
||||||
|
|
||||||
|
export const sizeToClassName: { lg: string[] } = {
|
||||||
|
lg: [styles.lg],
|
||||||
|
};
|
||||||
|
interface Props {
|
||||||
|
variant: ButtonVariant;
|
||||||
|
size: ButtonSize;
|
||||||
|
on: () => void;
|
||||||
|
off: () => void;
|
||||||
|
iconStyle: string;
|
||||||
|
className: string;
|
||||||
|
children: Element[];
|
||||||
|
onPress: (e: PressEvent) => void;
|
||||||
|
onPressStart: (e: PressEvent) => void;
|
||||||
|
// TODO: add all props for <Button>
|
||||||
|
[index: string]: unknown;
|
||||||
|
}
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, Props>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
variant = "default",
|
||||||
|
size,
|
||||||
|
on,
|
||||||
|
off,
|
||||||
|
iconStyle,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
onPressStart,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const buttonRef = useObjectRef<HTMLButtonElement>(ref);
|
||||||
|
const { buttonProps } = useButton(
|
||||||
|
{ onPress, onPressStart, ...rest },
|
||||||
|
buttonRef
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: react-aria's useButton hook prevents form submission via keyboard
|
||||||
|
// Remove the hack below after this is merged https://github.com/adobe/react-spectrum/pull/904
|
||||||
|
let filteredButtonProps = buttonProps;
|
||||||
|
|
||||||
|
if (rest.type === "submit" && !rest.onPress) {
|
||||||
|
const { ...filtered } = buttonProps;
|
||||||
|
filteredButtonProps = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
variantToClassName[variant],
|
||||||
|
sizeToClassName[size],
|
||||||
|
styles[iconStyle],
|
||||||
|
className,
|
||||||
|
{
|
||||||
|
[styles.on]: on,
|
||||||
|
[styles.off]: off,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
{...mergeProps(rest, filteredButtonProps)}
|
||||||
|
ref={buttonRef}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
{variant === "dropdown" && <ArrowDownIcon />}
|
||||||
|
</>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export function MicButton({
|
||||||
|
muted,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
muted: boolean;
|
||||||
|
// TODO: add all props for <Button>
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger
|
||||||
|
tooltip={() => (muted ? "Unmute microphone" : "Mute microphone")}
|
||||||
|
>
|
||||||
|
<Button variant="toolbar" {...rest} off={muted}>
|
||||||
|
{muted ? <MuteMicIcon /> : <MicIcon />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoButton({
|
||||||
|
muted,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
muted: boolean;
|
||||||
|
// TODO: add all props for <Button>
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger
|
||||||
|
tooltip={() => (muted ? "Turn on camera" : "Turn off camera")}
|
||||||
|
>
|
||||||
|
<Button variant="toolbar" {...rest} off={muted}>
|
||||||
|
{muted ? <DisableVideoIcon /> : <VideoIcon />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenshareButton({
|
||||||
|
enabled,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
className?: string;
|
||||||
|
// TODO: add all props for <Button>
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger
|
||||||
|
tooltip={() => (enabled ? "Stop sharing screen" : "Share screen")}
|
||||||
|
>
|
||||||
|
<Button variant="toolbarSecondary" {...rest} on={enabled}>
|
||||||
|
<ScreenshareIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HangupButton({
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
// TODO: add all props for <Button>
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger tooltip={() => "Leave"}>
|
||||||
|
<Button
|
||||||
|
variant="toolbar"
|
||||||
|
className={classNames(styles.hangupButton, className)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<HangupIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsButton({
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
// TODO: add all props for <Button>
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger tooltip={() => "Settings"}>
|
||||||
|
<Button variant="toolbar" {...rest}>
|
||||||
|
<SettingsIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteButton({
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
// TODO: add all props for <Button>
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger tooltip={() => "Invite"}>
|
||||||
|
<Button variant="toolbar" {...rest}>
|
||||||
|
<AddUserIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioButtonProps extends Omit<Props, "variant"> {
|
||||||
|
/**
|
||||||
|
* A number between 0 and 1
|
||||||
|
*/
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioButton({ volume, ...rest }: AudioButtonProps) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger tooltip={() => "Local volume"}>
|
||||||
|
<Button variant="icon" {...rest}>
|
||||||
|
<VolumeIcon volume={volume} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FullscreenButtonProps extends Omit<Props, "variant"> {
|
||||||
|
fullscreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FullscreenButton({
|
||||||
|
fullscreen,
|
||||||
|
...rest
|
||||||
|
}: FullscreenButtonProps) {
|
||||||
|
const getTooltip = useCallback(() => {
|
||||||
|
return fullscreen ? "Exit full screen" : "Full screen";
|
||||||
|
}, [fullscreen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipTrigger tooltip={getTooltip}>
|
||||||
|
<Button variant="icon" {...rest}>
|
||||||
|
{fullscreen ? <FullscreenExit /> : <Fullscreen />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import useClipboard from "react-use-clipboard";
|
|
||||||
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
|
||||||
import { ReactComponent as CopyIcon } from "../icons/Copy.svg";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
|
|
||||||
export function CopyButton({
|
|
||||||
value,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
copiedMessage,
|
|
||||||
...rest
|
|
||||||
}) {
|
|
||||||
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
{...rest}
|
|
||||||
variant={variant === "icon" ? "iconCopy" : "copy"}
|
|
||||||
on={isCopied}
|
|
||||||
className={className}
|
|
||||||
onPress={setCopied}
|
|
||||||
iconStyle={isCopied ? "stroke" : "fill"}
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
{variant !== "icon" && <span>{copiedMessage || "Copied!"}</span>}
|
|
||||||
<CheckIcon />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{variant !== "icon" && <span>{children || value}</span>}
|
|
||||||
<CopyIcon />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
63
src/button/CopyButton.tsx
Normal file
63
src/button/CopyButton.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 React from "react";
|
||||||
|
import useClipboard from "react-use-clipboard";
|
||||||
|
|
||||||
|
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
||||||
|
import { ReactComponent as CopyIcon } from "../icons/Copy.svg";
|
||||||
|
import { Button, ButtonVariant } from "./Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
children?: JSX.Element | string;
|
||||||
|
className?: string;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
copiedMessage?: string;
|
||||||
|
}
|
||||||
|
export function CopyButton({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
copiedMessage,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
{...rest}
|
||||||
|
variant={variant === "icon" ? "iconCopy" : variant || "copy"}
|
||||||
|
on={isCopied}
|
||||||
|
className={className}
|
||||||
|
onPress={setCopied}
|
||||||
|
iconStyle={isCopied ? "stroke" : "fill"}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
{variant !== "icon" && <span>{copiedMessage || "Copied!"}</span>}
|
||||||
|
<CheckIcon />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{variant !== "icon" && <span>{children || value}</span>}
|
||||||
|
<CopyIcon />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { variantToClassName, sizeToClassName } from "./Button";
|
|
||||||
|
|
||||||
export function LinkButton({ className, variant, size, children, ...rest }) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className={classNames(
|
|
||||||
variantToClassName[variant || "secondary"],
|
|
||||||
sizeToClassName[size],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
58
src/button/LinkButton.tsx
Normal file
58
src/button/LinkButton.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 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 React, { HTMLAttributes } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import * as H from "history";
|
||||||
|
|
||||||
|
import {
|
||||||
|
variantToClassName,
|
||||||
|
sizeToClassName,
|
||||||
|
ButtonVariant,
|
||||||
|
ButtonSize,
|
||||||
|
} from "./Button";
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<HTMLAnchorElement> {
|
||||||
|
children: JSX.Element | string;
|
||||||
|
to: H.LocationDescriptor | ((location: H.Location) => H.LocationDescriptor);
|
||||||
|
size?: ButtonSize;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkButton({
|
||||||
|
children,
|
||||||
|
to,
|
||||||
|
size,
|
||||||
|
variant,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={classNames(
|
||||||
|
variantToClassName[variant || "secondary"],
|
||||||
|
sizeToClassName[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
to={to}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/button/VolumeIcon.tsx
Normal file
35
src/button/VolumeIcon.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 New Vector Ltd
|
||||||
|
|
||||||
|
|
||||||
|
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 React from "react";
|
||||||
|
|
||||||
|
import { ReactComponent as AudioMuted } from "../icons/AudioMuted.svg";
|
||||||
|
import { ReactComponent as AudioLow } from "../icons/AudioLow.svg";
|
||||||
|
import { ReactComponent as Audio } from "../icons/Audio.svg";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Number between 0 and 1
|
||||||
|
*/
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VolumeIcon({ volume }: Props) {
|
||||||
|
if (volume <= 0) return <AudioMuted />;
|
||||||
|
if (volume <= 0.5) return <AudioLow />;
|
||||||
|
return <Audio />;
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from "./Button";
|
|
||||||
export * from "./CopyButton";
|
|
||||||
export * from "./LinkButton";
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user