Compare commits
561 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8774ad682 | ||
|
|
30a54f3795 | ||
|
|
66b79f57bb | ||
|
|
61a24262de | ||
|
|
0955d7bcc3 | ||
|
|
36ce21d7ac | ||
|
|
eddc590235 | ||
|
|
61bc4dcc14 | ||
|
|
e2c4eae67b | ||
|
|
1da3fe0731 | ||
|
|
f562cc1e7f | ||
|
|
69b762b9ed | ||
|
|
ff55b1d189 | ||
|
|
d796ebe3fa | ||
|
|
b4bc41ba02 | ||
|
|
a072dfae9c | ||
|
|
0eba3ef75f | ||
|
|
2b9bf1fbe6 | ||
|
|
8769f8966d | ||
|
|
4e7b29e142 | ||
|
|
977ba92dba | ||
|
|
64e7047b12 | ||
|
|
ed99af0be6 | ||
|
|
52058716f6 | ||
|
|
29df87d22c | ||
|
|
6443e911dc | ||
|
|
aa6b7056ae | ||
|
|
c20737ba4c | ||
|
|
6f03653532 | ||
|
|
2ec0aaa0de | ||
|
|
9b4ad24f10 | ||
|
|
5069b008e2 | ||
|
|
6d8e45aea8 | ||
|
|
f0f9b929a1 | ||
|
|
9b5072cc57 | ||
|
|
b13fa85465 | ||
|
|
bf5128cfee | ||
|
|
f928e63c7b | ||
|
|
eef92249f7 | ||
|
|
04ad44f900 | ||
|
|
90072aa2bb | ||
|
|
ab42fe97cb | ||
|
|
f4cf3d8c62 | ||
|
|
1782a0eaf3 | ||
|
|
5bf46eb8f8 | ||
|
|
b4973bbc6b | ||
|
|
eaf3fb13c1 | ||
|
|
b503056673 | ||
|
|
86e3c346a4 | ||
|
|
7449e1f6e4 | ||
|
|
aadf6c05ac | ||
|
|
39ee8d838e | ||
|
|
1f10245adc | ||
|
|
c1de41106f | ||
|
|
e12bad952a | ||
|
|
7abb56e406 | ||
|
|
00d8100dfe | ||
|
|
eb051ab318 | ||
|
|
942e28f3c2 | ||
|
|
0bfec65405 | ||
|
|
f89342713a | ||
|
|
5a0b81b57f | ||
|
|
f9323d8b2c | ||
|
|
c68d536d80 | ||
|
|
fde7dbedaa | ||
|
|
7e240e96b7 | ||
|
|
f84800363f | ||
|
|
f9e12c8ff3 | ||
|
|
6abd1fbca1 | ||
|
|
599a4708cb | ||
|
|
f53ea75c94 | ||
|
|
2b67a9cfbe | ||
|
|
d582a7cc29 | ||
|
|
8757f07982 | ||
|
|
5b8910d265 | ||
|
|
a03ab6c9fa | ||
|
|
a3ce333352 | ||
|
|
d5faa5ea90 | ||
|
|
5becd2e175 | ||
|
|
3b38a5322c | ||
|
|
d062871f41 | ||
|
|
6b64bdfdb5 | ||
|
|
2de4705fa7 | ||
|
|
12e233970c | ||
|
|
10b915c707 | ||
|
|
5544695f21 | ||
|
|
72de8e066c | ||
|
|
63afda05bc | ||
|
|
b05c4234b7 | ||
|
|
80ddb7495d | ||
|
|
380f49fccc | ||
|
|
447bac3280 | ||
|
|
c74cebcc4b | ||
|
|
cd0aa0ced6 | ||
|
|
9cbd146e24 | ||
|
|
509bb4f1b0 | ||
|
|
3be3a32f3d | ||
|
|
17adfc5777 | ||
|
|
4eb1be678d | ||
|
|
b34e7d00e9 | ||
|
|
78f4c2a650 | ||
|
|
a3773c0a9a | ||
|
|
2b92ce8af2 | ||
|
|
5564e2fde6 | ||
|
|
35e2d2c432 | ||
|
|
ea2d98179c | ||
|
|
d83a104dda | ||
|
|
58f274eabf | ||
|
|
632ad07304 | ||
|
|
4173fd113b | ||
|
|
56b5f2845d | ||
|
|
afee9eaa26 | ||
|
|
364b78abda | ||
|
|
507b1fc52d | ||
|
|
6812c35a40 | ||
|
|
377b7ff5de | ||
|
|
4955535374 | ||
|
|
0664f978e3 | ||
|
|
bcc06d86ff | ||
|
|
7526826b0c | ||
|
|
b4e0df75c0 | ||
|
|
d561a41666 | ||
|
|
d53ad9a8f3 | ||
|
|
e04affe93e | ||
|
|
24870deead | ||
|
|
7fcd7125c1 | ||
|
|
1efa594430 | ||
|
|
caea4b250e | ||
|
|
0a8c6c1454 | ||
|
|
d4a2617f7b | ||
|
|
e05c6f1bdf | ||
|
|
2bc56dbff2 | ||
|
|
a59875dab5 | ||
|
|
8c21e8f277 | ||
|
|
d8634eed3d | ||
|
|
be4b70c1e1 | ||
|
|
e79cded57f | ||
|
|
2440037639 | ||
|
|
a16f235277 | ||
|
|
45c89a2298 | ||
|
|
7979493371 | ||
|
|
e0b10d89b5 | ||
|
|
183d2d9050 | ||
|
|
12b719da95 | ||
|
|
dfda7539d6 | ||
|
|
7f40ce8dde | ||
|
|
ec1b020d4e | ||
|
|
54c22f4ab2 | ||
|
|
ffbbc74a96 | ||
|
|
34c45cb5e2 | ||
|
|
af0bd795b5 | ||
|
|
0d485ef97f | ||
|
|
5647619b36 | ||
|
|
8a414012a0 | ||
|
|
e33fbd77d1 | ||
|
|
fdc6d4a1b6 | ||
|
|
a534356dd9 | ||
|
|
f847692953 | ||
|
|
486430d1f0 | ||
|
|
599d6fd007 | ||
|
|
14fc1481f3 | ||
|
|
e6ddf40b1b | ||
|
|
9f521a79f7 | ||
|
|
83784a717a | ||
|
|
0729deee79 | ||
|
|
77c3114cf8 | ||
|
|
82a56c8204 | ||
|
|
b39896d8c6 | ||
|
|
79b3fdb645 | ||
|
|
0f877cd021 | ||
|
|
db2acc75b2 | ||
|
|
a5dbfbf2c1 | ||
|
|
34c7d02de2 | ||
|
|
ca45067158 | ||
|
|
5a6eb7c573 | ||
|
|
41083c0f9e | ||
|
|
20602c122b | ||
|
|
5ad2a27a92 | ||
|
|
68daaa45f9 | ||
|
|
c40ea35937 | ||
|
|
d27f433175 | ||
|
|
8a6101cd14 | ||
|
|
4db7c2bc68 | ||
|
|
18740fc686 | ||
|
|
0c39398493 | ||
|
|
949145f04b | ||
|
|
8578dcadf2 | ||
|
|
959db44eca | ||
|
|
a031c0e128 | ||
|
|
591833505f | ||
|
|
f7ad5074d8 | ||
|
|
e0aef74bf5 | ||
|
|
b2378bf899 | ||
|
|
255f6b1814 | ||
|
|
4c491b5363 | ||
|
|
61c808d4cf | ||
|
|
13ef3183e2 | ||
|
|
afd4fdcea2 | ||
|
|
982181ccd4 | ||
|
|
30629ebba2 | ||
|
|
7f6a32d21a | ||
|
|
320ade0a50 | ||
|
|
8c6fee3150 | ||
|
|
5c6acaf915 | ||
|
|
c46549b2b6 | ||
|
|
97a58f6db7 | ||
|
|
b6288579c9 | ||
|
|
44bf987cdc | ||
|
|
a7d55824bb | ||
|
|
8fa038c61f | ||
|
|
869d9b43cb | ||
|
|
974a2fe49b | ||
|
|
022497c8e5 | ||
|
|
207a5f047c | ||
|
|
d3c63f9314 | ||
|
|
f9ef037cea | ||
|
|
8666ffec81 | ||
|
|
4b85879891 | ||
|
|
f376291f50 | ||
|
|
f0f56bf101 | ||
|
|
ba647780e8 | ||
|
|
812ae2ce89 | ||
|
|
09ca3b4dc0 | ||
|
|
86afde8612 | ||
|
|
469f0b5983 | ||
|
|
69d1beaf28 | ||
|
|
7f22f442b1 | ||
|
|
ab0f8fa2e3 | ||
|
|
f5abbb1e5e | ||
|
|
a80c96d187 | ||
|
|
fb0c8fb92b | ||
|
|
438a6c2a42 | ||
|
|
0c8cd0842a | ||
|
|
b39d35d5d1 | ||
|
|
6f00a961c9 | ||
|
|
0ed7853958 | ||
|
|
4acf279f32 | ||
|
|
a7c065f300 | ||
|
|
a0d248065d | ||
|
|
c18008e039 | ||
|
|
0bab898c25 | ||
|
|
53d1f717c9 | ||
|
|
0a69664186 | ||
|
|
d1307e61b9 | ||
|
|
a267eca78d | ||
|
|
51a8d2b718 | ||
|
|
2d76a3780d | ||
|
|
e70818b2da | ||
|
|
03144783ac | ||
|
|
b9ab2fdf1b | ||
|
|
183a1845bb | ||
|
|
e423fc0ace | ||
|
|
40cbe5408e | ||
|
|
2c3e95f401 | ||
|
|
1fdff9bbd4 | ||
|
|
bd7209cd40 | ||
|
|
70fdc68b13 | ||
|
|
f9bed2c2a9 | ||
|
|
33437d9743 | ||
|
|
1f4139ae0a | ||
|
|
b8ad8baf9d | ||
|
|
694048dd7f | ||
|
|
a5088d4ae9 | ||
|
|
d2bdaf7049 | ||
|
|
0aeb68b445 | ||
|
|
f40740edd3 | ||
|
|
cd47b63d29 | ||
|
|
07ce272e9f | ||
|
|
a3c4e3e2a5 | ||
|
|
6b6ad16306 | ||
|
|
0f38445fdd | ||
|
|
b9b53ec251 | ||
|
|
b1a5c8c120 | ||
|
|
b44680149d | ||
|
|
cd0cec32b5 | ||
|
|
2faa9c9d50 | ||
|
|
caab45ee7f | ||
|
|
d3687298e0 | ||
|
|
42612476b8 | ||
|
|
7021ea6a5c | ||
|
|
ff09631546 | ||
|
|
28a3dfef23 | ||
|
|
2b5561a88c | ||
|
|
9dc740c2de | ||
|
|
0b93374e86 | ||
|
|
d36af0cae6 | ||
|
|
7fae106da2 | ||
|
|
a84b692f20 | ||
|
|
86a5c24750 | ||
|
|
ed6f02ac56 | ||
|
|
f701886aa9 | ||
|
|
d298e3438c | ||
|
|
c405b61c66 | ||
|
|
6be67aa145 | ||
|
|
603c658949 | ||
|
|
ebc33e003d | ||
|
|
023ab9fc47 | ||
|
|
417faf795d | ||
|
|
c8fe393fcf | ||
|
|
e6a9555a91 | ||
|
|
fd65baed58 | ||
|
|
d079bee5e0 | ||
|
|
1918478069 | ||
|
|
c6d8d5e137 | ||
|
|
7a4583dcb0 | ||
|
|
929175d826 | ||
|
|
49ce642c2d | ||
|
|
ce14c0f6fe | ||
|
|
1a10b67248 | ||
|
|
eda5f14a19 | ||
|
|
e6683569f8 | ||
|
|
90273c1924 | ||
|
|
10f49d0d84 | ||
|
|
418ee89e0f | ||
|
|
a4a57e5307 | ||
|
|
477eb0034a | ||
|
|
5ec57f04c6 | ||
|
|
69cb17adc8 | ||
|
|
8718a7139b | ||
|
|
94f267b93e | ||
|
|
2151696374 | ||
|
|
2679948dbe | ||
|
|
86e84028e1 | ||
|
|
dea6f05b51 | ||
|
|
ac450443a0 | ||
|
|
2a0375d93f | ||
|
|
56312b2753 | ||
|
|
137867b096 | ||
|
|
0ae62b121e | ||
|
|
70682a7490 | ||
|
|
7fc0f96ca6 | ||
|
|
f50b00e00f | ||
|
|
e9c98a02f0 | ||
|
|
dcb4d10afb | ||
|
|
705f9daf5f | ||
|
|
c5e60744a2 | ||
|
|
3670c36fac | ||
|
|
b5d25f1f2d | ||
|
|
51926cad3d | ||
|
|
2ab909fab1 | ||
|
|
cfe0b4d8e4 | ||
|
|
83795ae4bf | ||
|
|
8a4d4d3144 | ||
|
|
0e7fd791b5 | ||
|
|
96e6ca0c0e | ||
|
|
2e0ad5ca69 | ||
|
|
43f7f9b76c | ||
|
|
224cd53481 | ||
|
|
a5f7921a32 | ||
|
|
b8c8e36449 | ||
|
|
f07a491bd9 | ||
|
|
7fb0eb150d | ||
|
|
6e0a20a213 | ||
|
|
af99d2a60b | ||
|
|
b32d066a76 | ||
|
|
a0f9c55194 | ||
|
|
bb9cbe26b3 | ||
|
|
5284479ece | ||
|
|
be25d77e8b | ||
|
|
46732cf86b | ||
|
|
42d697068d | ||
|
|
96a1c4bf7a | ||
|
|
85c140bc32 | ||
|
|
affac2da40 | ||
|
|
bfb26ca500 | ||
|
|
257211e8f9 | ||
|
|
d352fefcaa | ||
|
|
8234211f03 | ||
|
|
6d0de07f07 | ||
|
|
1ed1b7c60c | ||
|
|
e8810882ef | ||
|
|
244387dc04 | ||
|
|
bfd3a61aef | ||
|
|
c5793b9e7c | ||
|
|
4283716dcc | ||
|
|
4288037f51 | ||
|
|
c52d5dc573 | ||
|
|
379050ff80 | ||
|
|
df4da4e4ec | ||
|
|
99d6ced566 | ||
|
|
46cfa65bc2 | ||
|
|
067c13ebf7 | ||
|
|
9f4a107865 | ||
|
|
5e2b652690 | ||
|
|
fc4ced7bb3 | ||
|
|
1381640cdb | ||
|
|
d56dcaf0eb | ||
|
|
1d43bd26ed | ||
|
|
51cc406af7 | ||
|
|
03d2818c1f | ||
|
|
e1c71327c5 | ||
|
|
1baed1184a | ||
|
|
c6fc0bb798 | ||
|
|
d5ccfa2ac5 | ||
|
|
7793805f1b | ||
|
|
c67040afe7 | ||
|
|
444297f3d9 | ||
|
|
afb43d049d | ||
|
|
93c2ae768e | ||
|
|
2bbd882425 | ||
|
|
e781dfa164 | ||
|
|
fc65c799d8 | ||
|
|
d9d218cb1f | ||
|
|
9946888d63 | ||
|
|
202388bd79 | ||
|
|
1df5ee05c9 | ||
|
|
ee2214c14d | ||
|
|
a2066f300b | ||
|
|
a0938bad13 | ||
|
|
9675df8434 | ||
|
|
b9a6ee70a8 | ||
|
|
5c399fbfb2 | ||
|
|
a5231983e8 | ||
|
|
76d3117a1c | ||
|
|
6bcfe61a79 | ||
|
|
9fe7f62a63 | ||
|
|
7455104aaf | ||
|
|
96bf809e8a | ||
|
|
b5a7c55d70 | ||
|
|
c578bcaf91 | ||
|
|
908ca2325a | ||
|
|
a1659c1fe2 | ||
|
|
3d7ef061cd | ||
|
|
a04500f102 | ||
|
|
b8aaa6b079 | ||
|
|
89911c1747 | ||
|
|
f7e63bd2f5 | ||
|
|
76bb72d3ea | ||
|
|
fd8ee1542b | ||
|
|
ef395efaf8 | ||
|
|
8da2f5c1c7 | ||
|
|
0bfe12bcbc | ||
|
|
63023f542b | ||
|
|
74ddcee2af | ||
|
|
9cce4bc0ad | ||
|
|
be44aa0157 | ||
|
|
e74095be54 | ||
|
|
3458ff9716 | ||
|
|
dcc04bb10f | ||
|
|
36d754f27f | ||
|
|
1daca7a0d9 | ||
|
|
884491d60e | ||
|
|
9c00d74924 | ||
|
|
f32934d7e4 | ||
|
|
4b1505abb7 | ||
|
|
21b4d46f83 | ||
|
|
cb113cbfe4 | ||
|
|
fd6032b44f | ||
|
|
229e5f3ce7 | ||
|
|
4271685c66 | ||
|
|
c59610a5c1 | ||
|
|
94ef74b8bf | ||
|
|
ea014e1b0c | ||
|
|
922bb00722 | ||
|
|
c412e2550d | ||
|
|
5bad53c63e | ||
|
|
4cc077a72a | ||
|
|
708cb18332 | ||
|
|
3c3150a6b6 | ||
|
|
fc525a6e45 | ||
|
|
545034d26f | ||
|
|
5f7410c809 | ||
|
|
db24e544bd | ||
|
|
71e3fefc00 | ||
|
|
1aaa161522 | ||
|
|
0b2c78d563 | ||
|
|
876c904eb0 | ||
|
|
ffd0832b31 | ||
|
|
907e7bdf52 | ||
|
|
0d997254d0 | ||
|
|
e40efd57c8 | ||
|
|
096ddce173 | ||
|
|
cc279d849f | ||
|
|
dd81ff68dd | ||
|
|
750e35015e | ||
|
|
97693639dd | ||
|
|
a88458aebf | ||
|
|
beb408aa04 | ||
|
|
a15ded502f | ||
|
|
5963bdae6e | ||
|
|
6c5e73513c | ||
|
|
d35a070844 | ||
|
|
c2ff3dc8b7 | ||
|
|
63e3a98c02 | ||
|
|
a7053ac595 | ||
|
|
75d529e66c | ||
|
|
fe7bb76f01 | ||
|
|
d78b894a6b | ||
|
|
5121b956ca | ||
|
|
255b3218a5 | ||
|
|
1614b2e8bd | ||
|
|
3f5c7257b3 | ||
|
|
ccea9fc961 | ||
|
|
5ebaad014c | ||
|
|
aa14051755 | ||
|
|
e9e79f8ec3 | ||
|
|
88471f1462 | ||
|
|
9b8088fb43 | ||
|
|
8e950e7c0a | ||
|
|
8de238dfc6 | ||
|
|
2f37787ea3 | ||
|
|
7cf50101b3 | ||
|
|
518f8c8783 | ||
|
|
f6094378fe | ||
|
|
44a8dffd08 | ||
|
|
9d2711a736 | ||
|
|
1a7048f2b8 | ||
|
|
16bc438ee3 | ||
|
|
5d670d4bfb | ||
|
|
ca761b9176 | ||
|
|
d5de8cac2d | ||
|
|
1c47a83c04 | ||
|
|
318702ad13 | ||
|
|
bf8a2bba45 | ||
|
|
592386daae | ||
|
|
e2bd381f65 | ||
|
|
28fcdaf6cb | ||
|
|
88a67b8fc8 | ||
|
|
09eb666daf | ||
|
|
c8ebd129ed | ||
|
|
1aede1e24e | ||
|
|
c932dd8c9b | ||
|
|
4a9a58475f | ||
|
|
b503260caa | ||
|
|
7883db3434 | ||
|
|
3310484859 | ||
|
|
e6aa60edd0 | ||
|
|
5a3d625333 | ||
|
|
163586a80e | ||
|
|
6d5887b33e | ||
|
|
8f2371757e | ||
|
|
478745a38d | ||
|
|
d79359ae66 | ||
|
|
f2b5ba24de | ||
|
|
33ec5b1451 | ||
|
|
0c87fd3b75 | ||
|
|
b1586e07a3 | ||
|
|
b254b38427 | ||
|
|
c3ed8bf139 | ||
|
|
661e11a2a4 | ||
|
|
134a356ba2 | ||
|
|
be3b1c40d0 | ||
|
|
c09d1ecbff | ||
|
|
186be88c24 | ||
|
|
f8fa8e3722 | ||
|
|
58b97d8c04 | ||
|
|
419bf22f2a | ||
|
|
322bbe38f8 | ||
|
|
5bd1832770 | ||
|
|
13fa572f26 | ||
|
|
bddf6fb856 | ||
|
|
ecc7f01933 | ||
|
|
48aef98440 | ||
|
|
46a12c7476 | ||
|
|
f7681a60ab | ||
|
|
19f56f27a7 | ||
|
|
1d128bcfe0 | ||
|
|
57548b5d43 | ||
|
|
9b83cebcad | ||
|
|
d543c1a9c0 | ||
|
|
6cfc2066c9 |
@@ -38,15 +38,6 @@ module.exports = {
|
|||||||
"jsx-a11y/media-has-caption": "off",
|
"jsx-a11y/media-has-caption": "off",
|
||||||
// We should use the js-sdk logger, never console directly.
|
// We should use the js-sdk logger, never console directly.
|
||||||
"no-console": ["error"],
|
"no-console": ["error"],
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
name: "@react-rxjs/core",
|
|
||||||
importNames: ["Subscribe", "RemoveSubscribe"],
|
|
||||||
message:
|
|
||||||
"These components are easy to misuse, please use the 'subscribe' component wrapper instead",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"react/display-name": "error",
|
"react/display-name": "error",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
49
.github/workflows/build.yaml
vendored
49
.github/workflows/build.yaml
vendored
@@ -1,34 +1,25 @@
|
|||||||
name: Build
|
name: Build
|
||||||
on:
|
on:
|
||||||
pull_request: {}
|
pull_request:
|
||||||
|
types:
|
||||||
|
- synchronize
|
||||||
|
- opened
|
||||||
|
- labeled
|
||||||
|
paths-ignore:
|
||||||
|
- ".github/**"
|
||||||
|
- "docs/**"
|
||||||
push:
|
push:
|
||||||
branches: [livekit, full-mesh]
|
branches: [livekit, full-mesh]
|
||||||
|
paths-ignore:
|
||||||
|
- ".github/**"
|
||||||
|
- "docs/**"
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build_element_call:
|
||||||
name: Build
|
uses: ./.github/workflows/element-call.yaml
|
||||||
runs-on: ubuntu-latest
|
with:
|
||||||
steps:
|
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||||
- name: Checkout code
|
secrets:
|
||||||
uses: actions/checkout@v4
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
- name: Yarn cache
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
uses: actions/setup-node@v4
|
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||||
with:
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
cache: "yarn"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: "yarn install"
|
|
||||||
- name: Build
|
|
||||||
run: "yarn run build"
|
|
||||||
env:
|
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
|
||||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
|
||||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
|
||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
|
||||||
- name: Upload Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: build
|
|
||||||
path: dist
|
|
||||||
# We'll only use this in a triggered job, then we're done with it
|
|
||||||
retention-days: 1
|
|
||||||
|
|||||||
60
.github/workflows/docker.yaml
vendored
Normal file
60
.github/workflows/docker.yaml
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Docker - Deploy
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
docker_tags:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
artifact_run_id:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ${{ github.run_id }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_deploy:
|
||||||
|
name: Build & publish docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # required to upload release asset
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Check it out
|
||||||
|
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||||
|
|
||||||
|
- name: 📥 Download artifact
|
||||||
|
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run-id: ${{ inputs.artifact_run_id }}
|
||||||
|
name: build-output
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Log in to container registry
|
||||||
|
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: ${{ inputs.docker_tags}}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out test private repo
|
- name: Check out test private repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||||
with:
|
with:
|
||||||
repository: element-hq/static-call-participant
|
repository: element-hq/static-call-participant
|
||||||
ref: refs/heads/main
|
ref: refs/heads/main
|
||||||
|
|||||||
47
.github/workflows/element-call.yaml
vendored
Normal file
47
.github/workflows/element-call.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: Element Call - Build
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
vite_app_version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
SENTRY_ORG:
|
||||||
|
required: true
|
||||||
|
SENTRY_PROJECT:
|
||||||
|
required: true
|
||||||
|
SENTRY_URL:
|
||||||
|
required: true
|
||||||
|
SENTRY_AUTH_TOKEN:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Element Call
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||||
|
- name: Yarn cache
|
||||||
|
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||||
|
with:
|
||||||
|
cache: "yarn"
|
||||||
|
node-version: "lts/*"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: "yarn install"
|
||||||
|
- name: Build
|
||||||
|
run: "yarn run build"
|
||||||
|
env:
|
||||||
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
|
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4
|
||||||
|
with:
|
||||||
|
name: build-output
|
||||||
|
path: dist
|
||||||
|
# We'll only use this in a triggered job, then we're done with it
|
||||||
|
retention-days: 1
|
||||||
5
.github/workflows/lint.yaml
vendored
5
.github/workflows/lint.yaml
vendored
@@ -7,11 +7,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||||
- name: Yarn cache
|
- name: Yarn cache
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
|
node-version: "lts/*"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: "yarn install"
|
run: "yarn install"
|
||||||
- name: Prettier
|
- name: Prettier
|
||||||
|
|||||||
@@ -1,45 +1,56 @@
|
|||||||
name: Netlify PR Preview
|
name: Netlify - Deploy
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_call:
|
||||||
workflows: ["Build"]
|
inputs:
|
||||||
types:
|
pr_number:
|
||||||
- completed
|
required: true
|
||||||
branches-ignore:
|
type: string
|
||||||
- "main"
|
pr_head_full_name:
|
||||||
- "livekit"
|
required: true
|
||||||
|
type: string
|
||||||
|
pr_head_ref:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
deployment_ref:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
artifact_run_id:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ${{ github.run_id }}
|
||||||
|
secrets:
|
||||||
|
ELEMENT_BOT_TOKEN:
|
||||||
|
required: true
|
||||||
|
NETLIFY_AUTH_TOKEN:
|
||||||
|
required: true
|
||||||
|
NETLIFY_SITE_ID:
|
||||||
|
required: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
deployments: write
|
deployments: write
|
||||||
environment: Netlify
|
environment: Netlify
|
||||||
steps:
|
steps:
|
||||||
- name: 📝 Create Deployment
|
- name: 📝 Create Deployment
|
||||||
uses: bobheadxi/deployments@v1
|
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
|
||||||
id: deployment
|
id: deployment
|
||||||
with:
|
with:
|
||||||
step: start
|
step: start
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
env: Netlify
|
env: Netlify
|
||||||
ref: ${{ github.event.workflow_run.head_sha }}
|
ref: ${{ inputs.deployment_ref }}
|
||||||
desc: |
|
desc: |
|
||||||
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
|
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
|
||||||
Exercise caution. Use test accounts.
|
Exercise caution. Use test accounts.
|
||||||
|
|
||||||
- id: prdetails
|
|
||||||
uses: matrix-org/pr-details-action@v1.3
|
|
||||||
with:
|
|
||||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
|
||||||
branch: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
|
|
||||||
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
|
|
||||||
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
|
|
||||||
- name: 📥 Download artifact
|
- name: 📥 Download artifact
|
||||||
uses: dawidd6/action-download-artifact@v3
|
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||||
with:
|
with:
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
name: build
|
run-id: ${{ inputs.artifact_run_id }}
|
||||||
|
name: build-output
|
||||||
path: webapp
|
path: webapp
|
||||||
|
|
||||||
- name: Add redirects file
|
- name: Add redirects file
|
||||||
@@ -47,25 +58,22 @@ jobs:
|
|||||||
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
|
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
|
||||||
|
|
||||||
- name: Add config file
|
- name: Add config file
|
||||||
run: curl -s "https://raw.githubusercontent.com/${{ github.event.workflow_run.head_repository.full_name }}/${{ github.event.workflow_run.head_branch }}/config/element_io_preview.json" > webapp/config.json
|
run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json
|
||||||
|
|
||||||
- name: ☁️ Deploy to Netlify
|
- name: ☁️ Deploy to Netlify
|
||||||
id: netlify
|
id: netlify
|
||||||
uses: nwtgck/actions-netlify@v2.1
|
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
|
||||||
with:
|
with:
|
||||||
publish-dir: webapp
|
publish-dir: webapp
|
||||||
deploy-message: "Deploy from GitHub Actions"
|
deploy-message: "Deploy from GitHub Actions"
|
||||||
# These don't work because we're in workflow_run
|
alias: pr${{ inputs.pr_number }}
|
||||||
enable-pull-request-comment: false
|
|
||||||
enable-commit-comment: false
|
|
||||||
alias: pr${{ steps.prdetails.outputs.pr_id }}
|
|
||||||
env:
|
env:
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
timeout-minutes: 1
|
timeout-minutes: 1
|
||||||
|
|
||||||
- name: 🚦 Update deployment status
|
- name: 🚦 Update deployment status
|
||||||
uses: bobheadxi/deployments@v1
|
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
step: finish
|
step: finish
|
||||||
50
.github/workflows/pr-deploy.yaml
vendored
Normal file
50
.github/workflows/pr-deploy.yaml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: PR Preview Deployments
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prdetails:
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
pr_number: ${{ steps.prdetails.outputs.pr_id }}
|
||||||
|
pr_data_json: ${{ steps.prdetails.outputs.data }}
|
||||||
|
steps:
|
||||||
|
- id: prdetails
|
||||||
|
uses: matrix-org/pr-details-action@15bde5285d7850ba276cc3bd8a03733e3f24622a # v1.3
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||||
|
branch: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
|
||||||
|
netlify:
|
||||||
|
needs: prdetails
|
||||||
|
permissions:
|
||||||
|
deployments: write
|
||||||
|
uses: ./.github/workflows/netlify.yaml
|
||||||
|
with:
|
||||||
|
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||||
|
pr_number: ${{ needs.prdetails.outputs.pr_number }}
|
||||||
|
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||||
|
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
||||||
|
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
||||||
|
secrets:
|
||||||
|
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|
||||||
|
docker:
|
||||||
|
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'docker build') }}
|
||||||
|
needs: prdetails
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
uses: ./.github/workflows/docker.yaml
|
||||||
|
with:
|
||||||
|
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||||
|
docker_tags: |
|
||||||
|
type=sha,format=short,event=branch
|
||||||
|
type=raw,value=pr_${{ needs.prdetails.outputs.pr_number }}
|
||||||
94
.github/workflows/publish.yaml
vendored
94
.github/workflows/publish.yaml
vendored
@@ -3,17 +3,34 @@ name: Build & publish images to the package registry for tags
|
|||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: ["Build"]
|
||||||
branches: [livekit]
|
branches: [livekit]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build_element_call:
|
||||||
name: Build & publish
|
if: ${{ github.event_name == 'release' }}
|
||||||
|
uses: ./.github/workflows/element-call.yaml
|
||||||
|
with:
|
||||||
|
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||||
|
secrets:
|
||||||
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
|
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
publish_tarball:
|
||||||
|
needs: build_element_call
|
||||||
|
if: always()
|
||||||
|
name: Publish tarball
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
unix_time: ${{steps.current-time.outputs.unix_time}}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # required to upload release asset
|
contents: write # required to upload release asset
|
||||||
packages: write
|
packages: write
|
||||||
@@ -21,64 +38,35 @@ jobs:
|
|||||||
- name: Get current time
|
- name: Get current time
|
||||||
id: current-time
|
id: current-time
|
||||||
run: echo "unix_time=$(date +'%s')" >> $GITHUB_OUTPUT
|
run: echo "unix_time=$(date +'%s')" >> $GITHUB_OUTPUT
|
||||||
|
- name: 📥 Download artifact
|
||||||
- name: Check it out
|
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to container registry
|
|
||||||
uses: docker/login-action@5139682d94efc37792e6b54386b5b470a68a4737
|
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
username: ${{ github.actor }}
|
run-id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
name: build-output
|
||||||
|
path: dist
|
||||||
- name: Yarn cache
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
cache: "yarn"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: "yarn install"
|
|
||||||
- name: Build
|
|
||||||
run: "yarn run build"
|
|
||||||
env:
|
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
|
||||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
|
||||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
VITE_APP_VERSION: ${{ github.event.release.tag_name || github.sha }}
|
|
||||||
|
|
||||||
- name: Create Tarball
|
- name: Create Tarball
|
||||||
env:
|
env:
|
||||||
TARBALL_VERSION: ${{ github.event.release.tag_name || github.sha }}
|
TARBALL_VERSION: ${{ github.event.release.tag_name || github.sha }}
|
||||||
run: |
|
run: |
|
||||||
tar --numeric-owner --transform "s/dist/element-call-${TARBALL_VERSION}/" -cvzf element-call-${TARBALL_VERSION}.tar.gz dist
|
tar --numeric-owner --transform "s/dist/element-call-${TARBALL_VERSION}/" -cvzf element-call-${TARBALL_VERSION}.tar.gz dist
|
||||||
|
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: actions/upload-artifact@ef09cdac3e2d3e60d8ccadda691f4f1cec5035cb
|
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
with:
|
with:
|
||||||
path: "./element-call-*.tar.gz"
|
path: "./element-call-*.tar.gz"
|
||||||
|
publish_docker:
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
needs: publish_tarball
|
||||||
id: meta
|
if: always()
|
||||||
uses: docker/metadata-action@232fc64e3a4e54539e087c5976439ea54be0959d
|
permissions:
|
||||||
with:
|
contents: write
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
packages: write
|
||||||
tags: |
|
uses: ./.github/workflows/docker.yaml
|
||||||
type=sha,format=short,event=branch
|
with:
|
||||||
type=semver,pattern=v{{version}}
|
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||||
type=raw,value=latest-ci,enable={{is_default_branch}}
|
docker_tags: |
|
||||||
type=raw,value=latest-ci_${{steps.current-time.outputs.unix_time}},enable={{is_default_branch}}
|
type=sha,format=short,event=branch
|
||||||
|
type=semver,pattern=v{{version}}
|
||||||
- name: Set up Docker Buildx
|
type=raw,value=latest-ci,enable={{is_default_branch}}
|
||||||
uses: docker/setup-buildx-action@0d103c3126aa41d772a8362f6aa67afac040f80c
|
type=raw,value=latest-ci_${{needs.publish_tarball.outputs.unix_time}},enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@b3eddbb94c4146a0988a620b01720afe50639271
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
|
|||||||
7
.github/workflows/test.yaml
vendored
7
.github/workflows/test.yaml
vendored
@@ -9,16 +9,17 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||||
- name: Yarn cache
|
- name: Yarn cache
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
|
node-version: "lts/*"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: "yarn install"
|
run: "yarn install"
|
||||||
- name: Vitest
|
- name: Vitest
|
||||||
run: "yarn run test"
|
run: "yarn run test"
|
||||||
- name: Upload to codecov
|
- name: Upload to codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4
|
||||||
with:
|
with:
|
||||||
flags: unittests
|
flags: unittests
|
||||||
|
|||||||
9
.github/workflows/translations-download.yaml
vendored
9
.github/workflows/translations-download.yaml
vendored
@@ -13,11 +13,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the code
|
- name: Checkout the code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
|
node-version: "lts/*"
|
||||||
|
|
||||||
- name: Install Deps
|
- name: Install Deps
|
||||||
run: "yarn install --frozen-lockfile"
|
run: "yarn install --frozen-lockfile"
|
||||||
@@ -26,7 +27,7 @@ jobs:
|
|||||||
run: "rm -R public/locales"
|
run: "rm -R public/locales"
|
||||||
|
|
||||||
- name: Download translation files
|
- name: Download translation files
|
||||||
uses: localazy/download@v1.1.0
|
uses: localazy/download@0a79880fb66150601e3b43606fab69c88123c087 # v1.1.0
|
||||||
with:
|
with:
|
||||||
groups: "-p includeSourceLang:true"
|
groups: "-p includeSourceLang:true"
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
id: cpr
|
id: cpr
|
||||||
uses: peter-evans/create-pull-request@v6.0.0
|
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
branch: actions/localazy-download
|
branch: actions/localazy-download
|
||||||
|
|||||||
6
.github/workflows/translations-upload.yaml
vendored
6
.github/workflows/translations-upload.yaml
vendored
@@ -3,6 +3,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- livekit
|
- livekit
|
||||||
|
paths-ignore:
|
||||||
|
- ".github/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
upload:
|
upload:
|
||||||
@@ -12,9 +14,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the code
|
- name: Checkout the code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||||
|
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: localazy/upload@v1
|
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||||
with:
|
with:
|
||||||
write_key: ${{ secrets.LOCALAZY_WRITE_KEY }}
|
write_key: ${{ secrets.LOCALAZY_WRITE_KEY }}
|
||||||
|
|||||||
62
README.md
62
README.md
@@ -22,7 +22,7 @@ yarn
|
|||||||
yarn build
|
yarn build
|
||||||
```
|
```
|
||||||
|
|
||||||
If all went well, you can now find the build output under `dist` as a series of static files. These can be hosted using any web server of your choice.
|
If all went well, you can now find the build output under `dist` as a series of static files. These can be hosted using any web server that can be configured with custom routes (see below).
|
||||||
|
|
||||||
You may also wish to add a configuration file (Element Call uses the domain it's hosted on as a Homeserver URL by default,
|
You may also wish to add a configuration file (Element Call uses the domain it's hosted on as a Homeserver URL by default,
|
||||||
but you can change this in the config file). This goes in `public/config.json` - you can use the sample as a starting point:
|
but you can change this in the config file). This goes in `public/config.json` - you can use the sample as a starting point:
|
||||||
@@ -54,6 +54,38 @@ Therefore, to use a self-hosted homeserver, this is recommended to be a new serv
|
|||||||
|
|
||||||
There are currently two different config files. `.env` holds variables that are used at build time, while `public/config.json` holds variables that are used at runtime. Documentation and default values for `public/config.json` can be found in [ConfigOptions.ts](src/config/ConfigOptions.ts).
|
There are currently two different config files. `.env` holds variables that are used at build time, while `public/config.json` holds variables that are used at runtime. Documentation and default values for `public/config.json` can be found in [ConfigOptions.ts](src/config/ConfigOptions.ts).
|
||||||
|
|
||||||
|
If you're using [Synapse](https://github.com/element-hq/synapse/), you'll need to additionally add the following to `homeserver.yaml` or Element Call won't work:
|
||||||
|
|
||||||
|
```
|
||||||
|
experimental_features:
|
||||||
|
msc3266_enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
MSC3266 allows to request a room summary of rooms you are not joined.
|
||||||
|
The summary contains the room join rules. We need that to decide if the user gets prompted with the option to knock ("ask to join"), a cannot join error or the join view.
|
||||||
|
|
||||||
|
Element Call requires a Livekit SFU behind a Livekit jwt service to work. The url to the Livekit jwt service can either be configured in the config of Element Call (fallback/legacy configuration) or be configured by your homeserver via the `.well-known`.
|
||||||
|
This is the recommended method.
|
||||||
|
|
||||||
|
The configuration is a list of Foci configs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"org.matrix.msc4143.rtc_foci": [
|
||||||
|
{
|
||||||
|
"type": "livekit",
|
||||||
|
"livekit_service_url": "https://someurl.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "livekit",
|
||||||
|
"livekit_service_url": "https://livekit2.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "another_foci",
|
||||||
|
"props_for_another_foci": "val"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
If you'd like to help translate Element Call, head over to [Localazy](https://localazy.com/p/element-call). You're also encouraged to join the [Element Translators](https://matrix.to/#/#translators:element.io) space to discuss and coordinate translation efforts.
|
If you'd like to help translate Element Call, head over to [Localazy](https://localazy.com/p/element-call). You're also encouraged to join the [Element Translators](https://matrix.to/#/#translators:element.io) space to discuss and coordinate translation efforts.
|
||||||
@@ -93,11 +125,13 @@ service for development. These use a test 'secret' published in this
|
|||||||
repository, so this must be used only for local development and
|
repository, so this must be used only for local development and
|
||||||
**_never be exposed to the public Internet._**
|
**_never be exposed to the public Internet._**
|
||||||
|
|
||||||
To use it, add SFU parameter in your local config `./public/config.json`:
|
To use it, add a SFU parameter in your local config `./public/config.json`:
|
||||||
|
(Be aware, that this is only the fallback Livekit SFU. If the homeserver
|
||||||
|
advertises one in the client well-known, this will not be used.)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"livekit_service_url": "http://localhost:8881"
|
"livekit_service_url": "http://localhost:7881"
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -107,6 +141,28 @@ Run backend components:
|
|||||||
yarn backend
|
yarn backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Add a new translation key
|
||||||
|
|
||||||
|
To add a new translation key you can do these steps:
|
||||||
|
|
||||||
|
1. Add the new key entry to the code where the new key is used: `t("some_new_key")`
|
||||||
|
1. Run `yarn i18n` to extract the new key and update the translation files. This will add a skeleton entry to the `public/locales/en-GB/app.json` file:
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
...
|
||||||
|
"some_new_key": "",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
1. Update the skeleton entry in the `public/locales/en-GB/app.json` file with the English translation:
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
...
|
||||||
|
"some_new_key": "Some new key",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Usage and other technical details about the project can be found here:
|
Usage and other technical details about the project can be found here:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
port: 7880
|
port: 7880
|
||||||
environment: dev
|
|
||||||
bind_addresses:
|
bind_addresses:
|
||||||
- "0.0.0.0"
|
- "0.0.0.0"
|
||||||
rtc:
|
rtc:
|
||||||
@@ -22,5 +21,3 @@ turn:
|
|||||||
external_tls: true
|
external_tls: true
|
||||||
keys:
|
keys:
|
||||||
devkey: secret
|
devkey: secret
|
||||||
signal_relay:
|
|
||||||
enabled: true
|
|
||||||
|
|||||||
@@ -5,5 +5,11 @@
|
|||||||
"server_name": "call.ems.host"
|
"server_name": "call.ems.host"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"livekit": {
|
||||||
|
"livekit_service_url": "http://localhost:7881"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"feature_use_device_session_member_events": true
|
||||||
|
},
|
||||||
"eula": "https://static.element.io/legal/online-EULA.pdf"
|
"eula": "https://static.element.io/legal/online-EULA.pdf"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"livekit": {
|
"livekit": {
|
||||||
"livekit_service_url": "https://livekit-jwt.call.element.dev"
|
"livekit_service_url": "https://livekit-jwt.call.element.dev"
|
||||||
},
|
},
|
||||||
|
"features": {
|
||||||
|
"feature_use_device_session_member_events": true
|
||||||
|
},
|
||||||
"posthog": {
|
"posthog": {
|
||||||
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
|
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
|
||||||
"api_host": "https://posthog-element-call.element.io"
|
"api_host": "https://posthog-element-call.element.io"
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"default_server_config": {
|
|
||||||
"m.homeserver": {
|
|
||||||
"base_url": "https://call.ems.host",
|
|
||||||
"server_name": "call.ems.host"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"posthog": {
|
|
||||||
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
|
|
||||||
"api_host": "https://posthog-element-call.element.io"
|
|
||||||
},
|
|
||||||
"sentry": {
|
|
||||||
"environment": "main-branch-cd",
|
|
||||||
"DSN": "https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41"
|
|
||||||
},
|
|
||||||
"rageshake": {
|
|
||||||
"submit_url": "https://element.io/bugreports/submit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
30
config/httpd.conf
Normal file
30
config/httpd.conf
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<VirtualHost *:8080>
|
||||||
|
ServerName localhost
|
||||||
|
|
||||||
|
DocumentRoot "/app"
|
||||||
|
|
||||||
|
<Location "/">
|
||||||
|
# disable cache entriely by default (apart from Etag which is accurate enough)
|
||||||
|
Header add Cache-Control "private no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"
|
||||||
|
CacheDisable on
|
||||||
|
ExpiresActive off
|
||||||
|
|
||||||
|
# also turn off last-modified since they are just the timestamps of the file in the docker image
|
||||||
|
# and may or may not bear any resemblance to when the resource changed
|
||||||
|
Header add Last-Modified ""
|
||||||
|
|
||||||
|
DirectoryIndex index.html
|
||||||
|
</Location>
|
||||||
|
|
||||||
|
# assets can be cached because they have hashed filenames
|
||||||
|
<Location "/assets">
|
||||||
|
ExpiresActive on
|
||||||
|
ExpiresDefault "access plus 1 week"
|
||||||
|
Header add Cache-Control "public, no-transform"
|
||||||
|
</Location>
|
||||||
|
|
||||||
|
<Location "/apple-app-site-association">
|
||||||
|
ForceType application/json
|
||||||
|
</Location>
|
||||||
|
</VirtualHost>
|
||||||
|
|
||||||
42
package.json
42
package.json
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"name": "element-call",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -19,15 +20,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@juggle/resize-observer": "^3.3.1",
|
"@juggle/resize-observer": "^3.3.1",
|
||||||
"@livekit/components-core": "^0.9.0",
|
"@livekit/components-core": "^0.11.0",
|
||||||
"@livekit/components-react": "^2.0.0",
|
"@livekit/components-react": "^2.0.0",
|
||||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
|
|
||||||
"@opentelemetry/api": "^1.4.0",
|
"@opentelemetry/api": "^1.4.0",
|
||||||
"@opentelemetry/context-zone": "^1.9.1",
|
"@opentelemetry/context-zone": "^1.9.1",
|
||||||
"@opentelemetry/exporter-jaeger": "^1.9.1",
|
"@opentelemetry/exporter-jaeger": "^1.9.1",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.48.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
|
||||||
"@opentelemetry/instrumentation-document-load": "^0.35.0",
|
"@opentelemetry/instrumentation-document-load": "^0.39.0",
|
||||||
"@opentelemetry/instrumentation-user-interaction": "^0.35.0",
|
"@opentelemetry/instrumentation-user-interaction": "^0.39.0",
|
||||||
"@opentelemetry/sdk-trace-web": "^1.9.1",
|
"@opentelemetry/sdk-trace-web": "^1.9.1",
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
"@radix-ui/react-slider": "^1.1.2",
|
||||||
@@ -40,45 +40,45 @@
|
|||||||
"@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-rxjs/core": "^0.10.7",
|
|
||||||
"@react-spring/web": "^9.4.4",
|
"@react-spring/web": "^9.4.4",
|
||||||
"@react-stately/collections": "^3.3.4",
|
"@react-stately/collections": "^3.3.4",
|
||||||
"@react-stately/select": "^3.1.3",
|
"@react-stately/select": "^3.1.3",
|
||||||
"@react-stately/tooltip": "^3.0.5",
|
"@react-stately/tooltip": "^3.0.5",
|
||||||
"@react-stately/tree": "^3.2.0",
|
"@react-stately/tree": "^3.2.0",
|
||||||
"@sentry/react": "^7.0.0",
|
"@sentry/react": "^8.0.0",
|
||||||
"@sentry/tracing": "^7.0.0",
|
"@sentry/tracing": "^7.0.0",
|
||||||
"@types/lodash": "^4.14.199",
|
"@types/lodash": "^4.14.199",
|
||||||
"@use-gesture/react": "^10.2.11",
|
"@use-gesture/react": "^10.2.11",
|
||||||
"@vector-im/compound-design-tokens": "^1.0.0",
|
"@vector-im/compound-design-tokens": "^1.0.0",
|
||||||
"@vector-im/compound-web": "^3.0.0",
|
"@vector-im/compound-web": "^6.0.0",
|
||||||
"@vitejs/plugin-basic-ssl": "^1.0.1",
|
"@vitejs/plugin-basic-ssl": "^1.0.1",
|
||||||
"@vitejs/plugin-react": "^4.0.1",
|
"@vitejs/plugin-react": "^4.0.1",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"i18next": "^23.0.0",
|
"i18next": "^23.0.0",
|
||||||
"i18next-browser-languagedetector": "^7.0.0",
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
"i18next-http-backend": "^2.0.0",
|
"i18next-http-backend": "^2.0.0",
|
||||||
"livekit-client": "^2.0.2",
|
"livekit-client": "^2.0.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#8123e9a3f1142a7619758c0a238172b007e3a06a",
|
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#467908703bc67fa3e23d978f5549e2709d4acf74",
|
||||||
"matrix-widget-api": "^1.3.1",
|
"matrix-widget-api": "^1.8.2",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
|
"observable-hooks": "^4.2.3",
|
||||||
"pako": "^2.0.4",
|
"pako": "^2.0.4",
|
||||||
"postcss-preset-env": "^9.0.0",
|
"postcss-preset-env": "^10.0.0",
|
||||||
"posthog-js": "^1.29.0",
|
"posthog-js": "^1.29.0",
|
||||||
"react": "18",
|
"react": "18",
|
||||||
"react-dom": "18",
|
"react-dom": "18",
|
||||||
"react-i18next": "^14.0.0",
|
"react-i18next": "^15.0.0",
|
||||||
"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",
|
"react-use-measure": "^2.1.1",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sdp-transform": "^2.14.1",
|
"sdp-transform": "^2.14.1",
|
||||||
"tinyqueue": "^2.0.3",
|
"tinyqueue": "^3.0.0",
|
||||||
"unique-names-generator": "^4.6.0",
|
"unique-names-generator": "^4.6.0",
|
||||||
"uuid": "9",
|
"uuid": "10",
|
||||||
"vaul": "^0.9.0"
|
"vaul": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -89,17 +89,19 @@
|
|||||||
"@react-spring/rafz": "^9.7.3",
|
"@react-spring/rafz": "^9.7.3",
|
||||||
"@react-types/dialog": "^3.5.5",
|
"@react-types/dialog": "^3.5.5",
|
||||||
"@sentry/vite-plugin": "^2.0.0",
|
"@sentry/vite-plugin": "^2.0.0",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/dom": "^10.1.0",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
"@testing-library/user-event": "^14.5.1",
|
"@testing-library/user-event": "^14.5.1",
|
||||||
"@types/content-type": "^1.1.5",
|
"@types/content-type": "^1.1.5",
|
||||||
"@types/dom-screen-wake-lock": "^1.0.1",
|
"@types/dom-screen-wake-lock": "^1.0.1",
|
||||||
"@types/dompurify": "^3.0.2",
|
"@types/dompurify": "^3.0.2",
|
||||||
"@types/grecaptcha": "^3.0.4",
|
"@types/grecaptcha": "^3.0.4",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/request": "^2.48.8",
|
"@types/request": "^2.48.8",
|
||||||
"@types/sdp-transform": "^2.4.5",
|
"@types/sdp-transform": "^2.4.5",
|
||||||
"@types/uuid": "9",
|
"@types/uuid": "10",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"babel-loader": "^9.0.0",
|
"babel-loader": "^9.0.0",
|
||||||
@@ -113,8 +115,8 @@
|
|||||||
"eslint-plugin-matrix-org": "^1.2.1",
|
"eslint-plugin-matrix-org": "^1.2.1",
|
||||||
"eslint-plugin-react": "^7.29.4",
|
"eslint-plugin-react": "^7.29.4",
|
||||||
"eslint-plugin-react-hooks": "^4.5.0",
|
"eslint-plugin-react-hooks": "^4.5.0",
|
||||||
"eslint-plugin-unicorn": "^51.0.0",
|
"eslint-plugin-unicorn": "^55.0.0",
|
||||||
"i18next-parser": "^8.0.0",
|
"i18next-parser": "^9.0.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"sass": "^1.42.1",
|
"sass": "^1.42.1",
|
||||||
@@ -123,6 +125,6 @@
|
|||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vite-plugin-html-template": "^1.1.0",
|
"vite-plugin-html-template": "^1.1.0",
|
||||||
"vite-plugin-svgr": "^4.0.0",
|
"vite-plugin-svgr": "^4.0.0",
|
||||||
"vitest": "^1.2.2"
|
"vitest": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="cpd-theme-dark">
|
<!-- The default class is: .no-theme {display: none}. It will be overwritten once the app is loaded. -->
|
||||||
|
<body class="no-theme">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -31,8 +31,6 @@
|
|||||||
"username": "Потребителско име",
|
"username": "Потребителско име",
|
||||||
"video": "Видео"
|
"video": "Видео"
|
||||||
},
|
},
|
||||||
"exit_fullscreen_button_label": "Излез от цял екран",
|
|
||||||
"fullscreen_button_label": "Цял екран",
|
|
||||||
"join_existing_call_modal": {
|
"join_existing_call_modal": {
|
||||||
"join_button": "Да, присъедини се",
|
"join_button": "Да, присъедини се",
|
||||||
"text": "Този разговор вече съществува, искате ли да се присъедините?",
|
"text": "Този разговор вече съществува, искате ли да се присъедините?",
|
||||||
@@ -42,7 +40,6 @@
|
|||||||
"lobby": {
|
"lobby": {
|
||||||
"join_button": "Влез в разговора"
|
"join_button": "Влез в разговора"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Локална сила на звука",
|
|
||||||
"logging_in": "Влизане…",
|
"logging_in": "Влизане…",
|
||||||
"login_auth_links": "<0>Създайте акаунт</0> или <2>Влезте като гост</2>",
|
"login_auth_links": "<0>Създайте акаунт</0> или <2>Влезте като гост</2>",
|
||||||
"login_title": "Влез",
|
"login_title": "Влез",
|
||||||
|
|||||||
@@ -29,10 +29,8 @@
|
|||||||
"settings": "Nastavení",
|
"settings": "Nastavení",
|
||||||
"username": "Uživatelské jméno"
|
"username": "Uživatelské jméno"
|
||||||
},
|
},
|
||||||
"exit_fullscreen_button_label": "Ukončit režim celé obrazovky",
|
|
||||||
"full_screen_view_description": "<0>Odeslání ladících záznamů nám pomůže diagnostikovat problém.</0>",
|
"full_screen_view_description": "<0>Odeslání ladících záznamů nám pomůže diagnostikovat problém.</0>",
|
||||||
"full_screen_view_h1": "<0>Oops, něco se pokazilo.</0>",
|
"full_screen_view_h1": "<0>Oops, něco se pokazilo.</0>",
|
||||||
"fullscreen_button_label": "Zvětšit na celou obrazovku",
|
|
||||||
"header_label": "Domov Element Call",
|
"header_label": "Domov Element Call",
|
||||||
"join_existing_call_modal": {
|
"join_existing_call_modal": {
|
||||||
"join_button": "Ano, připojit se",
|
"join_button": "Ano, připojit se",
|
||||||
@@ -43,7 +41,6 @@
|
|||||||
"lobby": {
|
"lobby": {
|
||||||
"join_button": "Připojit se k hovoru"
|
"join_button": "Připojit se k hovoru"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Lokální hlasitost",
|
|
||||||
"logging_in": "Přihlašování se…",
|
"logging_in": "Přihlašování se…",
|
||||||
"login_auth_links": "<0>Vytvořit účet</0> Or <2>Jako host</2>",
|
"login_auth_links": "<0>Vytvořit účet</0> Or <2>Jako host</2>",
|
||||||
"login_title": "Přihlášení",
|
"login_title": "Přihlášení",
|
||||||
|
|||||||
@@ -56,10 +56,8 @@
|
|||||||
"video": "Video"
|
"video": "Video"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
|
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
|
||||||
"exit_fullscreen_button_label": "Vollbildmodus verlassen",
|
|
||||||
"full_screen_view_description": "<0>Übermittelte Problemberichte helfen uns, Fehler zu beheben.</0>",
|
"full_screen_view_description": "<0>Übermittelte Problemberichte helfen uns, Fehler zu beheben.</0>",
|
||||||
"full_screen_view_h1": "<0>Hoppla, etwas ist schiefgelaufen.</0>",
|
"full_screen_view_h1": "<0>Hoppla, etwas ist schiefgelaufen.</0>",
|
||||||
"fullscreen_button_label": "Vollbild",
|
|
||||||
"group_call_loader_failed_heading": "Anruf nicht gefunden",
|
"group_call_loader_failed_heading": "Anruf nicht gefunden",
|
||||||
"group_call_loader_failed_text": "Anrufe sind nun Ende-zu-Ende-verschlüsselt und müssen auf der Startseite erstellt werden. Damit stellen wir sicher, dass alle denselben Schlüssel verwenden.",
|
"group_call_loader_failed_text": "Anrufe sind nun Ende-zu-Ende-verschlüsselt und müssen auf der Startseite erstellt werden. Damit stellen wir sicher, dass alle denselben Schlüssel verwenden.",
|
||||||
"hangup_button_label": "Anruf beenden",
|
"hangup_button_label": "Anruf beenden",
|
||||||
@@ -80,7 +78,6 @@
|
|||||||
"join_button": "Anruf beitreten",
|
"join_button": "Anruf beitreten",
|
||||||
"leave_button": "Zurück zu kürzlichen Anrufen"
|
"leave_button": "Zurück zu kürzlichen Anrufen"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Lokale Lautstärke",
|
|
||||||
"log_in": "Anmelden",
|
"log_in": "Anmelden",
|
||||||
"logging_in": "Anmelden …",
|
"logging_in": "Anmelden …",
|
||||||
"login_auth_links": "<0>Konto erstellen</0> Oder <2>Als Gast betreten</2>",
|
"login_auth_links": "<0>Konto erstellen</0> Oder <2>Als Gast betreten</2>",
|
||||||
@@ -141,7 +138,6 @@
|
|||||||
"unmute_microphone_button_label": "Mikrofon aktivieren",
|
"unmute_microphone_button_label": "Mikrofon aktivieren",
|
||||||
"version": "Version: {{version}}",
|
"version": "Version: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"presenter_label": "{{displayName}} präsentiert",
|
|
||||||
"sfu_participant_local": "Du"
|
"sfu_participant_local": "Du"
|
||||||
},
|
},
|
||||||
"waiting_for_participants": "Warte auf weitere Teilnehmer …"
|
"waiting_for_participants": "Warte auf weitere Teilnehmer …"
|
||||||
|
|||||||
@@ -37,10 +37,8 @@
|
|||||||
"username": "Όνομα χρήστη",
|
"username": "Όνομα χρήστη",
|
||||||
"video": "Βίντεο"
|
"video": "Βίντεο"
|
||||||
},
|
},
|
||||||
"exit_fullscreen_button_label": "Έξοδος από πλήρη οθόνη",
|
|
||||||
"full_screen_view_description": "<0>Η υποβολή αρχείων καταγραφής σφαλμάτων θα μας βοηθήσει να εντοπίσουμε το πρόβλημα.</0>",
|
"full_screen_view_description": "<0>Η υποβολή αρχείων καταγραφής σφαλμάτων θα μας βοηθήσει να εντοπίσουμε το πρόβλημα.</0>",
|
||||||
"full_screen_view_h1": "<0>Ωχ, κάτι πήγε στραβά.</0>",
|
"full_screen_view_h1": "<0>Ωχ, κάτι πήγε στραβά.</0>",
|
||||||
"fullscreen_button_label": "Πλήρη οθόνη",
|
|
||||||
"header_label": "Element Κεντρική Οθόνη Κλήσεων",
|
"header_label": "Element Κεντρική Οθόνη Κλήσεων",
|
||||||
"join_existing_call_modal": {
|
"join_existing_call_modal": {
|
||||||
"join_button": "Ναι, συμμετοχή στην κλήση",
|
"join_button": "Ναι, συμμετοχή στην κλήση",
|
||||||
@@ -50,7 +48,6 @@
|
|||||||
"lobby": {
|
"lobby": {
|
||||||
"join_button": "Συμμετοχή στην κλήση"
|
"join_button": "Συμμετοχή στην κλήση"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Τοπική ένταση",
|
|
||||||
"logging_in": "Σύνδεση…",
|
"logging_in": "Σύνδεση…",
|
||||||
"login_auth_links": "<0>Δημιουργήστε λογαριασμό</0> Ή <2>Συμμετέχετε ως επισκέπτης</2>",
|
"login_auth_links": "<0>Δημιουργήστε λογαριασμό</0> Ή <2>Συμμετέχετε ως επισκέπτης</2>",
|
||||||
"login_title": "Σύνδεση",
|
"login_title": "Σύνδεση",
|
||||||
@@ -94,8 +91,5 @@
|
|||||||
"unauthenticated_view_body": "Δεν έχετε εγγραφεί ακόμα; <2>Δημιουργήστε λογαριασμό</2>",
|
"unauthenticated_view_body": "Δεν έχετε εγγραφεί ακόμα; <2>Δημιουργήστε λογαριασμό</2>",
|
||||||
"unauthenticated_view_login_button": "Συνδεθείτε στον λογαριασμό σας",
|
"unauthenticated_view_login_button": "Συνδεθείτε στον λογαριασμό σας",
|
||||||
"version": "Έκδοση: {{version}}",
|
"version": "Έκδοση: {{version}}",
|
||||||
"video_tile": {
|
|
||||||
"presenter_label": "{{displayName}} παρουσιάζει"
|
|
||||||
},
|
|
||||||
"waiting_for_participants": "Αναμονή για άλλους συμμετέχοντες…"
|
"waiting_for_participants": "Αναμονή για άλλους συμμετέχοντες…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
|
"back": "Back",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"display_name": "Display name",
|
"display_name": "Display name",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"microphone": "Microphone",
|
"microphone": "Microphone",
|
||||||
|
"next": "Next",
|
||||||
"options": "Options",
|
"options": "Options",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
@@ -57,11 +59,22 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"video": "Video"
|
"video": "Video"
|
||||||
},
|
},
|
||||||
|
"crypto_version": "Crypto version: {{version}}",
|
||||||
|
"device_id": "Device ID: {{id}}",
|
||||||
"disconnected_banner": "Connectivity to the server has been lost.",
|
"disconnected_banner": "Connectivity to the server has been lost.",
|
||||||
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
|
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
|
||||||
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
|
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
|
||||||
"group_call_loader_failed_heading": "Call not found",
|
"group_call_loader": {
|
||||||
"group_call_loader_failed_text": "Calls are now end-to-end encrypted and need to be created from the home page. This helps make sure everyone's using the same encryption key.",
|
"banned_body": "You have been banned from the room.",
|
||||||
|
"banned_heading": "Banned",
|
||||||
|
"call_ended_body": "You have been removed from the call.",
|
||||||
|
"call_ended_heading": "Call ended",
|
||||||
|
"failed_heading": "Failed to join",
|
||||||
|
"failed_text": "Call not found or is not accessible.",
|
||||||
|
"knock_reject_body": "The room members declined your request to join.",
|
||||||
|
"knock_reject_heading": "Not allowed to join",
|
||||||
|
"reason": "Reason"
|
||||||
|
},
|
||||||
"hangup_button_label": "End call",
|
"hangup_button_label": "End call",
|
||||||
"header_label": "Element Call Home",
|
"header_label": "Element Call Home",
|
||||||
"header_participants_label": "Participants",
|
"header_participants_label": "Participants",
|
||||||
@@ -77,8 +90,10 @@
|
|||||||
"layout_grid_label": "Grid",
|
"layout_grid_label": "Grid",
|
||||||
"layout_spotlight_label": "Spotlight",
|
"layout_spotlight_label": "Spotlight",
|
||||||
"lobby": {
|
"lobby": {
|
||||||
|
"ask_to_join": "Ask to join call",
|
||||||
"join_button": "Join call",
|
"join_button": "Join call",
|
||||||
"leave_button": "Back to recents"
|
"leave_button": "Back to recents",
|
||||||
|
"waiting_for_invite": "Request sent"
|
||||||
},
|
},
|
||||||
"log_in": "Log In",
|
"log_in": "Log In",
|
||||||
"logging_in": "Logging in…",
|
"logging_in": "Logging in…",
|
||||||
@@ -86,6 +101,7 @@
|
|||||||
"login_auth_links_prompt": "Not registered yet?",
|
"login_auth_links_prompt": "Not registered yet?",
|
||||||
"login_subheading": "To continue to Element",
|
"login_subheading": "To continue to Element",
|
||||||
"login_title": "Login",
|
"login_title": "Login",
|
||||||
|
"matrix_id": "Matrix ID: {{id}}",
|
||||||
"microphone_off": "Microphone off",
|
"microphone_off": "Microphone off",
|
||||||
"microphone_on": "Microphone on",
|
"microphone_on": "Microphone on",
|
||||||
"mute_microphone_button_label": "Mute microphone",
|
"mute_microphone_button_label": "Mute microphone",
|
||||||
@@ -119,6 +135,7 @@
|
|||||||
"developer_settings_label": "Developer Settings",
|
"developer_settings_label": "Developer Settings",
|
||||||
"developer_settings_label_description": "Expose developer settings in the settings window.",
|
"developer_settings_label_description": "Expose developer settings in the settings window.",
|
||||||
"developer_tab_title": "Developer",
|
"developer_tab_title": "Developer",
|
||||||
|
"duplicate_tiles_label": "Number of additional tile copies per participant",
|
||||||
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
|
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
|
||||||
"feedback_tab_description_label": "Your feedback",
|
"feedback_tab_description_label": "Your feedback",
|
||||||
"feedback_tab_h4": "Submit feedback",
|
"feedback_tab_h4": "Submit feedback",
|
||||||
@@ -127,7 +144,6 @@
|
|||||||
"feedback_tab_title": "Feedback",
|
"feedback_tab_title": "Feedback",
|
||||||
"more_tab_title": "More",
|
"more_tab_title": "More",
|
||||||
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
|
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
|
||||||
"show_connection_stats_label": "Show connection stats",
|
|
||||||
"speaker_device_selection_label": "Speaker"
|
"speaker_device_selection_label": "Speaker"
|
||||||
},
|
},
|
||||||
"star_rating_input_label_one": "{{count}} stars",
|
"star_rating_input_label_one": "{{count}} stars",
|
||||||
@@ -141,14 +157,13 @@
|
|||||||
"unauthenticated_view_eula_caption": "By clicking \"Go\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
|
"unauthenticated_view_eula_caption": "By clicking \"Go\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
|
||||||
"unauthenticated_view_login_button": "Login to your account",
|
"unauthenticated_view_login_button": "Login to your account",
|
||||||
"unmute_microphone_button_label": "Unmute microphone",
|
"unmute_microphone_button_label": "Unmute microphone",
|
||||||
"version": "Version: {{version}}",
|
"version": "{{productName}} version: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
|
"always_show": "Always show",
|
||||||
"change_fit_contain": "Fit to frame",
|
"change_fit_contain": "Fit to frame",
|
||||||
"exit_full_screen": "Exit full screen",
|
"exit_full_screen": "Exit full screen",
|
||||||
"full_screen": "Full screen",
|
"full_screen": "Full screen",
|
||||||
"mute_for_me": "Mute for me",
|
"mute_for_me": "Mute for me",
|
||||||
"sfu_participant_local": "You",
|
|
||||||
"volume": "Volume"
|
"volume": "Volume"
|
||||||
},
|
}
|
||||||
"waiting_for_participants": "Waiting for other participants…"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,8 @@
|
|||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"username": "Nombre de usuario"
|
"username": "Nombre de usuario"
|
||||||
},
|
},
|
||||||
"exit_fullscreen_button_label": "Salir de pantalla completa",
|
|
||||||
"full_screen_view_description": "<0>Subir los registros de depuración nos ayudará a encontrar el problema.</0>",
|
"full_screen_view_description": "<0>Subir los registros de depuración nos ayudará a encontrar el problema.</0>",
|
||||||
"full_screen_view_h1": "<0>Ups, algo ha salido mal.</0>",
|
"full_screen_view_h1": "<0>Ups, algo ha salido mal.</0>",
|
||||||
"fullscreen_button_label": "Pantalla completa",
|
|
||||||
"header_label": "Inicio de Element Call",
|
"header_label": "Inicio de Element Call",
|
||||||
"join_existing_call_modal": {
|
"join_existing_call_modal": {
|
||||||
"join_button": "Si, unirse a la llamada",
|
"join_button": "Si, unirse a la llamada",
|
||||||
@@ -48,7 +46,6 @@
|
|||||||
"lobby": {
|
"lobby": {
|
||||||
"join_button": "Unirse a la llamada"
|
"join_button": "Unirse a la llamada"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Volumen local",
|
|
||||||
"logging_in": "Iniciando sesión…",
|
"logging_in": "Iniciando sesión…",
|
||||||
"login_auth_links": "<0>Crear una cuenta</0> o <2>Acceder como invitado</2>",
|
"login_auth_links": "<0>Crear una cuenta</0> o <2>Acceder como invitado</2>",
|
||||||
"login_title": "Iniciar sesión",
|
"login_title": "Iniciar sesión",
|
||||||
@@ -95,8 +92,5 @@
|
|||||||
"unauthenticated_view_eula_caption": "Al hacer clic en \"Comenzar\", aceptas nuestro <2>Contrato de Licencia de Usuario Final (CLUF)</2>",
|
"unauthenticated_view_eula_caption": "Al hacer clic en \"Comenzar\", aceptas nuestro <2>Contrato de Licencia de Usuario Final (CLUF)</2>",
|
||||||
"unauthenticated_view_login_button": "Iniciar sesión en tu cuenta",
|
"unauthenticated_view_login_button": "Iniciar sesión en tu cuenta",
|
||||||
"version": "Versión: {{version}}",
|
"version": "Versión: {{version}}",
|
||||||
"video_tile": {
|
|
||||||
"presenter_label": "{{displayName}} está presentando"
|
|
||||||
},
|
|
||||||
"waiting_for_participants": "Esperando a los otros participantes…"
|
"waiting_for_participants": "Esperando a los otros participantes…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,10 +52,8 @@
|
|||||||
"username": "Kasutajanimi"
|
"username": "Kasutajanimi"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "Võrguühendus serveriga on katkenud.",
|
"disconnected_banner": "Võrguühendus serveriga on katkenud.",
|
||||||
"exit_fullscreen_button_label": "Välju täisekraanivaatest",
|
|
||||||
"full_screen_view_description": "<0>Kui saadad meile vealogid, siis on lihtsam vea põhjust otsida.</0>",
|
"full_screen_view_description": "<0>Kui saadad meile vealogid, siis on lihtsam vea põhjust otsida.</0>",
|
||||||
"full_screen_view_h1": "<0>Ohoo, midagi on nüüd katki.</0>",
|
"full_screen_view_h1": "<0>Ohoo, midagi on nüüd katki.</0>",
|
||||||
"fullscreen_button_label": "Täisekraan",
|
|
||||||
"group_call_loader_failed_heading": "Kõnet ei leidu",
|
"group_call_loader_failed_heading": "Kõnet ei leidu",
|
||||||
"group_call_loader_failed_text": "Kõned on nüüd läbivalt krüptitud ning need pead looma kodulehelt. Sellega tagad, et kõik kasutavad samu krüptovõtmeid.",
|
"group_call_loader_failed_text": "Kõned on nüüd läbivalt krüptitud ning need pead looma kodulehelt. Sellega tagad, et kõik kasutavad samu krüptovõtmeid.",
|
||||||
"hangup_button_label": "Lõpeta kõne",
|
"hangup_button_label": "Lõpeta kõne",
|
||||||
@@ -75,7 +73,6 @@
|
|||||||
"join_button": "Kõnega liitumine",
|
"join_button": "Kõnega liitumine",
|
||||||
"leave_button": "Tagasi hiljutiste kõnede juurde"
|
"leave_button": "Tagasi hiljutiste kõnede juurde"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Kohalik helitugevus",
|
|
||||||
"logging_in": "Sisselogimine …",
|
"logging_in": "Sisselogimine …",
|
||||||
"login_auth_links": "<0>Loo konto</0> Või <2>Sisene külalisena</2>",
|
"login_auth_links": "<0>Loo konto</0> Või <2>Sisene külalisena</2>",
|
||||||
"login_title": "Sisselogimine",
|
"login_title": "Sisselogimine",
|
||||||
@@ -133,7 +130,6 @@
|
|||||||
"unmute_microphone_button_label": "Lülita mikrofon sisse",
|
"unmute_microphone_button_label": "Lülita mikrofon sisse",
|
||||||
"version": "Versioon: {{version}}",
|
"version": "Versioon: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"presenter_label": "{{displayName}} on esitlemas",
|
|
||||||
"sfu_participant_local": "Sina"
|
"sfu_participant_local": "Sina"
|
||||||
},
|
},
|
||||||
"waiting_for_participants": "Ootame teiste osalejate lisandumist…"
|
"waiting_for_participants": "Ootame teiste osalejate lisandumist…"
|
||||||
|
|||||||
@@ -32,8 +32,6 @@
|
|||||||
"username": "نام کاربری",
|
"username": "نام کاربری",
|
||||||
"video": "ویدیو"
|
"video": "ویدیو"
|
||||||
},
|
},
|
||||||
"exit_fullscreen_button_label": "خروج از حالت تمام صفحه",
|
|
||||||
"fullscreen_button_label": "تمام صحفه",
|
|
||||||
"header_label": "خانهٔ تماس المنت",
|
"header_label": "خانهٔ تماس المنت",
|
||||||
"join_existing_call_modal": {
|
"join_existing_call_modal": {
|
||||||
"join_button": "بله، به تماس بپیوندید",
|
"join_button": "بله، به تماس بپیوندید",
|
||||||
@@ -44,7 +42,6 @@
|
|||||||
"lobby": {
|
"lobby": {
|
||||||
"join_button": "پیوستن به تماس"
|
"join_button": "پیوستن به تماس"
|
||||||
},
|
},
|
||||||
"local_volume_label": "حجم داخلی",
|
|
||||||
"logging_in": "ورود…",
|
"logging_in": "ورود…",
|
||||||
"login_auth_links": "<0>ساخت حساب کاربری</0> Or <2>دسترسی به عنوان میهمان</2>",
|
"login_auth_links": "<0>ساخت حساب کاربری</0> Or <2>دسترسی به عنوان میهمان</2>",
|
||||||
"login_title": "ورود",
|
"login_title": "ورود",
|
||||||
|
|||||||
@@ -50,10 +50,8 @@
|
|||||||
"video": "Vidéo"
|
"video": "Vidéo"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "La connexion avec le serveur a été perdue.",
|
"disconnected_banner": "La connexion avec le serveur a été perdue.",
|
||||||
"exit_fullscreen_button_label": "Quitter le plein écran",
|
|
||||||
"full_screen_view_description": "<0>Soumettre les journaux de débogage nous aidera à déterminer le problème.</0>",
|
"full_screen_view_description": "<0>Soumettre les journaux de débogage nous aidera à déterminer le problème.</0>",
|
||||||
"full_screen_view_h1": "<0>Oups, quelque chose s’est mal passé.</0>",
|
"full_screen_view_h1": "<0>Oups, quelque chose s’est mal passé.</0>",
|
||||||
"fullscreen_button_label": "Plein écran",
|
|
||||||
"group_call_loader_failed_heading": "Appel non trouvé",
|
"group_call_loader_failed_heading": "Appel non trouvé",
|
||||||
"group_call_loader_failed_text": "Les appels sont maintenant chiffrés de bout-en-bout et doivent être créés depuis la page d’accueil. Cela permet d’être sûr que tout le monde utilise la même clé de chiffrement.",
|
"group_call_loader_failed_text": "Les appels sont maintenant chiffrés de bout-en-bout et doivent être créés depuis la page d’accueil. Cela permet d’être sûr que tout le monde utilise la même clé de chiffrement.",
|
||||||
"hangup_button_label": "Terminer l’appel",
|
"hangup_button_label": "Terminer l’appel",
|
||||||
@@ -73,7 +71,6 @@
|
|||||||
"join_button": "Rejoindre l’appel",
|
"join_button": "Rejoindre l’appel",
|
||||||
"leave_button": "Revenir à l’historique des appels"
|
"leave_button": "Revenir à l’historique des appels"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Volume local",
|
|
||||||
"logging_in": "Connexion…",
|
"logging_in": "Connexion…",
|
||||||
"login_auth_links": "<0>Créer un compte</0> Or <2>Accès invité</2>",
|
"login_auth_links": "<0>Créer un compte</0> Or <2>Accès invité</2>",
|
||||||
"login_title": "Connexion",
|
"login_title": "Connexion",
|
||||||
@@ -131,7 +128,6 @@
|
|||||||
"unmute_microphone_button_label": "Allumer le microphone",
|
"unmute_microphone_button_label": "Allumer le microphone",
|
||||||
"version": "Version : {{version}}",
|
"version": "Version : {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"presenter_label": "{{displayName}} est à l’écran",
|
|
||||||
"sfu_participant_local": "Vous"
|
"sfu_participant_local": "Vous"
|
||||||
},
|
},
|
||||||
"waiting_for_participants": "En attente d’autres participants…"
|
"waiting_for_participants": "En attente d’autres participants…"
|
||||||
|
|||||||
@@ -50,10 +50,8 @@
|
|||||||
"username": "Nama pengguna"
|
"username": "Nama pengguna"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "Koneksi ke server telah hilang.",
|
"disconnected_banner": "Koneksi ke server telah hilang.",
|
||||||
"exit_fullscreen_button_label": "Keluar dari layar penuh",
|
|
||||||
"full_screen_view_description": "<0>Mengirim catatan pengawakutuan akan membantu kami melacak masalahnya.</0>",
|
"full_screen_view_description": "<0>Mengirim catatan pengawakutuan akan membantu kami melacak masalahnya.</0>",
|
||||||
"full_screen_view_h1": "<0>Aduh, ada yang salah.</0>",
|
"full_screen_view_h1": "<0>Aduh, ada yang salah.</0>",
|
||||||
"fullscreen_button_label": "Layar penuh",
|
|
||||||
"group_call_loader_failed_heading": "Panggilan tidak ditemukan",
|
"group_call_loader_failed_heading": "Panggilan tidak ditemukan",
|
||||||
"group_call_loader_failed_text": "Panggilan sekarang terenkripsi secara ujung ke ujung dan harus dibuat dari laman beranda. Ini memastikan bahwa semuanya menggunakan kunci enkripsi yang sama.",
|
"group_call_loader_failed_text": "Panggilan sekarang terenkripsi secara ujung ke ujung dan harus dibuat dari laman beranda. Ini memastikan bahwa semuanya menggunakan kunci enkripsi yang sama.",
|
||||||
"hangup_button_label": "Akhiri panggilan",
|
"hangup_button_label": "Akhiri panggilan",
|
||||||
@@ -74,7 +72,6 @@
|
|||||||
"join_button": "Bergabung ke panggilan",
|
"join_button": "Bergabung ke panggilan",
|
||||||
"leave_button": "Kembali ke terkini"
|
"leave_button": "Kembali ke terkini"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Volume lokal",
|
|
||||||
"logging_in": "Memasuki…",
|
"logging_in": "Memasuki…",
|
||||||
"login_auth_links": "<0>Buat akun</0> Atau <2>Akses sebagai tamu</2>",
|
"login_auth_links": "<0>Buat akun</0> Atau <2>Akses sebagai tamu</2>",
|
||||||
"login_title": "Masuk",
|
"login_title": "Masuk",
|
||||||
@@ -132,7 +129,6 @@
|
|||||||
"unmute_microphone_button_label": "Nyalakan mikrofon",
|
"unmute_microphone_button_label": "Nyalakan mikrofon",
|
||||||
"version": "Versi: {{version}}",
|
"version": "Versi: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"presenter_label": "{{displayName}} sedang menampilkan",
|
|
||||||
"sfu_participant_local": "Anda"
|
"sfu_participant_local": "Anda"
|
||||||
},
|
},
|
||||||
"waiting_for_participants": "Menunggu peserta lain…"
|
"waiting_for_participants": "Menunggu peserta lain…"
|
||||||
|
|||||||
@@ -48,10 +48,8 @@
|
|||||||
"username": "Nome utente"
|
"username": "Nome utente"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "La connessione al server è stata persa.",
|
"disconnected_banner": "La connessione al server è stata persa.",
|
||||||
"exit_fullscreen_button_label": "Esci da schermo intero",
|
|
||||||
"full_screen_view_description": "<0>L'invio di registri di debug ci aiuterà ad individuare il problema.</0>",
|
"full_screen_view_description": "<0>L'invio di registri di debug ci aiuterà ad individuare il problema.</0>",
|
||||||
"full_screen_view_h1": "<0>Ops, qualcosa è andato storto.</0>",
|
"full_screen_view_h1": "<0>Ops, qualcosa è andato storto.</0>",
|
||||||
"fullscreen_button_label": "Schermo intero",
|
|
||||||
"group_call_loader_failed_heading": "Chiamata non trovata",
|
"group_call_loader_failed_heading": "Chiamata non trovata",
|
||||||
"group_call_loader_failed_text": "Le chiamate ora sono cifrate end-to-end e devono essere create dalla pagina principale. Ciò assicura che chiunque usi la stessa chiave di crittografia.",
|
"group_call_loader_failed_text": "Le chiamate ora sono cifrate end-to-end e devono essere create dalla pagina principale. Ciò assicura che chiunque usi la stessa chiave di crittografia.",
|
||||||
"hangup_button_label": "Termina chiamata",
|
"hangup_button_label": "Termina chiamata",
|
||||||
@@ -72,7 +70,6 @@
|
|||||||
"join_button": "Entra in chiamata",
|
"join_button": "Entra in chiamata",
|
||||||
"leave_button": "Torna ai recenti"
|
"leave_button": "Torna ai recenti"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Volume locale",
|
|
||||||
"logging_in": "Accesso…",
|
"logging_in": "Accesso…",
|
||||||
"login_auth_links": "<0>Crea un profilo</0> o <2>Accedi come ospite</2>",
|
"login_auth_links": "<0>Crea un profilo</0> o <2>Accedi come ospite</2>",
|
||||||
"login_title": "Accedi",
|
"login_title": "Accedi",
|
||||||
@@ -129,7 +126,6 @@
|
|||||||
"unmute_microphone_button_label": "Riaccendi il microfono",
|
"unmute_microphone_button_label": "Riaccendi il microfono",
|
||||||
"version": "Versione: {{version}}",
|
"version": "Versione: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"presenter_label": "{{displayName}} sta presentando",
|
|
||||||
"sfu_participant_local": "Tu"
|
"sfu_participant_local": "Tu"
|
||||||
},
|
},
|
||||||
"waiting_for_participants": "In attesa di altri partecipanti…"
|
"waiting_for_participants": "In attesa di altri partecipanti…"
|
||||||
|
|||||||
@@ -30,9 +30,7 @@
|
|||||||
"username": "ユーザー名",
|
"username": "ユーザー名",
|
||||||
"video": "ビデオ"
|
"video": "ビデオ"
|
||||||
},
|
},
|
||||||
"exit_fullscreen_button_label": "全画面表示を終了",
|
|
||||||
"full_screen_view_h1": "<0>何かがうまく行きませんでした。</0>",
|
"full_screen_view_h1": "<0>何かがうまく行きませんでした。</0>",
|
||||||
"fullscreen_button_label": "全画面表示",
|
|
||||||
"header_label": "Element Call ホーム",
|
"header_label": "Element Call ホーム",
|
||||||
"join_existing_call_modal": {
|
"join_existing_call_modal": {
|
||||||
"join_button": "はい、通話に参加",
|
"join_button": "はい、通話に参加",
|
||||||
|
|||||||
@@ -40,10 +40,8 @@
|
|||||||
"username": "Lietotājvārds"
|
"username": "Lietotājvārds"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "Ir zaudēts savienojums ar serveri.",
|
"disconnected_banner": "Ir zaudēts savienojums ar serveri.",
|
||||||
"exit_fullscreen_button_label": "Iziet no pilnekrāna",
|
|
||||||
"full_screen_view_description": "<0>Atkļūdošanas žurnāla ierakstu iesūtīšana palīdzēs mums atklāt nepilnību.</0>",
|
"full_screen_view_description": "<0>Atkļūdošanas žurnāla ierakstu iesūtīšana palīdzēs mums atklāt nepilnību.</0>",
|
||||||
"full_screen_view_h1": "<0>Ak vai, kaut kas nogāja greizi!</0>",
|
"full_screen_view_h1": "<0>Ak vai, kaut kas nogāja greizi!</0>",
|
||||||
"fullscreen_button_label": "Pilnekrāns",
|
|
||||||
"header_label": "Element Call sākums",
|
"header_label": "Element Call sākums",
|
||||||
"join_existing_call_modal": {
|
"join_existing_call_modal": {
|
||||||
"join_button": "Jā, pievienoties zvanam",
|
"join_button": "Jā, pievienoties zvanam",
|
||||||
@@ -54,7 +52,6 @@
|
|||||||
"lobby": {
|
"lobby": {
|
||||||
"join_button": "Pievienoties zvanam"
|
"join_button": "Pievienoties zvanam"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Vietējais skaļums",
|
|
||||||
"logging_in": "Piesakās…",
|
"logging_in": "Piesakās…",
|
||||||
"login_auth_links": "<0>Izveidot kontu</0> vai <2>Piekļūt kā viesim</2>",
|
"login_auth_links": "<0>Izveidot kontu</0> vai <2>Piekļūt kā viesim</2>",
|
||||||
"login_title": "Pieteikties",
|
"login_title": "Pieteikties",
|
||||||
@@ -103,8 +100,5 @@
|
|||||||
"unauthenticated_view_eula_caption": "Klikšķināšana uz \"Aiziet\" apliecina piekrišanu mūsu <2>galalietotāja licencēšanas nolīgumam (GLLN)</2>",
|
"unauthenticated_view_eula_caption": "Klikšķināšana uz \"Aiziet\" apliecina piekrišanu mūsu <2>galalietotāja licencēšanas nolīgumam (GLLN)</2>",
|
||||||
"unauthenticated_view_login_button": "Pieteikties kontā",
|
"unauthenticated_view_login_button": "Pieteikties kontā",
|
||||||
"version": "Versija: {{version}}",
|
"version": "Versija: {{version}}",
|
||||||
"video_tile": {
|
|
||||||
"presenter_label": "{{displayName}} uzstājas"
|
|
||||||
},
|
|
||||||
"waiting_for_participants": "Gaida citus dalībniekus…"
|
"waiting_for_participants": "Gaida citus dalībniekus…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,10 +53,8 @@
|
|||||||
"video": "Wideo"
|
"video": "Wideo"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "Utracono połączenie z serwerem.",
|
"disconnected_banner": "Utracono połączenie z serwerem.",
|
||||||
"exit_fullscreen_button_label": "Opuść pełny ekran",
|
|
||||||
"full_screen_view_description": "<0>Wysłanie dzienników debuggowania pomoże nam ustalić przyczynę problemu.</0>",
|
"full_screen_view_description": "<0>Wysłanie dzienników debuggowania pomoże nam ustalić przyczynę problemu.</0>",
|
||||||
"full_screen_view_h1": "<0>Ojej, coś poszło nie tak.</0>",
|
"full_screen_view_h1": "<0>Ojej, coś poszło nie tak.</0>",
|
||||||
"fullscreen_button_label": "Pełny ekran",
|
|
||||||
"group_call_loader_failed_heading": "Nie znaleziono połączenia",
|
"group_call_loader_failed_heading": "Nie znaleziono połączenia",
|
||||||
"group_call_loader_failed_text": "Połączenia są teraz szyfrowane end-to-end i muszą zostać utworzone ze strony głównej. Pomaga to upewnić się, że każdy korzysta z tego samego klucza szyfrującego.",
|
"group_call_loader_failed_text": "Połączenia są teraz szyfrowane end-to-end i muszą zostać utworzone ze strony głównej. Pomaga to upewnić się, że każdy korzysta z tego samego klucza szyfrującego.",
|
||||||
"hangup_button_label": "Zakończ połączenie",
|
"hangup_button_label": "Zakończ połączenie",
|
||||||
@@ -77,7 +75,6 @@
|
|||||||
"join_button": "Dołącz do połączenia",
|
"join_button": "Dołącz do połączenia",
|
||||||
"leave_button": "Wróć do ostatnie"
|
"leave_button": "Wróć do ostatnie"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Głośność lokalna",
|
|
||||||
"logging_in": "Logowanie…",
|
"logging_in": "Logowanie…",
|
||||||
"login_auth_links": "<0>Utwórz konto</0> lub <2>Dołącz jako gość</2>",
|
"login_auth_links": "<0>Utwórz konto</0> lub <2>Dołącz jako gość</2>",
|
||||||
"login_title": "Zaloguj się",
|
"login_title": "Zaloguj się",
|
||||||
@@ -135,7 +132,6 @@
|
|||||||
"unmute_microphone_button_label": "Odcisz mikrofon",
|
"unmute_microphone_button_label": "Odcisz mikrofon",
|
||||||
"version": "Wersja: {{version}}",
|
"version": "Wersja: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"presenter_label": "{{displayName}} prezentuje",
|
|
||||||
"sfu_participant_local": "Ty"
|
"sfu_participant_local": "Ty"
|
||||||
},
|
},
|
||||||
"waiting_for_participants": "Oczekiwanie na pozostałych uczestników…"
|
"waiting_for_participants": "Oczekiwanie na pozostałych uczestników…"
|
||||||
|
|||||||
@@ -38,10 +38,8 @@
|
|||||||
"username": "Имя пользователя",
|
"username": "Имя пользователя",
|
||||||
"video": "Видео"
|
"video": "Видео"
|
||||||
},
|
},
|
||||||
"exit_fullscreen_button_label": "Выйти из полноэкранного режима",
|
|
||||||
"full_screen_view_description": "<0>Отправка журналов поможет нам найти и устранить проблему.</0>",
|
"full_screen_view_description": "<0>Отправка журналов поможет нам найти и устранить проблему.</0>",
|
||||||
"full_screen_view_h1": "<0>Упс, что-то пошло не так.</0>",
|
"full_screen_view_h1": "<0>Упс, что-то пошло не так.</0>",
|
||||||
"fullscreen_button_label": "Полноэкранный режим",
|
|
||||||
"header_label": "Главная Element Call",
|
"header_label": "Главная Element Call",
|
||||||
"join_existing_call_modal": {
|
"join_existing_call_modal": {
|
||||||
"join_button": "Да, присоединиться",
|
"join_button": "Да, присоединиться",
|
||||||
@@ -52,7 +50,6 @@
|
|||||||
"lobby": {
|
"lobby": {
|
||||||
"join_button": "Присоединиться"
|
"join_button": "Присоединиться"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Местная громкость",
|
|
||||||
"logging_in": "Вход…",
|
"logging_in": "Вход…",
|
||||||
"login_auth_links": "<0>Создать аккаунт</0> или <2>Зайти как гость</2>",
|
"login_auth_links": "<0>Создать аккаунт</0> или <2>Зайти как гость</2>",
|
||||||
"login_title": "Вход",
|
"login_title": "Вход",
|
||||||
@@ -96,8 +93,5 @@
|
|||||||
"unauthenticated_view_body": "Ещё не зарегистрированы? <2>Создайте аккаунт</2>",
|
"unauthenticated_view_body": "Ещё не зарегистрированы? <2>Создайте аккаунт</2>",
|
||||||
"unauthenticated_view_login_button": "Войдите в свой аккаунт",
|
"unauthenticated_view_login_button": "Войдите в свой аккаунт",
|
||||||
"version": "Версия: {{version}}",
|
"version": "Версия: {{version}}",
|
||||||
"video_tile": {
|
|
||||||
"presenter_label": "{{displayName}} представляет"
|
|
||||||
},
|
|
||||||
"waiting_for_participants": "Ожидание других участников…"
|
"waiting_for_participants": "Ожидание других участников…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,8 @@
|
|||||||
"username": "Meno používateľa"
|
"username": "Meno používateľa"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "Spojenie so serverom sa stratilo.",
|
"disconnected_banner": "Spojenie so serverom sa stratilo.",
|
||||||
"exit_fullscreen_button_label": "Ukončiť zobrazenie na celú obrazovku",
|
|
||||||
"full_screen_view_description": "<0>Odoslanie záznamov ladenia nám pomôže nájsť problém.</0>",
|
"full_screen_view_description": "<0>Odoslanie záznamov ladenia nám pomôže nájsť problém.</0>",
|
||||||
"full_screen_view_h1": "<0>Hups, niečo sa pokazilo.</0>",
|
"full_screen_view_h1": "<0>Hups, niečo sa pokazilo.</0>",
|
||||||
"fullscreen_button_label": "Zobrazenie na celú obrazovku",
|
|
||||||
"group_call_loader_failed_heading": "Hovor nebol nájdený",
|
"group_call_loader_failed_heading": "Hovor nebol nájdený",
|
||||||
"group_call_loader_failed_text": "Hovory sú teraz end-to-end šifrované a je potrebné ich vytvoriť z domovskej stránky. To pomáha zabezpečiť, aby všetci používali rovnaký šifrovací kľúč.",
|
"group_call_loader_failed_text": "Hovory sú teraz end-to-end šifrované a je potrebné ich vytvoriť z domovskej stránky. To pomáha zabezpečiť, aby všetci používali rovnaký šifrovací kľúč.",
|
||||||
"hangup_button_label": "Ukončiť hovor",
|
"hangup_button_label": "Ukončiť hovor",
|
||||||
@@ -75,7 +73,6 @@
|
|||||||
"join_button": "Pripojiť sa k hovoru",
|
"join_button": "Pripojiť sa k hovoru",
|
||||||
"leave_button": "Späť k nedávnym"
|
"leave_button": "Späť k nedávnym"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Lokálna hlasitosť",
|
|
||||||
"logging_in": "Prihlasovanie…",
|
"logging_in": "Prihlasovanie…",
|
||||||
"login_auth_links": "<0>Vytvoriť konto</0> Alebo <2>Prihlásiť sa ako hosť</2>",
|
"login_auth_links": "<0>Vytvoriť konto</0> Alebo <2>Prihlásiť sa ako hosť</2>",
|
||||||
"login_title": "Prihlásiť sa",
|
"login_title": "Prihlásiť sa",
|
||||||
@@ -133,7 +130,6 @@
|
|||||||
"unmute_microphone_button_label": "Zrušiť stlmenie mikrofónu",
|
"unmute_microphone_button_label": "Zrušiť stlmenie mikrofónu",
|
||||||
"version": "Verzia: {{version}}",
|
"version": "Verzia: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"presenter_label": "{{displayName}} prezentuje",
|
|
||||||
"sfu_participant_local": "Vy"
|
"sfu_participant_local": "Vy"
|
||||||
},
|
},
|
||||||
"waiting_for_participants": "Čaká sa na ďalších účastníkov…"
|
"waiting_for_participants": "Čaká sa na ďalších účastníkov…"
|
||||||
|
|||||||
@@ -3,8 +3,5 @@
|
|||||||
"headline": "{{displayName}}, ditt samtal har avslutats."
|
"headline": "{{displayName}}, ditt samtal har avslutats."
|
||||||
},
|
},
|
||||||
"star_rating_input_label_one": "{{count}} stjärna",
|
"star_rating_input_label_one": "{{count}} stjärna",
|
||||||
"star_rating_input_label_other": "{{count}} stjärnor",
|
"star_rating_input_label_other": "{{count}} stjärnor"
|
||||||
"video_tile": {
|
|
||||||
"presenter_label": "{{displayName}} presenterar"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,6 @@
|
|||||||
"password": "Parola",
|
"password": "Parola",
|
||||||
"settings": "Ayarlar"
|
"settings": "Ayarlar"
|
||||||
},
|
},
|
||||||
"exit_fullscreen_button_label": "Tam ekranı terk et",
|
|
||||||
"fullscreen_button_label": "Tam ekran",
|
|
||||||
"join_existing_call_modal": {
|
"join_existing_call_modal": {
|
||||||
"text": "Bu arama zaten var, katılmak ister misiniz?",
|
"text": "Bu arama zaten var, katılmak ister misiniz?",
|
||||||
"title": "Mevcut aramaya katıl?"
|
"title": "Mevcut aramaya katıl?"
|
||||||
@@ -33,7 +31,6 @@
|
|||||||
"lobby": {
|
"lobby": {
|
||||||
"join_button": "Aramaya katıl"
|
"join_button": "Aramaya katıl"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Yerel ses seviyesi",
|
|
||||||
"logging_in": "Giriliyor…",
|
"logging_in": "Giriliyor…",
|
||||||
"login_auth_links": "<0>Hesap oluştur</0> yahut <2>Konuk olarak gir</2>",
|
"login_auth_links": "<0>Hesap oluştur</0> yahut <2>Konuk olarak gir</2>",
|
||||||
"login_title": "Gir",
|
"login_title": "Gir",
|
||||||
|
|||||||
@@ -53,10 +53,8 @@
|
|||||||
"video": "Відео"
|
"video": "Відео"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "Втрачено зв'язок з сервером.",
|
"disconnected_banner": "Втрачено зв'язок з сервером.",
|
||||||
"exit_fullscreen_button_label": "Вийти з повноекранного режиму",
|
|
||||||
"full_screen_view_description": "<0>Надсилання журналів налагодження допоможе нам виявити проблему.</0>",
|
"full_screen_view_description": "<0>Надсилання журналів налагодження допоможе нам виявити проблему.</0>",
|
||||||
"full_screen_view_h1": "<0>Йой, щось пішло не за планом.</0>",
|
"full_screen_view_h1": "<0>Йой, щось пішло не за планом.</0>",
|
||||||
"fullscreen_button_label": "Повноекранний режим",
|
|
||||||
"group_call_loader_failed_heading": "Виклик не знайдено",
|
"group_call_loader_failed_heading": "Виклик не знайдено",
|
||||||
"group_call_loader_failed_text": "Відтепер виклики захищено наскрізним шифруванням, і їх потрібно створювати з домашньої сторінки. Це допомагає переконатися, що всі користувачі використовують один і той самий ключ шифрування.",
|
"group_call_loader_failed_text": "Відтепер виклики захищено наскрізним шифруванням, і їх потрібно створювати з домашньої сторінки. Це допомагає переконатися, що всі користувачі використовують один і той самий ключ шифрування.",
|
||||||
"hangup_button_label": "Завершити виклик",
|
"hangup_button_label": "Завершити виклик",
|
||||||
@@ -77,7 +75,6 @@
|
|||||||
"join_button": "Приєднатися до виклику",
|
"join_button": "Приєднатися до виклику",
|
||||||
"leave_button": "Повернутися до недавніх"
|
"leave_button": "Повернутися до недавніх"
|
||||||
},
|
},
|
||||||
"local_volume_label": "Локальна гучність",
|
|
||||||
"logging_in": "Вхід…",
|
"logging_in": "Вхід…",
|
||||||
"login_auth_links": "<0>Створити обліковий запис</0> або <2>Отримати доступ як гість</2>",
|
"login_auth_links": "<0>Створити обліковий запис</0> або <2>Отримати доступ як гість</2>",
|
||||||
"login_title": "Увійти",
|
"login_title": "Увійти",
|
||||||
@@ -135,7 +132,6 @@
|
|||||||
"unmute_microphone_button_label": "Увімкнути мікрофон",
|
"unmute_microphone_button_label": "Увімкнути мікрофон",
|
||||||
"version": "Версія: {{version}}",
|
"version": "Версія: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"presenter_label": "{{displayName}} представляє",
|
|
||||||
"sfu_participant_local": "Ви"
|
"sfu_participant_local": "Ви"
|
||||||
},
|
},
|
||||||
"waiting_for_participants": "Очікування на інших учасників…"
|
"waiting_for_participants": "Очікування на інших учасників…"
|
||||||
|
|||||||
@@ -29,10 +29,8 @@
|
|||||||
"username": "Tên người dùng",
|
"username": "Tên người dùng",
|
||||||
"video": "Truyền hình"
|
"video": "Truyền hình"
|
||||||
},
|
},
|
||||||
"exit_fullscreen_button_label": "Rời chế độ toàn màn hình",
|
|
||||||
"full_screen_view_description": "<0>Gửi nhật ký gỡ lỗi sẽ giúp chúng tôi theo dõi vấn đề.</0>",
|
"full_screen_view_description": "<0>Gửi nhật ký gỡ lỗi sẽ giúp chúng tôi theo dõi vấn đề.</0>",
|
||||||
"full_screen_view_h1": "<0>Ối, có cái gì đó sai.</0>",
|
"full_screen_view_h1": "<0>Ối, có cái gì đó sai.</0>",
|
||||||
"fullscreen_button_label": "Toàn màn hình",
|
|
||||||
"join_existing_call_modal": {
|
"join_existing_call_modal": {
|
||||||
"join_button": "Vâng, tham gia cuộc gọi",
|
"join_button": "Vâng, tham gia cuộc gọi",
|
||||||
"text": "Cuộc gọi đã tồn tại, bạn có muốn tham gia không?",
|
"text": "Cuộc gọi đã tồn tại, bạn có muốn tham gia không?",
|
||||||
@@ -73,8 +71,5 @@
|
|||||||
"unauthenticated_view_body": "Chưa đăng ký? <2>Tạo tài khoản</2>",
|
"unauthenticated_view_body": "Chưa đăng ký? <2>Tạo tài khoản</2>",
|
||||||
"unauthenticated_view_login_button": "Đăng nhập vào tài khoản của bạn",
|
"unauthenticated_view_login_button": "Đăng nhập vào tài khoản của bạn",
|
||||||
"version": "Phiên bản: {{version}}",
|
"version": "Phiên bản: {{version}}",
|
||||||
"video_tile": {
|
|
||||||
"presenter_label": "{{displayName}} đang trình bày"
|
|
||||||
},
|
|
||||||
"waiting_for_participants": "Đang đợi những người khác…"
|
"waiting_for_participants": "Đang đợi những người khác…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,8 @@
|
|||||||
"video": "视频"
|
"video": "视频"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "与服务器的连接中断。",
|
"disconnected_banner": "与服务器的连接中断。",
|
||||||
"exit_fullscreen_button_label": "退出全屏",
|
|
||||||
"full_screen_view_description": "<0>提交日志以帮助我们修复问题。</0>",
|
"full_screen_view_description": "<0>提交日志以帮助我们修复问题。</0>",
|
||||||
"full_screen_view_h1": "<0>哎哟,出问题了。</0>",
|
"full_screen_view_h1": "<0>哎哟,出问题了。</0>",
|
||||||
"fullscreen_button_label": "全屏",
|
|
||||||
"group_call_loader_failed_heading": "未找到通话",
|
"group_call_loader_failed_heading": "未找到通话",
|
||||||
"group_call_loader_failed_text": "现在,通话是端对端加密的,需要从主页创建。这有助于确保每个人都使用相同的加密密钥。",
|
"group_call_loader_failed_text": "现在,通话是端对端加密的,需要从主页创建。这有助于确保每个人都使用相同的加密密钥。",
|
||||||
"hangup_button_label": "通话结束",
|
"hangup_button_label": "通话结束",
|
||||||
@@ -70,7 +68,6 @@
|
|||||||
"join_button": "加入通话",
|
"join_button": "加入通话",
|
||||||
"leave_button": "返回最近通话"
|
"leave_button": "返回最近通话"
|
||||||
},
|
},
|
||||||
"local_volume_label": "本地音量",
|
|
||||||
"logging_in": "登录中……",
|
"logging_in": "登录中……",
|
||||||
"login_auth_links": "<0>创建账户</0> Or <2>以访客身份继续</2>",
|
"login_auth_links": "<0>创建账户</0> Or <2>以访客身份继续</2>",
|
||||||
"login_title": "登录",
|
"login_title": "登录",
|
||||||
@@ -128,7 +125,6 @@
|
|||||||
"unmute_microphone_button_label": "取消麦克风静音",
|
"unmute_microphone_button_label": "取消麦克风静音",
|
||||||
"version": "版本:{{version}}",
|
"version": "版本:{{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"presenter_label": "{{displayName}}正在展示",
|
|
||||||
"sfu_participant_local": "你"
|
"sfu_participant_local": "你"
|
||||||
},
|
},
|
||||||
"waiting_for_participants": "等待其他参与者……"
|
"waiting_for_participants": "等待其他参与者……"
|
||||||
|
|||||||
@@ -53,10 +53,8 @@
|
|||||||
"video": "視訊"
|
"video": "視訊"
|
||||||
},
|
},
|
||||||
"disconnected_banner": "到伺服器的連線已遺失。",
|
"disconnected_banner": "到伺服器的連線已遺失。",
|
||||||
"exit_fullscreen_button_label": "退出全螢幕",
|
|
||||||
"full_screen_view_description": "<0>送出除錯紀錄,可幫助我們修正問題。</0>",
|
"full_screen_view_description": "<0>送出除錯紀錄,可幫助我們修正問題。</0>",
|
||||||
"full_screen_view_h1": "<0>喔喔,有些地方怪怪的。</0>",
|
"full_screen_view_h1": "<0>喔喔,有些地方怪怪的。</0>",
|
||||||
"fullscreen_button_label": "全螢幕",
|
|
||||||
"group_call_loader_failed_heading": "找不到通話",
|
"group_call_loader_failed_heading": "找不到通話",
|
||||||
"group_call_loader_failed_text": "通話現在是端對端加密的,必須從首頁建立。這有助於確保每個人都使用相同的加密金鑰。",
|
"group_call_loader_failed_text": "通話現在是端對端加密的,必須從首頁建立。這有助於確保每個人都使用相同的加密金鑰。",
|
||||||
"hangup_button_label": "結束通話",
|
"hangup_button_label": "結束通話",
|
||||||
@@ -77,7 +75,6 @@
|
|||||||
"join_button": "加入通話",
|
"join_button": "加入通話",
|
||||||
"leave_button": "回到最近的通話"
|
"leave_button": "回到最近的通話"
|
||||||
},
|
},
|
||||||
"local_volume_label": "您的音量",
|
|
||||||
"logging_in": "登入中…",
|
"logging_in": "登入中…",
|
||||||
"login_auth_links": "<0>建立帳號</0> 或<2>以訪客身份登入</2>",
|
"login_auth_links": "<0>建立帳號</0> 或<2>以訪客身份登入</2>",
|
||||||
"login_title": "登入",
|
"login_title": "登入",
|
||||||
@@ -135,7 +132,6 @@
|
|||||||
"unmute_microphone_button_label": "將麥克風取消靜音",
|
"unmute_microphone_button_label": "將麥克風取消靜音",
|
||||||
"version": "版本: {{version}}",
|
"version": "版本: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"presenter_label": "{{displayName}} 正在展示",
|
|
||||||
"sfu_participant_local": "您"
|
"sfu_participant_local": "您"
|
||||||
},
|
},
|
||||||
"waiting_for_participants": "等待其他參加者…"
|
"waiting_for_participants": "等待其他參加者…"
|
||||||
|
|||||||
@@ -3,9 +3,45 @@
|
|||||||
"extends": ["config:base"],
|
"extends": ["config:base"],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"description": "Disable renoavte for packages we want to monitor ourselves",
|
"groupName": "all non-major dependencies",
|
||||||
"matchPackagePatterns": ["matrix-js-sdk"],
|
"groupSlug": "all-minor-patch",
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"extends": ["schedule:weekly"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "GitHub Actions",
|
||||||
|
"matchDepTypes": ["action"],
|
||||||
|
"pinDigests": true,
|
||||||
|
"extends": ["schedule:monthly"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Disable Renovate for packages we want to monitor ourselves",
|
||||||
|
"groupName": "manually updated packages",
|
||||||
|
"matchDepNames": ["matrix-js-sdk"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "matrix-widget-api",
|
||||||
|
"matchDepNames": ["matrix-widget-api"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "Compound",
|
||||||
|
"matchPackagePrefixes": ["@vector-im/compound-"],
|
||||||
|
"schedule": "before 5am on Tuesday and Friday"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "LiveKit client",
|
||||||
|
"matchDepNames": ["livekit-client"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "LiveKit components",
|
||||||
|
"matchPackagePrefixes": ["@livekit/components-"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "Vaul",
|
||||||
|
"matchDepNames": ["vaul"],
|
||||||
|
"extends": ["schedule:monthly"],
|
||||||
|
"prHeader": "Please review modals on mobile for visual regressions."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"semanticCommits": "disabled",
|
"semanticCommits": "disabled",
|
||||||
|
|||||||
11
src/@types/global.d.ts
vendored
11
src/@types/global.d.ts
vendored
@@ -23,17 +23,6 @@ declare global {
|
|||||||
webkitFullscreenElement: HTMLElement | null;
|
webkitFullscreenElement: HTMLElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HTMLElement {
|
interface HTMLElement {
|
||||||
// Safari only supports this prefixed, so tell the type system about it
|
// Safari only supports this prefixed, so tell the type system about it
|
||||||
webkitRequestFullscreen: () => void;
|
webkitRequestFullscreen: () => void;
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ export const App: FC<AppProps> = ({ history }) => {
|
|||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Initializer.init()?.then(() => {
|
Initializer.init()?.then(() => {
|
||||||
|
if (loaded) return;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
|
widget?.api.sendContentLoaded();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
ICreateClientOpts,
|
||||||
|
MatrixClient,
|
||||||
|
} from "matrix-js-sdk/src/client";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
||||||
@@ -317,7 +321,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return (): void => {
|
||||||
if (initClientState.client) {
|
if (initClientState.client) {
|
||||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||||
}
|
}
|
||||||
@@ -360,13 +364,13 @@ async function loadClient(): Promise<InitResult | null> {
|
|||||||
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
const { user_id, device_id, access_token, passwordlessUser } = session;
|
const { user_id, device_id, access_token, passwordlessUser } = session;
|
||||||
const initClientParams = {
|
const initClientParams: ICreateClientOpts = {
|
||||||
baseUrl: Config.defaultHomeserverUrl()!,
|
baseUrl: Config.defaultHomeserverUrl()!,
|
||||||
accessToken: access_token,
|
accessToken: access_token,
|
||||||
userId: user_id,
|
userId: user_id,
|
||||||
deviceId: device_id,
|
deviceId: device_id,
|
||||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||||
livekitServiceURL: Config.get().livekit!.livekit_service_url,
|
livekitServiceURL: Config.get().livekit?.livekit_service_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import styles from "./FullScreenView.module.css";
|
|||||||
import { TranslatedError } from "./TranslatedError";
|
import { TranslatedError } from "./TranslatedError";
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
import { RageshakeButton } from "./settings/RageshakeButton";
|
import { RageshakeButton } from "./settings/RageshakeButton";
|
||||||
|
import { useUrlParams } from "./UrlParams";
|
||||||
|
|
||||||
interface FullScreenViewProps {
|
interface FullScreenViewProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -37,12 +38,11 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { hideHeader } = useUrlParams();
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.page, className)}>
|
<div className={classNames(styles.page, className)}>
|
||||||
<Header>
|
<Header>
|
||||||
<LeftNav>
|
<LeftNav>{!hideHeader && <HeaderLogo />}</LeftNav>
|
||||||
<HeaderLogo />
|
|
||||||
</LeftNav>
|
|
||||||
<RightNav />
|
<RightNav />
|
||||||
</Header>
|
</Header>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -58,6 +58,7 @@ interface ErrorViewProps {
|
|||||||
|
|
||||||
export const ErrorView: FC<ErrorViewProps> = ({ error }) => {
|
export const ErrorView: FC<ErrorViewProps> = ({ error }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { confineToRoom } = useUrlParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,25 +79,26 @@ export const ErrorView: FC<ErrorViewProps> = ({ error }) => {
|
|||||||
: error.message}
|
: error.message}
|
||||||
</p>
|
</p>
|
||||||
<RageshakeButton description={`***Error View***: ${error.message}`} />
|
<RageshakeButton description={`***Error View***: ${error.message}`} />
|
||||||
{location.pathname === "/" ? (
|
{!confineToRoom &&
|
||||||
<Button
|
(location.pathname === "/" ? (
|
||||||
size="lg"
|
<Button
|
||||||
variant="default"
|
size="lg"
|
||||||
className={styles.homeLink}
|
variant="default"
|
||||||
onPress={onReload}
|
className={styles.homeLink}
|
||||||
>
|
onPress={onReload}
|
||||||
{t("return_home_button")}
|
>
|
||||||
</Button>
|
{t("return_home_button")}
|
||||||
) : (
|
</Button>
|
||||||
<LinkButton
|
) : (
|
||||||
size="lg"
|
<LinkButton
|
||||||
variant="default"
|
size="lg"
|
||||||
className={styles.homeLink}
|
variant="default"
|
||||||
to="/"
|
className={styles.homeLink}
|
||||||
>
|
to="/"
|
||||||
{t("return_home_button")}
|
>
|
||||||
</LinkButton>
|
{t("return_home_button")}
|
||||||
)}
|
</LinkButton>
|
||||||
|
))}
|
||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2022 New Vector Ltd
|
Copyright 2022-2024 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.
|
||||||
@@ -90,6 +90,7 @@ limitations under the License.
|
|||||||
.nameLine {
|
.nameLine {
|
||||||
grid-area: name;
|
grid-area: name;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--cpd-space-1x);
|
gap: var(--cpd-space-1x);
|
||||||
@@ -97,8 +98,6 @@ limitations under the License.
|
|||||||
|
|
||||||
.nameLine > h1 {
|
.nameLine > h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
/* XXX I can't actually get this ellipsis overflow to trigger, because
|
|
||||||
constraint propagation in a nested flexbox layout is a massive pain */
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
@@ -109,6 +108,7 @@ limitations under the License.
|
|||||||
|
|
||||||
.participantsLine {
|
.participantsLine {
|
||||||
grid-area: participants;
|
grid-area: participants;
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--cpd-space-1-5x);
|
gap: var(--cpd-space-1-5x);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2022 New Vector Ltd
|
Copyright 2022-2024 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.
|
||||||
@@ -15,11 +15,11 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
import { FC, HTMLAttributes, ReactNode, forwardRef } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Heading, Text } from "@vector-im/compound-web";
|
import { Heading, Text } from "@vector-im/compound-web";
|
||||||
import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg?react";
|
import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import styles from "./Header.module.css";
|
import styles from "./Header.module.css";
|
||||||
import Logo from "./icons/Logo.svg?react";
|
import Logo from "./icons/Logo.svg?react";
|
||||||
@@ -32,13 +32,21 @@ interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header: FC<HeaderProps> = ({ children, className, ...rest }) => {
|
export const Header = forwardRef<HTMLElement, HeaderProps>(
|
||||||
return (
|
({ children, className, ...rest }, ref) => {
|
||||||
<header className={classNames(styles.header, className)} {...rest}>
|
return (
|
||||||
{children}
|
<header
|
||||||
</header>
|
ref={ref}
|
||||||
);
|
className={classNames(styles.header, className)}
|
||||||
};
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Header.displayName = "Header";
|
||||||
|
|
||||||
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
|
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -117,7 +125,7 @@ interface RoomHeaderInfoProps {
|
|||||||
name: string;
|
name: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
encrypted: boolean;
|
encrypted: boolean;
|
||||||
participantCount: number;
|
participantCount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||||
@@ -150,7 +158,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
|||||||
</Heading>
|
</Heading>
|
||||||
<EncryptionLock encrypted={encrypted} />
|
<EncryptionLock encrypted={encrypted} />
|
||||||
</div>
|
</div>
|
||||||
{participantCount > 0 && (
|
{(participantCount ?? 0) > 0 && (
|
||||||
<div className={styles.participantsLine}>
|
<div className={styles.participantsLine}>
|
||||||
<UserProfileIcon
|
<UserProfileIcon
|
||||||
width={20}
|
width={20}
|
||||||
@@ -158,7 +166,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
|||||||
aria-label={t("header_participants_label")}
|
aria-label={t("header_participants_label")}
|
||||||
/>
|
/>
|
||||||
<Text as="span" size="sm" weight="medium">
|
<Text as="span" size="sm" weight="medium">
|
||||||
{t("participant_count", { count: participantCount })}
|
{t("participant_count", { count: participantCount ?? 0 })}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
} from "@radix-ui/react-dialog";
|
} from "@radix-ui/react-dialog";
|
||||||
import { Drawer } from "vaul";
|
import { Drawer } from "vaul";
|
||||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import CloseIcon from "@vector-im/compound-design-tokens/icons/close.svg?react";
|
import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { Heading, Glass } from "@vector-im/compound-web";
|
import { Heading, Glass } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,8 @@ if (/android/i.test(navigator.userAgent)) {
|
|||||||
} else {
|
} else {
|
||||||
platform = "desktop";
|
platform = "desktop";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isFirefox = (): boolean => {
|
||||||
|
const { userAgent } = navigator;
|
||||||
|
return userAgent.includes("Firefox");
|
||||||
|
};
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const Toast: FC<Props> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && autoDismiss !== undefined) {
|
if (open && autoDismiss !== undefined) {
|
||||||
const timeout = setTimeout(onDismiss, autoDismiss);
|
const timeout = setTimeout(onDismiss, autoDismiss);
|
||||||
return () => clearTimeout(timeout);
|
return (): void => clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}, [open, autoDismiss, onDismiss]);
|
}, [open, autoDismiss, onDismiss]);
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ limitations under the License.
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
|
import { EncryptionSystem } from "./e2ee/sharedKeyManagement";
|
||||||
export const PASSWORD_STRING = "password=";
|
import { E2eeType } from "./e2ee/e2eeType";
|
||||||
|
|
||||||
interface RoomIdentifier {
|
interface RoomIdentifier {
|
||||||
roomAlias: string | null;
|
roomAlias: string | null;
|
||||||
@@ -328,3 +329,32 @@ export const useRoomIdentifier = (): RoomIdentifier => {
|
|||||||
[pathname, search, hash],
|
[pathname, search, hash],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function generateUrlSearchParams(
|
||||||
|
roomId: string,
|
||||||
|
encryptionSystem: EncryptionSystem,
|
||||||
|
viaServers?: string[],
|
||||||
|
): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
// The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
|
||||||
|
// it in case it came from another client that generated a non url-safe one
|
||||||
|
switch (encryptionSystem?.kind) {
|
||||||
|
case E2eeType.SHARED_KEY: {
|
||||||
|
const encodedPassword = encodeURIComponent(encryptionSystem.secret);
|
||||||
|
if (encodedPassword !== encryptionSystem.secret) {
|
||||||
|
logger.info(
|
||||||
|
"Encoded call password used non URL-safe chars: buggy client?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.set("password", encodedPassword);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case E2eeType.PER_PARTICIPANT:
|
||||||
|
params.set("perParticipantE2EE", "true");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
params.set("roomId", roomId);
|
||||||
|
viaServers?.forEach((s) => params.set("viaServers", s));
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { MatrixClient } from "matrix-js-sdk";
|
|||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
|
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { getSetting, setSetting, getSettingKey } from "../settings/useSetting";
|
|
||||||
import {
|
import {
|
||||||
CallEndedTracker,
|
CallEndedTracker,
|
||||||
CallStartedTracker,
|
CallStartedTracker,
|
||||||
@@ -31,10 +30,11 @@ import {
|
|||||||
UndecryptableToDeviceEventTracker,
|
UndecryptableToDeviceEventTracker,
|
||||||
QualitySurveyEventTracker,
|
QualitySurveyEventTracker,
|
||||||
CallDisconnectedEventTracker,
|
CallDisconnectedEventTracker,
|
||||||
|
CallConnectDurationTracker,
|
||||||
} from "./PosthogEvents";
|
} from "./PosthogEvents";
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { getUrlParams } from "../UrlParams";
|
import { getUrlParams } from "../UrlParams";
|
||||||
import { localStorageBus } from "../useLocalStorage";
|
import { optInAnalytics } from "../settings/settings";
|
||||||
|
|
||||||
/* Posthog analytics tracking.
|
/* Posthog analytics tracking.
|
||||||
*
|
*
|
||||||
@@ -130,7 +130,7 @@ export class PosthogAnalytics {
|
|||||||
const { analyticsID } = getUrlParams();
|
const { analyticsID } = getUrlParams();
|
||||||
// if the embedding platform (element web) already got approval to communicating with posthog
|
// if the embedding platform (element web) already got approval to communicating with posthog
|
||||||
// element call can also send events to posthog
|
// element call can also send events to posthog
|
||||||
setSetting("opt-in-analytics", Boolean(analyticsID));
|
optInAnalytics.setValue(Boolean(analyticsID));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.posthog.init(posthogConfig.project_api_key, {
|
this.posthog.init(posthogConfig.project_api_key, {
|
||||||
@@ -150,9 +150,7 @@ export class PosthogAnalytics {
|
|||||||
);
|
);
|
||||||
this.enabled = false;
|
this.enabled = false;
|
||||||
}
|
}
|
||||||
this.startListeningToSettingsChanges();
|
this.startListeningToSettingsChanges(); // Triggers maybeIdentifyUser
|
||||||
const optInAnalytics = getSetting("opt-in-analytics", false);
|
|
||||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeProperties = (
|
private sanitizeProperties = (
|
||||||
@@ -335,8 +333,7 @@ export class PosthogAnalytics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onLoginStatusChanged(): void {
|
public onLoginStatusChanged(): void {
|
||||||
const optInAnalytics = getSetting("opt-in-analytics", false);
|
this.maybeIdentifyUser();
|
||||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSuperProperties(): void {
|
private updateSuperProperties(): void {
|
||||||
@@ -359,20 +356,12 @@ export class PosthogAnalytics {
|
|||||||
return this.eventSignup.getSignupEndTime() > new Date(0);
|
return this.eventSignup.getSignupEndTime() > new Date(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateAnonymityAndIdentifyUser(
|
private async maybeIdentifyUser(): Promise<void> {
|
||||||
pseudonymousOptIn: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
// Update this.anonymity based on the user's analytics opt-in settings
|
|
||||||
const anonymity = pseudonymousOptIn
|
|
||||||
? Anonymity.Pseudonymous
|
|
||||||
: Anonymity.Disabled;
|
|
||||||
this.setAnonymity(anonymity);
|
|
||||||
|
|
||||||
// We may not yet have a Matrix client at this point, if not, bail. This should get
|
// We may not yet have a Matrix client at this point, if not, bail. This should get
|
||||||
// triggered again by onLoginStatusChanged once we do have a client.
|
// triggered again by onLoginStatusChanged once we do have a client.
|
||||||
if (!window.matrixclient) return;
|
if (!window.matrixclient) return;
|
||||||
|
|
||||||
if (anonymity === Anonymity.Pseudonymous) {
|
if (this.anonymity === Anonymity.Pseudonymous) {
|
||||||
this.setRegistrationType(
|
this.setRegistrationType(
|
||||||
window.matrixclient.isGuest() || window.passwordlessUser
|
window.matrixclient.isGuest() || window.passwordlessUser
|
||||||
? RegistrationType.Guest
|
? RegistrationType.Guest
|
||||||
@@ -388,7 +377,7 @@ export class PosthogAnalytics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anonymity !== Anonymity.Disabled) {
|
if (this.anonymity !== Anonymity.Disabled) {
|
||||||
this.updateSuperProperties();
|
this.updateSuperProperties();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,8 +407,9 @@ export class PosthogAnalytics {
|
|||||||
// * When the user changes their preferences on this device
|
// * When the user changes their preferences on this device
|
||||||
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
|
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
|
||||||
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
|
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
|
||||||
localStorageBus.on(getSettingKey("opt-in-analytics"), (optInAnalytics) => {
|
optInAnalytics.value.subscribe((optIn) => {
|
||||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
|
||||||
|
this.maybeIdentifyUser();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,4 +434,5 @@ export class PosthogAnalytics {
|
|||||||
public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker();
|
public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker();
|
||||||
public eventQualitySurvey = new QualitySurveyEventTracker();
|
public eventQualitySurvey = new QualitySurveyEventTracker();
|
||||||
public eventCallDisconnected = new CallDisconnectedEventTracker();
|
public eventCallDisconnected = new CallDisconnectedEventTracker();
|
||||||
|
public eventCallConnectDuration = new CallConnectDurationTracker();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DisconnectReason } from "livekit-client";
|
import { DisconnectReason } from "livekit-client";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IPosthogEvent,
|
IPosthogEvent,
|
||||||
@@ -201,3 +202,38 @@ export class CallDisconnectedEventTracker {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CallConnectDuration extends IPosthogEvent {
|
||||||
|
eventName: "CallConnectDuration";
|
||||||
|
totalDuration: number;
|
||||||
|
websocketDuration: number;
|
||||||
|
peerConnectionDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CallConnectDurationTracker {
|
||||||
|
private connectStart = 0;
|
||||||
|
private websocketConnected = 0;
|
||||||
|
public cacheConnectStart(): void {
|
||||||
|
this.connectStart = Date.now();
|
||||||
|
}
|
||||||
|
public cacheWsConnect(): void {
|
||||||
|
this.websocketConnected = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public track(options = { log: false }): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const totalDuration = now - this.connectStart;
|
||||||
|
const websocketDuration = this.websocketConnected - this.connectStart;
|
||||||
|
const peerConnectionDuration = now - this.websocketConnected;
|
||||||
|
PosthogAnalytics.instance.trackEvent<CallConnectDuration>({
|
||||||
|
eventName: "CallConnectDuration",
|
||||||
|
totalDuration,
|
||||||
|
websocketDuration,
|
||||||
|
peerConnectionDuration,
|
||||||
|
});
|
||||||
|
if (options.log)
|
||||||
|
logger.log(
|
||||||
|
`Time to connect:\ntotal: ${totalDuration}ms\npeerConnection: ${websocketDuration}ms\nwebsocket: ${peerConnectionDuration}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ export const LoginPage: FC = () => {
|
|||||||
},
|
},
|
||||||
[login, location, history, homeserver, setClient],
|
[login, location, history, homeserver, setClient],
|
||||||
);
|
);
|
||||||
|
// we need to limit the length of the homserver name to not cover the whole loginview input with the string.
|
||||||
|
let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25);
|
||||||
|
shortendHomeserverName =
|
||||||
|
shortendHomeserverName?.length !== Config.defaultServerName()?.length
|
||||||
|
? shortendHomeserverName + "..."
|
||||||
|
: shortendHomeserverName;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -102,7 +107,7 @@ export const LoginPage: FC = () => {
|
|||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
prefix="@"
|
prefix="@"
|
||||||
suffix={`:${Config.defaultServerName()}`}
|
suffix={`:${shortendHomeserverName}`}
|
||||||
data-testid="login_username"
|
data-testid="login_username"
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ limitations under the License.
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||||
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
import {
|
||||||
|
createClient,
|
||||||
|
LoginResponse,
|
||||||
|
MatrixClient,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { initClient } from "../matrix-utils";
|
import { initClient } from "../matrix-utils";
|
||||||
import { Session } from "../ClientContext";
|
import { Session } from "../ClientContext";
|
||||||
@@ -37,7 +41,7 @@ export function useInteractiveLogin(): (
|
|||||||
|
|
||||||
const interactiveAuth = new InteractiveAuth({
|
const interactiveAuth = new InteractiveAuth({
|
||||||
matrixClient: authClient,
|
matrixClient: authClient,
|
||||||
doRequest: () =>
|
doRequest: (): Promise<LoginResponse> =>
|
||||||
authClient.login("m.login.password", {
|
authClient.login("m.login.password", {
|
||||||
identifier: {
|
identifier: {
|
||||||
type: "m.id.user",
|
type: "m.id.user",
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ limitations under the License.
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||||
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
import {
|
||||||
|
createClient,
|
||||||
|
MatrixClient,
|
||||||
|
RegisterResponse,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { initClient } from "../matrix-utils";
|
import { initClient } from "../matrix-utils";
|
||||||
import { Session } from "../ClientContext";
|
import { Session } from "../ClientContext";
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
|
import { widget } from "../widget";
|
||||||
|
|
||||||
export const useInteractiveRegistration = (): {
|
export const useInteractiveRegistration = (): {
|
||||||
privacyPolicyUrl?: string;
|
privacyPolicyUrl?: string;
|
||||||
@@ -48,6 +53,8 @@ export const useInteractiveRegistration = (): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (widget) return;
|
||||||
|
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||||
authClient.current!.registerRequest({}).catch((error) => {
|
authClient.current!.registerRequest({}).catch((error) => {
|
||||||
setPrivacyPolicyUrl(
|
setPrivacyPolicyUrl(
|
||||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url,
|
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url,
|
||||||
@@ -66,7 +73,7 @@ export const useInteractiveRegistration = (): {
|
|||||||
): Promise<[MatrixClient, Session]> => {
|
): Promise<[MatrixClient, Session]> => {
|
||||||
const interactiveAuth = new InteractiveAuth({
|
const interactiveAuth = new InteractiveAuth({
|
||||||
matrixClient: authClient.current!,
|
matrixClient: authClient.current!,
|
||||||
doRequest: (auth) =>
|
doRequest: (auth): Promise<RegisterResponse> =>
|
||||||
authClient.current!.registerRequest({
|
authClient.current!.registerRequest({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ import { useButton } from "@react-aria/button";
|
|||||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Tooltip } from "@vector-im/compound-web";
|
import { Tooltip } from "@vector-im/compound-web";
|
||||||
import MicOnSolidIcon from "@vector-im/compound-design-tokens/icons/mic-on-solid.svg?react";
|
import {
|
||||||
import MicOffSolidIcon from "@vector-im/compound-design-tokens/icons/mic-off-solid.svg?react";
|
MicOnSolidIcon,
|
||||||
import VideoCallSolidIcon from "@vector-im/compound-design-tokens/icons/video-call-solid.svg?react";
|
MicOffSolidIcon,
|
||||||
import VideoCallOffSolidIcon from "@vector-im/compound-design-tokens/icons/video-call-off-solid.svg?react";
|
VideoCallSolidIcon,
|
||||||
import EndCallIcon from "@vector-im/compound-design-tokens/icons/end-call.svg?react";
|
VideoCallOffSolidIcon,
|
||||||
import ShareScreenSolidIcon from "@vector-im/compound-design-tokens/icons/share-screen-solid.svg?react";
|
EndCallIcon,
|
||||||
import SettingsSolidIcon from "@vector-im/compound-design-tokens/icons/settings-solid.svg?react";
|
ShareScreenSolidIcon,
|
||||||
import ChevronDownIcon from "@vector-im/compound-design-tokens/icons/chevron-down.svg?react";
|
SettingsSolidIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import styles from "./Button.module.css";
|
import styles from "./Button.module.css";
|
||||||
|
|
||||||
@@ -149,7 +151,7 @@ export const MicButton: FC<{
|
|||||||
return (
|
return (
|
||||||
<Tooltip label={label}>
|
<Tooltip label={label}>
|
||||||
<Button variant="toolbar" {...rest} on={muted}>
|
<Button variant="toolbar" {...rest} on={muted}>
|
||||||
<Icon aria-label={label} />
|
<Icon aria-hidden width={24} height={24} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -169,7 +171,7 @@ export const VideoButton: FC<{
|
|||||||
return (
|
return (
|
||||||
<Tooltip label={label}>
|
<Tooltip label={label}>
|
||||||
<Button variant="toolbar" {...rest} on={muted}>
|
<Button variant="toolbar" {...rest} on={muted}>
|
||||||
<Icon aria-label={label} />
|
<Icon aria-hidden width={24} height={24} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -189,7 +191,7 @@ export const ScreenshareButton: FC<{
|
|||||||
return (
|
return (
|
||||||
<Tooltip label={label}>
|
<Tooltip label={label}>
|
||||||
<Button variant="toolbar" {...rest} on={enabled}>
|
<Button variant="toolbar" {...rest} on={enabled}>
|
||||||
<ShareScreenSolidIcon aria-label={label} />
|
<ShareScreenSolidIcon aria-hidden width={24} height={24} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -209,7 +211,7 @@ export const HangupButton: FC<{
|
|||||||
className={classNames(styles.hangupButton, className)}
|
className={classNames(styles.hangupButton, className)}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<EndCallIcon aria-label={t("hangup_button_label")} />
|
<EndCallIcon aria-hidden width={24} height={24} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -225,7 +227,7 @@ export const SettingsButton: FC<{
|
|||||||
return (
|
return (
|
||||||
<Tooltip label={t("common.settings")}>
|
<Tooltip label={t("common.settings")}>
|
||||||
<Button variant="toolbar" {...rest}>
|
<Button variant="toolbar" {...rest}>
|
||||||
<SettingsSolidIcon aria-label={t("common.settings")} />
|
<SettingsSolidIcon aria-hidden width={24} height={24} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ limitations under the License.
|
|||||||
import { ComponentPropsWithoutRef, FC } from "react";
|
import { ComponentPropsWithoutRef, FC } from "react";
|
||||||
import { Button } from "@vector-im/compound-web";
|
import { Button } from "@vector-im/compound-web";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import UserAddIcon from "@vector-im/compound-design-tokens/icons/user-add.svg?react";
|
import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
export const InviteButton: FC<
|
export const InviteButton: FC<
|
||||||
Omit<ComponentPropsWithoutRef<"button">, "children">
|
Omit<ComponentPropsWithoutRef<"button">, "children">
|
||||||
|
|||||||
@@ -55,16 +55,31 @@ export interface ConfigOptions {
|
|||||||
|
|
||||||
// Describes the LiveKit configuration to be used.
|
// Describes the LiveKit configuration to be used.
|
||||||
livekit?: {
|
livekit?: {
|
||||||
// The link to the service that returns a livekit url and token to use it
|
// The link to the service that returns a livekit url and token to use it.
|
||||||
|
// This is a fallback link in case the homeserver in use does not advertise
|
||||||
|
// a livekit service url in the client well-known.
|
||||||
|
// The well known needs to be formatted like so:
|
||||||
|
// {"type":"livekit", "livekit_service_url":"https://livekit.example.com"}
|
||||||
|
// and stored under the key: "livekit_focus"
|
||||||
livekit_service_url: string;
|
livekit_service_url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow to join a group calls without audio and video.
|
* TEMPORARY experimental features.
|
||||||
* TEMPORARY: Is a feature that's not proved and experimental
|
|
||||||
*/
|
*/
|
||||||
features?: {
|
features?: {
|
||||||
feature_group_calls_without_video_and_audio: boolean;
|
/**
|
||||||
|
* Allow to join group calls without audio and video.
|
||||||
|
*/
|
||||||
|
feature_group_calls_without_video_and_audio?: boolean;
|
||||||
|
/**
|
||||||
|
* Send device-specific call session membership state events instead of
|
||||||
|
* legacy user-specific call membership state events.
|
||||||
|
* This setting has no effect when the user joins an active call with
|
||||||
|
* legacy state events. For compatibility, Element Call will always join
|
||||||
|
* active legacy calls with legacy state events.
|
||||||
|
*/
|
||||||
|
feature_use_device_session_member_events?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Room } from "matrix-js-sdk";
|
|
||||||
|
|
||||||
import { setLocalStorageItem, useLocalStorage } from "../useLocalStorage";
|
import { setLocalStorageItem, useLocalStorage } from "../useLocalStorage";
|
||||||
import { useClient } from "../ClientContext";
|
|
||||||
import { UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
|
import { UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
|
||||||
import { widget } from "../widget";
|
import { E2eeType } from "./e2eeType";
|
||||||
|
import { useClient } from "../ClientContext";
|
||||||
|
|
||||||
export function saveKeyForRoom(roomId: string, password: string): void {
|
export function saveKeyForRoom(roomId: string, password: string): void {
|
||||||
setLocalStorageItem(getRoomSharedKeyLocalStorageKey(roomId), password);
|
setLocalStorageItem(getRoomSharedKeyLocalStorageKey(roomId), password);
|
||||||
@@ -68,30 +67,37 @@ const useKeyFromUrl = (): [string, string] | [undefined, undefined] => {
|
|||||||
: [undefined, undefined];
|
: [undefined, undefined];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRoomSharedKey = (roomId: string): string | undefined => {
|
export type Unencrypted = { kind: E2eeType.NONE };
|
||||||
|
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||||
|
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||||
|
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||||
|
|
||||||
|
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||||
|
const { client } = useClient();
|
||||||
|
|
||||||
// make sure we've extracted the key from the URL first
|
// make sure we've extracted the key from the URL first
|
||||||
// (and we still need to take the value it returns because
|
// (and we still need to take the value it returns because
|
||||||
// the effect won't run in time for it to save to localstorage in
|
// the effect won't run in time for it to save to localstorage in
|
||||||
// time for us to read it out again).
|
// time for us to read it out again).
|
||||||
const [urlRoomId, passwordFormUrl] = useKeyFromUrl();
|
const [urlRoomId, passwordFromUrl] = useKeyFromUrl();
|
||||||
|
|
||||||
const storedPassword = useInternalRoomSharedKey(roomId);
|
const storedPassword = useInternalRoomSharedKey(roomId);
|
||||||
|
const room = client?.getRoom(roomId);
|
||||||
if (storedPassword) return storedPassword;
|
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
||||||
if (urlRoomId === roomId) return passwordFormUrl;
|
if (!room) return { kind: E2eeType.NONE };
|
||||||
return undefined;
|
if (storedPassword)
|
||||||
};
|
return {
|
||||||
|
kind: E2eeType.SHARED_KEY,
|
||||||
export const useIsRoomE2EE = (roomId: string): boolean | null => {
|
secret: storedPassword,
|
||||||
const { client } = useClient();
|
};
|
||||||
const room = useMemo(() => client?.getRoom(roomId), [roomId, client]);
|
if (urlRoomId === roomId)
|
||||||
|
return {
|
||||||
return useMemo(() => !room || isRoomE2EE(room), [room]);
|
kind: E2eeType.SHARED_KEY,
|
||||||
};
|
secret: passwordFromUrl,
|
||||||
|
};
|
||||||
export function isRoomE2EE(room: Room): boolean {
|
if (room.hasEncryptionStateEvent()) {
|
||||||
// For now, rooms in widget mode are never considered encrypted.
|
return { kind: E2eeType.PER_PARTICIPANT };
|
||||||
// In the future, when widget mode gains encryption support, then perhaps we
|
}
|
||||||
// should inspect the e2eEnabled URL parameter here?
|
return { kind: E2eeType.NONE };
|
||||||
return widget === null && !room.getCanonicalAlias();
|
}, [passwordFromUrl, room, roomId, storedPassword, urlRoomId]);
|
||||||
|
return e2eeSystem;
|
||||||
}
|
}
|
||||||
|
|||||||
150
src/grid/CallLayout.ts
Normal file
150
src/grid/CallLayout.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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 { BehaviorSubject, Observable } from "rxjs";
|
||||||
|
import { ComponentType } from "react";
|
||||||
|
|
||||||
|
import { MediaViewModel, UserMediaViewModel } from "../state/MediaViewModel";
|
||||||
|
import { LayoutProps } from "./Grid";
|
||||||
|
|
||||||
|
export interface Bounds {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Alignment {
|
||||||
|
inline: "start" | "end";
|
||||||
|
block: "start" | "end";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSpotlightAlignment: Alignment = {
|
||||||
|
inline: "end",
|
||||||
|
block: "end",
|
||||||
|
};
|
||||||
|
export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };
|
||||||
|
|
||||||
|
export interface CallLayoutInputs {
|
||||||
|
/**
|
||||||
|
* The minimum bounds of the layout area.
|
||||||
|
*/
|
||||||
|
minBounds: Observable<Bounds>;
|
||||||
|
/**
|
||||||
|
* The alignment of the floating spotlight tile, if present.
|
||||||
|
*/
|
||||||
|
spotlightAlignment: BehaviorSubject<Alignment>;
|
||||||
|
/**
|
||||||
|
* The alignment of the small picture-in-picture tile, if present.
|
||||||
|
*/
|
||||||
|
pipAlignment: BehaviorSubject<Alignment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridTileModel {
|
||||||
|
type: "grid";
|
||||||
|
vm: UserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotlightTileModel {
|
||||||
|
type: "spotlight";
|
||||||
|
vms: MediaViewModel[];
|
||||||
|
maximised: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TileModel = GridTileModel | SpotlightTileModel;
|
||||||
|
|
||||||
|
export interface CallLayoutOutputs<Model> {
|
||||||
|
/**
|
||||||
|
* Whether the scrolling layer of the layout should appear on top.
|
||||||
|
*/
|
||||||
|
scrollingOnTop: boolean;
|
||||||
|
/**
|
||||||
|
* The visually fixed (non-scrolling) layer of the layout.
|
||||||
|
*/
|
||||||
|
fixed: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
|
||||||
|
/**
|
||||||
|
* The layer of the layout that can overflow and be scrolled.
|
||||||
|
*/
|
||||||
|
scrolling: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A layout system for media tiles.
|
||||||
|
*/
|
||||||
|
export type CallLayout<Model> = (
|
||||||
|
inputs: CallLayoutInputs,
|
||||||
|
) => CallLayoutOutputs<Model>;
|
||||||
|
|
||||||
|
export interface GridArrangement {
|
||||||
|
tileWidth: number;
|
||||||
|
tileHeight: number;
|
||||||
|
gap: number;
|
||||||
|
columns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileMaxAspectRatio = 17 / 9;
|
||||||
|
const tileMinAspectRatio = 4 / 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the ideal arrangement of tiles into a grid of a particular size.
|
||||||
|
*/
|
||||||
|
export function arrangeTiles(
|
||||||
|
width: number,
|
||||||
|
minHeight: number,
|
||||||
|
tileCount: number,
|
||||||
|
): GridArrangement {
|
||||||
|
// The goal here is to determine the grid size and padding that maximizes
|
||||||
|
// use of screen space for n tiles without making those tiles too small or
|
||||||
|
// too cropped (having an extreme aspect ratio)
|
||||||
|
const gap = width < 800 ? 16 : 20;
|
||||||
|
const area = width * minHeight;
|
||||||
|
// Magic numbers that make tiles scale up nicely as the window gets larger
|
||||||
|
const tileArea = Math.pow(Math.sqrt(area) / 8 + 125, 2);
|
||||||
|
const tilesPerPage = Math.min(tileCount, area / tileArea);
|
||||||
|
|
||||||
|
let columns = Math.min(
|
||||||
|
// Don't create more columns than we have items for
|
||||||
|
tilesPerPage,
|
||||||
|
// The ideal number of columns is given by a packing of equally-sized
|
||||||
|
// squares into a grid.
|
||||||
|
// width / column = height / row.
|
||||||
|
// columns * rows = number of squares.
|
||||||
|
// ∴ columns = sqrt(width / height * number of squares).
|
||||||
|
// Except we actually want 16:9-ish tiles rather than squares, so we
|
||||||
|
// divide the width-to-height ratio by the target aspect ratio.
|
||||||
|
Math.round(
|
||||||
|
Math.sqrt((width / minHeight / tileMinAspectRatio) * tilesPerPage),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let rows = tilesPerPage / columns;
|
||||||
|
// If all the tiles could fit on one page, we want to ensure that they do by
|
||||||
|
// not leaving fractional rows hanging off the bottom
|
||||||
|
if (tilesPerPage === tileCount) {
|
||||||
|
rows = Math.ceil(rows);
|
||||||
|
// We may now be able to fit the tiles into fewer columns
|
||||||
|
columns = Math.ceil(tileCount / rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tileWidth = (width - (columns + 1) * gap) / columns;
|
||||||
|
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
|
||||||
|
|
||||||
|
// Impose a minimum and maximum aspect ratio on the tiles
|
||||||
|
const tileAspectRatio = tileWidth / tileHeight;
|
||||||
|
if (tileAspectRatio > tileMaxAspectRatio)
|
||||||
|
tileWidth = tileHeight * tileMaxAspectRatio;
|
||||||
|
else if (tileAspectRatio < tileMinAspectRatio)
|
||||||
|
tileHeight = tileWidth / tileMinAspectRatio;
|
||||||
|
|
||||||
|
return { tileWidth, tileHeight, gap, columns };
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2022 New Vector Ltd
|
Copyright 2023-2024 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.
|
||||||
You may obtain a copy of the License at
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
Unless required by applicable law or agreed to in writing, software
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.videoGrid {
|
.grid {
|
||||||
position: relative;
|
contain: layout style;
|
||||||
overflow: hidden;
|
}
|
||||||
flex: 1;
|
|
||||||
touch-action: none;
|
.slot {
|
||||||
|
contain: strict;
|
||||||
}
|
}
|
||||||
514
src/grid/Grid.tsx
Normal file
514
src/grid/Grid.tsx
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023-2024 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 {
|
||||||
|
SpringRef,
|
||||||
|
TransitionFn,
|
||||||
|
animated,
|
||||||
|
useTransition,
|
||||||
|
} from "@react-spring/web";
|
||||||
|
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
|
||||||
|
import {
|
||||||
|
CSSProperties,
|
||||||
|
ComponentProps,
|
||||||
|
ComponentType,
|
||||||
|
Dispatch,
|
||||||
|
FC,
|
||||||
|
LegacyRef,
|
||||||
|
ReactNode,
|
||||||
|
SetStateAction,
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
memo,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import useMeasure from "react-use-measure";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import styles from "./Grid.module.css";
|
||||||
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
|
import { TileWrapper } from "./TileWrapper";
|
||||||
|
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tile<Model> {
|
||||||
|
id: string;
|
||||||
|
model: Model;
|
||||||
|
onDrag: DragCallback | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlacedTile<Model> = Tile<Model> & Rect;
|
||||||
|
|
||||||
|
interface TileSpring {
|
||||||
|
opacity: number;
|
||||||
|
scale: number;
|
||||||
|
zIndex: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TileSpringUpdate extends Partial<TileSpring> {
|
||||||
|
from?: Partial<TileSpring>;
|
||||||
|
reset?: boolean;
|
||||||
|
immediate?: boolean | ((key: string) => boolean);
|
||||||
|
delay?: (key: string) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
tileId: string;
|
||||||
|
tileX: number;
|
||||||
|
tileY: number;
|
||||||
|
cursorX: number;
|
||||||
|
cursorY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
|
||||||
|
id: string;
|
||||||
|
model: Model;
|
||||||
|
onDrag?: DragCallback;
|
||||||
|
style?: CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Offset {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the offset of one element relative to an ancestor.
|
||||||
|
*/
|
||||||
|
function offset(element: HTMLElement, relativeTo: Element): Offset {
|
||||||
|
if (
|
||||||
|
!(element.offsetParent instanceof HTMLElement) ||
|
||||||
|
element.offsetParent === relativeTo
|
||||||
|
) {
|
||||||
|
return { x: element.offsetLeft, y: element.offsetTop };
|
||||||
|
} else {
|
||||||
|
const o = offset(element.offsetParent, relativeTo);
|
||||||
|
o.x += element.offsetLeft;
|
||||||
|
o.y += element.offsetTop;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutContext {
|
||||||
|
setGeneration: Dispatch<SetStateAction<number | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayoutContext = createContext<LayoutContext | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables Grid to react to layout changes. You must call this in your Layout
|
||||||
|
* component or else Grid will not be reactive.
|
||||||
|
*/
|
||||||
|
export function useUpdateLayout(): void {
|
||||||
|
const context = useContext(LayoutContext);
|
||||||
|
if (context === null)
|
||||||
|
throw new Error("useUpdateLayout called outside a Grid layout context");
|
||||||
|
|
||||||
|
// On every render, tell Grid that the layout may have changed
|
||||||
|
useEffect(() =>
|
||||||
|
context.setGeneration((prev) => (prev === null ? 0 : prev + 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
|
||||||
|
ref: LegacyRef<R>;
|
||||||
|
model: LayoutModel;
|
||||||
|
/**
|
||||||
|
* Component creating an invisible "slot" for a tile to go in.
|
||||||
|
*/
|
||||||
|
Slot: ComponentType<SlotProps<TileModel>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TileProps<Model, R extends HTMLElement> {
|
||||||
|
ref: LegacyRef<R>;
|
||||||
|
className?: string;
|
||||||
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
|
/**
|
||||||
|
* The width this tile will have once its animations have settled.
|
||||||
|
*/
|
||||||
|
targetWidth: number;
|
||||||
|
/**
|
||||||
|
* The height this tile will have once its animations have settled.
|
||||||
|
*/
|
||||||
|
targetHeight: number;
|
||||||
|
model: Model;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Drag {
|
||||||
|
/**
|
||||||
|
* The X coordinate of the dragged tile in grid space.
|
||||||
|
*/
|
||||||
|
x: number;
|
||||||
|
/**
|
||||||
|
* The Y coordinate of the dragged tile in grid space.
|
||||||
|
*/
|
||||||
|
y: number;
|
||||||
|
/**
|
||||||
|
* The X coordinate of the dragged tile, as a scalar of the grid width.
|
||||||
|
*/
|
||||||
|
xRatio: number;
|
||||||
|
/**
|
||||||
|
* The Y coordinate of the dragged tile, as a scalar of the grid height.
|
||||||
|
*/
|
||||||
|
yRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DragCallback = (drag: Drag) => void;
|
||||||
|
|
||||||
|
interface LayoutMemoProps<LayoutModel, TileModel, R extends HTMLElement>
|
||||||
|
extends LayoutProps<LayoutModel, TileModel, R> {
|
||||||
|
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, R>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<
|
||||||
|
LayoutModel,
|
||||||
|
TileModel,
|
||||||
|
LayoutRef extends HTMLElement,
|
||||||
|
TileRef extends HTMLElement,
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Data with which to populate the layout.
|
||||||
|
*/
|
||||||
|
model: LayoutModel;
|
||||||
|
/**
|
||||||
|
* A component which creates an invisible layout grid of "slots" for tiles to
|
||||||
|
* go in. The root element must have a data-generation attribute which
|
||||||
|
* increments whenever the layout may have changed.
|
||||||
|
*/
|
||||||
|
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, LayoutRef>>;
|
||||||
|
/**
|
||||||
|
* The component used to render each tile in the layout.
|
||||||
|
*/
|
||||||
|
Tile: ComponentType<TileProps<TileModel, TileRef>>;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grid of animated tiles.
|
||||||
|
*/
|
||||||
|
export function Grid<
|
||||||
|
LayoutModel,
|
||||||
|
TileModel,
|
||||||
|
LayoutRef extends HTMLElement,
|
||||||
|
TileRef extends HTMLElement,
|
||||||
|
>({
|
||||||
|
model,
|
||||||
|
Layout,
|
||||||
|
Tile,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: Props<LayoutModel, TileModel, LayoutRef, TileRef>): ReactNode {
|
||||||
|
// Overview: This component places tiles by rendering an invisible layout grid
|
||||||
|
// of "slots" for tiles to go in. Once rendered, it uses the DOM API to get
|
||||||
|
// the dimensions of each slot, feeding these numbers back into react-spring
|
||||||
|
// to let the actual tiles move freely atop the layout.
|
||||||
|
|
||||||
|
// To tell us when the layout has changed, the layout system increments its
|
||||||
|
// data-generation attribute, which we watch with a MutationObserver.
|
||||||
|
|
||||||
|
const [gridRef1, gridBounds] = useMeasure();
|
||||||
|
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
|
||||||
|
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
|
||||||
|
|
||||||
|
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
||||||
|
const [generation, setGeneration] = useState<number | null>(null);
|
||||||
|
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
|
||||||
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
|
||||||
|
const Slot: FC<SlotProps<TileModel>> = useMemo(
|
||||||
|
() =>
|
||||||
|
function Slot({ id, model, onDrag, style, className, ...props }) {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
tiles.set(id, { id, model, onDrag });
|
||||||
|
return (): void => void tiles.delete(id);
|
||||||
|
}, [id, model, onDrag]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(className, styles.slot)}
|
||||||
|
data-id={id}
|
||||||
|
style={style}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[tiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
// We must memoize the Layout component to break the update loop where a
|
||||||
|
// render of Grid causes a re-render of Layout, which in turn re-renders Grid
|
||||||
|
const LayoutMemo = useMemo(
|
||||||
|
() =>
|
||||||
|
memo(
|
||||||
|
forwardRef<
|
||||||
|
LayoutRef,
|
||||||
|
LayoutMemoProps<LayoutModel, TileModel, LayoutRef>
|
||||||
|
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode {
|
||||||
|
return <Layout {...props} ref={ref} />;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const context: LayoutContext = useMemo(() => ({ setGeneration }), []);
|
||||||
|
|
||||||
|
// Combine the tile definitions and slots together to create placed tiles
|
||||||
|
const placedTiles = useMemo(() => {
|
||||||
|
const result: PlacedTile<TileModel>[] = [];
|
||||||
|
|
||||||
|
if (gridRoot !== null && layoutRoot !== null) {
|
||||||
|
const slots = layoutRoot.getElementsByClassName(
|
||||||
|
styles.slot,
|
||||||
|
) as HTMLCollectionOf<HTMLElement>;
|
||||||
|
for (const slot of slots) {
|
||||||
|
const id = slot.getAttribute("data-id")!;
|
||||||
|
if (slot.offsetWidth > 0 && slot.offsetHeight > 0)
|
||||||
|
result.push({
|
||||||
|
...tiles.get(id)!,
|
||||||
|
...offset(slot, gridRoot),
|
||||||
|
width: slot.offsetWidth,
|
||||||
|
height: slot.offsetHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
// The rects may change due to the grid resizing or updating to a new
|
||||||
|
// generation, but eslint can't statically verify this
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [gridRoot, layoutRoot, tiles, gridBounds, generation]);
|
||||||
|
|
||||||
|
// Drag state is stored in a ref rather than component state, because we use
|
||||||
|
// react-spring's imperative API during gestures to improve responsiveness
|
||||||
|
const dragState = useRef<DragState | null>(null);
|
||||||
|
|
||||||
|
const [tileTransitions, springRef] = useTransition(
|
||||||
|
placedTiles,
|
||||||
|
() => ({
|
||||||
|
key: ({ id }: Tile<TileModel>): string => id,
|
||||||
|
from: ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: PlacedTile<TileModel>): TileSpringUpdate => ({
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
immediate: prefersReducedMotion,
|
||||||
|
}),
|
||||||
|
enter: { opacity: 1, scale: 1, immediate: prefersReducedMotion },
|
||||||
|
update: ({
|
||||||
|
id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: PlacedTile<TileModel>): TileSpringUpdate | null =>
|
||||||
|
id === dragState.current?.tileId
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
immediate: prefersReducedMotion,
|
||||||
|
},
|
||||||
|
leave: { opacity: 0, scale: 0, immediate: prefersReducedMotion },
|
||||||
|
config: { mass: 0.7, tension: 252, friction: 25 },
|
||||||
|
}),
|
||||||
|
// react-spring's types are bugged and can't infer the spring type
|
||||||
|
) as unknown as [
|
||||||
|
TransitionFn<PlacedTile<TileModel>, TileSpring>,
|
||||||
|
SpringRef<TileSpring>,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Because we're using react-spring in imperative mode, we're responsible for
|
||||||
|
// firing animations manually whenever the tiles array updates
|
||||||
|
useEffect(() => {
|
||||||
|
springRef.start();
|
||||||
|
}, [placedTiles, springRef]);
|
||||||
|
|
||||||
|
const animateDraggedTile = (
|
||||||
|
endOfGesture: boolean,
|
||||||
|
callback: DragCallback,
|
||||||
|
): void => {
|
||||||
|
const { tileId, tileX, tileY } = dragState.current!;
|
||||||
|
const tile = placedTiles.find((t) => t.id === tileId)!;
|
||||||
|
|
||||||
|
springRef.current
|
||||||
|
.find((c) => (c.item as Tile<TileModel>).id === tileId)
|
||||||
|
?.start(
|
||||||
|
endOfGesture
|
||||||
|
? {
|
||||||
|
scale: 1,
|
||||||
|
zIndex: 1,
|
||||||
|
x: tile.x,
|
||||||
|
y: tile.y,
|
||||||
|
width: tile.width,
|
||||||
|
height: tile.height,
|
||||||
|
immediate:
|
||||||
|
prefersReducedMotion || ((key): boolean => key === "zIndex"),
|
||||||
|
// Allow the tile's position to settle before pushing its
|
||||||
|
// z-index back down
|
||||||
|
delay: (key): number => (key === "zIndex" ? 500 : 0),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
scale: 1.1,
|
||||||
|
zIndex: 2,
|
||||||
|
x: tileX,
|
||||||
|
y: tileY,
|
||||||
|
immediate:
|
||||||
|
prefersReducedMotion ||
|
||||||
|
((key): boolean =>
|
||||||
|
key === "zIndex" || key === "x" || key === "y"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (endOfGesture)
|
||||||
|
callback({
|
||||||
|
x: tileX,
|
||||||
|
y: tileY,
|
||||||
|
xRatio: tileX / (gridBounds.width - tile.width),
|
||||||
|
yRatio: tileY / (gridBounds.height - tile.height),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Callback for useDrag. We could call useDrag here, but the default
|
||||||
|
// pattern of spreading {...bind()} across the children to bind the gesture
|
||||||
|
// ends up breaking memoization and ruining this component's performance.
|
||||||
|
// Instead, we pass this callback to each tile via a ref, to let them bind the
|
||||||
|
// gesture using the much more sensible ref-based method.
|
||||||
|
const onTileDrag = (
|
||||||
|
tileId: string,
|
||||||
|
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
tap,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
initial: [initialX, initialY],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
delta: [dx, dy],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
last,
|
||||||
|
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
||||||
|
): void => {
|
||||||
|
if (!tap) {
|
||||||
|
const tileController = springRef.current.find(
|
||||||
|
(c) => (c.item as Tile<TileModel>).id === tileId,
|
||||||
|
)!;
|
||||||
|
const callback = tiles.get(tileController.item.id)!.onDrag;
|
||||||
|
|
||||||
|
if (callback != null) {
|
||||||
|
if (dragState.current === null) {
|
||||||
|
const tileSpring = tileController.get();
|
||||||
|
dragState.current = {
|
||||||
|
tileId,
|
||||||
|
tileX: tileSpring.x,
|
||||||
|
tileY: tileSpring.y,
|
||||||
|
cursorX: initialX - gridBounds.x,
|
||||||
|
cursorY: initialY - gridBounds.y + scrollOffset.current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dragState.current.tileX += dx;
|
||||||
|
dragState.current.tileY += dy;
|
||||||
|
dragState.current.cursorX += dx;
|
||||||
|
dragState.current.cursorY += dy;
|
||||||
|
|
||||||
|
animateDraggedTile(last, callback);
|
||||||
|
|
||||||
|
if (last) dragState.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTileDragRef = useRef(onTileDrag);
|
||||||
|
onTileDragRef.current = onTileDrag;
|
||||||
|
|
||||||
|
const scrollOffset = useRef(0);
|
||||||
|
|
||||||
|
useScroll(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
({ xy: [, y], delta: [, dy] }) => {
|
||||||
|
scrollOffset.current = y;
|
||||||
|
|
||||||
|
if (dragState.current !== null) {
|
||||||
|
dragState.current.tileY += dy;
|
||||||
|
dragState.current.cursorY += dy;
|
||||||
|
animateDraggedTile(false, tiles.get(dragState.current.tileId)!.onDrag!);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ target: gridRoot ?? undefined },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
className={classNames(className, styles.grid)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<LayoutContext.Provider value={context}>
|
||||||
|
<LayoutMemo
|
||||||
|
ref={setLayoutRoot}
|
||||||
|
Layout={Layout}
|
||||||
|
model={model}
|
||||||
|
Slot={Slot}
|
||||||
|
/>
|
||||||
|
</LayoutContext.Provider>
|
||||||
|
{tileTransitions((spring, { id, model, onDrag, width, height }) => (
|
||||||
|
<TileWrapper
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
onDrag={onDrag ? onTileDragRef : null}
|
||||||
|
targetWidth={width}
|
||||||
|
targetHeight={height}
|
||||||
|
model={model}
|
||||||
|
Tile={Tile}
|
||||||
|
{...spring}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/grid/GridLayout.module.css
Normal file
61
src/grid/GridLayout.module.css
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.fixed,
|
||||||
|
.scrolling {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolling {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrolling > .slot {
|
||||||
|
width: var(--width);
|
||||||
|
height: var(--height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed > .slot {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 404px;
|
||||||
|
block-size: 233px;
|
||||||
|
inset-block: 0;
|
||||||
|
inset-inline: var(--cpd-space-3x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed > .slot[data-block-alignment="start"] {
|
||||||
|
inset-block-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed > .slot[data-block-alignment="end"] {
|
||||||
|
inset-block-start: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed > .slot[data-inline-alignment="start"] {
|
||||||
|
inset-inline-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed > .slot[data-inline-alignment="end"] {
|
||||||
|
inset-inline-start: unset;
|
||||||
|
}
|
||||||
129
src/grid/GridLayout.tsx
Normal file
129
src/grid/GridLayout.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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 { CSSProperties, forwardRef, useCallback, useMemo } from "react";
|
||||||
|
import { distinctUntilChanged } from "rxjs";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
|
import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
|
||||||
|
import styles from "./GridLayout.module.css";
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
|
import {
|
||||||
|
CallLayout,
|
||||||
|
GridTileModel,
|
||||||
|
TileModel,
|
||||||
|
arrangeTiles,
|
||||||
|
} from "./CallLayout";
|
||||||
|
import { DragCallback, useUpdateLayout } from "./Grid";
|
||||||
|
|
||||||
|
interface GridCSSProperties extends CSSProperties {
|
||||||
|
"--gap": string;
|
||||||
|
"--width": string;
|
||||||
|
"--height": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "grid" layout, in which all participants are shown
|
||||||
|
* together in a scrolling grid.
|
||||||
|
*/
|
||||||
|
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||||
|
minBounds,
|
||||||
|
spotlightAlignment,
|
||||||
|
}) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
|
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
||||||
|
// lives
|
||||||
|
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
|
||||||
|
useUpdateLayout();
|
||||||
|
const alignment = useObservableEagerState(
|
||||||
|
useInitial(() =>
|
||||||
|
spotlightAlignment.pipe(
|
||||||
|
distinctUntilChanged(
|
||||||
|
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const tileModel: TileModel | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
model.spotlight && {
|
||||||
|
type: "spotlight",
|
||||||
|
vms: model.spotlight,
|
||||||
|
maximised: false,
|
||||||
|
},
|
||||||
|
[model.spotlight],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragSpotlight: DragCallback = useCallback(
|
||||||
|
({ xRatio, yRatio }) =>
|
||||||
|
spotlightAlignment.next({
|
||||||
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={styles.fixed}>
|
||||||
|
{tileModel && (
|
||||||
|
<Slot
|
||||||
|
className={styles.slot}
|
||||||
|
id="spotlight"
|
||||||
|
model={tileModel}
|
||||||
|
onDrag={onDragSpotlight}
|
||||||
|
data-block-alignment={alignment.block}
|
||||||
|
data-inline-alignment={alignment.inline}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// The scrolling part of the layout is where all the grid tiles live
|
||||||
|
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
|
||||||
|
useUpdateLayout();
|
||||||
|
const { width, height: minHeight } = useObservableEagerState(minBounds);
|
||||||
|
const { gap, tileWidth, tileHeight } = useMemo(
|
||||||
|
() => arrangeTiles(width, minHeight, model.grid.length),
|
||||||
|
[width, minHeight, model.grid.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tileModels: GridTileModel[] = useMemo(
|
||||||
|
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||||
|
[model.grid],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={styles.scrolling}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width,
|
||||||
|
"--gap": `${gap}px`,
|
||||||
|
"--width": `${Math.floor(tileWidth)}px`,
|
||||||
|
"--height": `${Math.floor(tileHeight)}px`,
|
||||||
|
} as GridCSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tileModels.map((m) => (
|
||||||
|
<Slot key={m.vm.id} className={styles.slot} id={m.vm.id} model={m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
54
src/grid/OneOnOneLayout.module.css
Normal file
54
src/grid/OneOnOneLayout.module.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 180px;
|
||||||
|
block-size: 135px;
|
||||||
|
inset: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 404px;
|
||||||
|
block-size: 233px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot[data-block-alignment="start"] {
|
||||||
|
inset-block-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot[data-block-alignment="end"] {
|
||||||
|
inset-block-start: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot[data-inline-alignment="start"] {
|
||||||
|
inset-inline-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot[data-inline-alignment="end"] {
|
||||||
|
inset-inline-start: unset;
|
||||||
|
}
|
||||||
88
src/grid/OneOnOneLayout.tsx
Normal file
88
src/grid/OneOnOneLayout.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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 { forwardRef, useCallback, useMemo } from "react";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
|
||||||
|
import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout";
|
||||||
|
import styles from "./OneOnOneLayout.module.css";
|
||||||
|
import { DragCallback, useUpdateLayout } from "./Grid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "one-on-one" layout, in which the remote participant
|
||||||
|
* is shown at maximum size, overlaid by a small view of the local participant.
|
||||||
|
*/
|
||||||
|
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||||
|
minBounds,
|
||||||
|
pipAlignment,
|
||||||
|
}) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
|
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
|
||||||
|
useUpdateLayout();
|
||||||
|
return <div ref={ref} />;
|
||||||
|
}),
|
||||||
|
|
||||||
|
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
|
||||||
|
useUpdateLayout();
|
||||||
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
|
const pipAlignmentValue = useObservableEagerState(pipAlignment);
|
||||||
|
const { tileWidth, tileHeight } = useMemo(
|
||||||
|
() => arrangeTiles(width, height, 1),
|
||||||
|
[width, height],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remoteTileModel: GridTileModel = useMemo(
|
||||||
|
() => ({ type: "grid", vm: model.remote }),
|
||||||
|
[model.remote],
|
||||||
|
);
|
||||||
|
const localTileModel: GridTileModel = useMemo(
|
||||||
|
() => ({ type: "grid", vm: model.local }),
|
||||||
|
[model.local],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragLocalTile: DragCallback = useCallback(
|
||||||
|
({ xRatio, yRatio }) =>
|
||||||
|
pipAlignment.next({
|
||||||
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={styles.layer}>
|
||||||
|
<Slot
|
||||||
|
id={remoteTileModel.vm.id}
|
||||||
|
model={remoteTileModel}
|
||||||
|
className={styles.container}
|
||||||
|
style={{ width: tileWidth, height: tileHeight }}
|
||||||
|
>
|
||||||
|
<Slot
|
||||||
|
className={classNames(styles.slot, styles.local)}
|
||||||
|
id={localTileModel.vm.id}
|
||||||
|
model={localTileModel}
|
||||||
|
onDrag={onDragLocalTile}
|
||||||
|
data-block-alignment={pipAlignmentValue.block}
|
||||||
|
data-inline-alignment={pipAlignmentValue.inline}
|
||||||
|
/>
|
||||||
|
</Slot>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
54
src/grid/SpotlightExpandedLayout.module.css
Normal file
54
src/grid/SpotlightExpandedLayout.module.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
block-size: 100%;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 135px;
|
||||||
|
block-size: 160px;
|
||||||
|
inset: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.pip {
|
||||||
|
inline-size: 180px;
|
||||||
|
block-size: 135px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-block-alignment="start"] {
|
||||||
|
inset-block-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-block-alignment="end"] {
|
||||||
|
inset-block-start: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-inline-alignment="start"] {
|
||||||
|
inset-inline-end: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip[data-inline-alignment="end"] {
|
||||||
|
inset-inline-start: unset;
|
||||||
|
}
|
||||||
91
src/grid/SpotlightExpandedLayout.tsx
Normal file
91
src/grid/SpotlightExpandedLayout.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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 { forwardRef, useCallback, useMemo } from "react";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
|
import { SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
|
||||||
|
import { CallLayout, GridTileModel, SpotlightTileModel } from "./CallLayout";
|
||||||
|
import { DragCallback, useUpdateLayout } from "./Grid";
|
||||||
|
import styles from "./SpotlightExpandedLayout.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "expanded spotlight" layout, in which the spotlight
|
||||||
|
* tile stretches edge-to-edge and is overlaid by a picture-in-picture tile.
|
||||||
|
*/
|
||||||
|
export const makeSpotlightExpandedLayout: CallLayout<
|
||||||
|
SpotlightExpandedLayoutModel
|
||||||
|
> = ({ pipAlignment }) => ({
|
||||||
|
scrollingOnTop: true,
|
||||||
|
|
||||||
|
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
useUpdateLayout();
|
||||||
|
const spotlightTileModel: SpotlightTileModel = useMemo(
|
||||||
|
() => ({ type: "spotlight", vms: model.spotlight, maximised: true }),
|
||||||
|
[model.spotlight],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={styles.layer}>
|
||||||
|
<Slot
|
||||||
|
className={styles.spotlight}
|
||||||
|
id="spotlight"
|
||||||
|
model={spotlightTileModel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
useUpdateLayout();
|
||||||
|
const pipAlignmentValue = useObservableEagerState(pipAlignment);
|
||||||
|
|
||||||
|
const pipTileModel: GridTileModel | undefined = useMemo(
|
||||||
|
() => model.pip && { type: "grid", vm: model.pip },
|
||||||
|
[model.pip],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragPip: DragCallback = useCallback(
|
||||||
|
({ xRatio, yRatio }) =>
|
||||||
|
pipAlignment.next({
|
||||||
|
block: yRatio < 0.5 ? "start" : "end",
|
||||||
|
inline: xRatio < 0.5 ? "start" : "end",
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={styles.layer}>
|
||||||
|
{pipTileModel && (
|
||||||
|
<Slot
|
||||||
|
className={styles.pip}
|
||||||
|
id="pip"
|
||||||
|
model={pipTileModel}
|
||||||
|
onDrag={onDragPip}
|
||||||
|
data-block-alignment={pipAlignmentValue.block}
|
||||||
|
data-inline-alignment={pipAlignmentValue.inline}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
54
src/grid/SpotlightLandscapeLayout.module.css
Normal file
54
src/grid/SpotlightLandscapeLayout.module.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
display: grid;
|
||||||
|
--gap: 20px;
|
||||||
|
gap: var(--gap);
|
||||||
|
--grid-slot-width: 180px;
|
||||||
|
grid-template-columns: 1fr var(--grid-slot-width);
|
||||||
|
grid-template-rows: minmax(1fr, auto);
|
||||||
|
padding-inline: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
container: spotlight / size;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS makes us put a condition here, even though all we want to do is
|
||||||
|
unconditionally select the container so we can use cq units */
|
||||||
|
@container spotlight (width > 0) {
|
||||||
|
.spotlight > .slot {
|
||||||
|
inline-size: min(100cqi, 100cqb * (17 / 9));
|
||||||
|
block-size: min(100cqb, 100cqi / (4 / 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--gap);
|
||||||
|
justify-content: center;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > .slot {
|
||||||
|
inline-size: 180px;
|
||||||
|
block-size: 135px;
|
||||||
|
}
|
||||||
92
src/grid/SpotlightLandscapeLayout.tsx
Normal file
92
src/grid/SpotlightLandscapeLayout.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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 { forwardRef, useMemo } from "react";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
|
||||||
|
import { SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
|
||||||
|
import styles from "./SpotlightLandscapeLayout.module.css";
|
||||||
|
import { useUpdateLayout } from "./Grid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "spotlight landscape" layout, in which the spotlight
|
||||||
|
* tile takes up most of the space on the left, and the grid of participants is
|
||||||
|
* shown as a scrolling rail on the right.
|
||||||
|
*/
|
||||||
|
export const makeSpotlightLandscapeLayout: CallLayout<
|
||||||
|
SpotlightLandscapeLayoutModel
|
||||||
|
> = ({ minBounds }) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
|
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
useUpdateLayout();
|
||||||
|
useObservableEagerState(minBounds);
|
||||||
|
const tileModel: TileModel = useMemo(
|
||||||
|
() => ({
|
||||||
|
type: "spotlight",
|
||||||
|
vms: model.spotlight,
|
||||||
|
maximised: false,
|
||||||
|
}),
|
||||||
|
[model.spotlight],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={styles.layer}>
|
||||||
|
<div className={styles.spotlight}>
|
||||||
|
<Slot className={styles.slot} id="spotlight" model={tileModel} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.grid} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
useUpdateLayout();
|
||||||
|
useObservableEagerState(minBounds);
|
||||||
|
const tileModels: GridTileModel[] = useMemo(
|
||||||
|
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||||
|
[model.grid],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={styles.layer}>
|
||||||
|
<div
|
||||||
|
className={classNames(styles.spotlight, {
|
||||||
|
[styles.withIndicators]: model.spotlight.length > 1,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{tileModels.map((m) => (
|
||||||
|
<Slot
|
||||||
|
key={m.vm.id}
|
||||||
|
className={styles.slot}
|
||||||
|
id={m.vm.id}
|
||||||
|
model={m}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
56
src/grid/SpotlightPortraitLayout.module.css
Normal file
56
src/grid/SpotlightPortraitLayout.module.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
block-size: 100%;
|
||||||
|
display: grid;
|
||||||
|
--gap: 20px;
|
||||||
|
gap: var(--gap);
|
||||||
|
margin-inline: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight {
|
||||||
|
container: spotlight / size;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
margin-block-end: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight.withIndicators {
|
||||||
|
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spotlight > .slot {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--grid-gap);
|
||||||
|
justify-content: center;
|
||||||
|
align-content: start;
|
||||||
|
padding-inline: var(--grid-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > .slot {
|
||||||
|
inline-size: var(--grid-tile-width);
|
||||||
|
block-size: var(--grid-tile-height);
|
||||||
|
}
|
||||||
118
src/grid/SpotlightPortraitLayout.tsx
Normal file
118
src/grid/SpotlightPortraitLayout.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 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 { CSSProperties, forwardRef, useMemo } from "react";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CallLayout,
|
||||||
|
GridTileModel,
|
||||||
|
TileModel,
|
||||||
|
arrangeTiles,
|
||||||
|
} from "./CallLayout";
|
||||||
|
import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
|
||||||
|
import styles from "./SpotlightPortraitLayout.module.css";
|
||||||
|
import { useUpdateLayout } from "./Grid";
|
||||||
|
|
||||||
|
interface GridCSSProperties extends CSSProperties {
|
||||||
|
"--grid-gap": string;
|
||||||
|
"--grid-tile-width": string;
|
||||||
|
"--grid-tile-height": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of the "spotlight portrait" layout, in which the spotlight
|
||||||
|
* tile is shown across the top of the screen, and the grid of participants
|
||||||
|
* scrolls behind it.
|
||||||
|
*/
|
||||||
|
export const makeSpotlightPortraitLayout: CallLayout<
|
||||||
|
SpotlightPortraitLayoutModel
|
||||||
|
> = ({ minBounds }) => ({
|
||||||
|
scrollingOnTop: false,
|
||||||
|
|
||||||
|
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
useUpdateLayout();
|
||||||
|
const tileModel: TileModel = useMemo(
|
||||||
|
() => ({
|
||||||
|
type: "spotlight",
|
||||||
|
vms: model.spotlight,
|
||||||
|
maximised: true,
|
||||||
|
}),
|
||||||
|
[model.spotlight],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={styles.layer}>
|
||||||
|
<div className={styles.spotlight}>
|
||||||
|
<Slot className={styles.slot} id="spotlight" model={tileModel} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
|
||||||
|
{ model, Slot },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
useUpdateLayout();
|
||||||
|
const { width } = useObservableEagerState(minBounds);
|
||||||
|
const { gap, tileWidth, tileHeight } = arrangeTiles(
|
||||||
|
width,
|
||||||
|
// TODO: We pretend that the minimum height is the width, because the
|
||||||
|
// actual minimum height is difficult to calculate
|
||||||
|
width,
|
||||||
|
model.grid.length,
|
||||||
|
);
|
||||||
|
const tileModels: GridTileModel[] = useMemo(
|
||||||
|
() => model.grid.map((vm) => ({ type: "grid", vm })),
|
||||||
|
[model.grid],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={styles.layer}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--grid-gap": `${gap}px`,
|
||||||
|
"--grid-tile-width": `${Math.floor(tileWidth)}px`,
|
||||||
|
"--grid-tile-height": `${Math.floor(tileHeight)}px`,
|
||||||
|
} as GridCSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(styles.spotlight, {
|
||||||
|
[styles.withIndicators]: model.spotlight.length > 1,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{tileModels.map((m) => (
|
||||||
|
<Slot
|
||||||
|
key={m.vm.id}
|
||||||
|
className={styles.slot}
|
||||||
|
id={m.vm.id}
|
||||||
|
model={m}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2023 New Vector Ltd
|
Copyright 2024 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,15 +14,10 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.bigGrid {
|
.tile.draggable {
|
||||||
display: grid;
|
cursor: grab;
|
||||||
grid-auto-rows: 130px;
|
|
||||||
gap: var(--cpd-space-2x);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
.tile.draggable:active {
|
||||||
.bigGrid {
|
cursor: grabbing;
|
||||||
grid-auto-rows: 135px;
|
|
||||||
gap: var(--cpd-space-5x);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2023 New Vector Ltd
|
Copyright 2023-2024 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,83 +14,76 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, ReactNode, RefObject, useRef } from "react";
|
import { ComponentType, memo, RefObject, useRef } from "react";
|
||||||
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
||||||
import { SpringValue, to } from "@react-spring/web";
|
import { SpringValue } from "@react-spring/web";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { ChildrenProperties } from "./VideoGrid";
|
import { TileProps } from "./Grid";
|
||||||
|
import styles from "./TileWrapper.module.css";
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<M, R extends HTMLElement> {
|
||||||
id: string;
|
id: string;
|
||||||
onDragRef: RefObject<
|
onDrag: RefObject<
|
||||||
(
|
(
|
||||||
tileId: string,
|
tileId: string,
|
||||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
||||||
) => void
|
) => void
|
||||||
>;
|
> | null;
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
data: T;
|
model: M;
|
||||||
|
Tile: ComponentType<TileProps<M, R>>;
|
||||||
opacity: SpringValue<number>;
|
opacity: SpringValue<number>;
|
||||||
scale: SpringValue<number>;
|
scale: SpringValue<number>;
|
||||||
shadow: SpringValue<number>;
|
|
||||||
shadowSpread: SpringValue<number>;
|
|
||||||
zIndex: SpringValue<number>;
|
zIndex: SpringValue<number>;
|
||||||
x: SpringValue<number>;
|
x: SpringValue<number>;
|
||||||
y: SpringValue<number>;
|
y: SpringValue<number>;
|
||||||
width: SpringValue<number>;
|
width: SpringValue<number>;
|
||||||
height: SpringValue<number>;
|
height: SpringValue<number>;
|
||||||
children: (props: ChildrenProperties<T>) => ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TileWrapper_ = memo(
|
const TileWrapper_ = memo(
|
||||||
<T,>({
|
<M, R extends HTMLElement>({
|
||||||
id,
|
id,
|
||||||
onDragRef,
|
onDrag,
|
||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
data,
|
model,
|
||||||
|
Tile,
|
||||||
opacity,
|
opacity,
|
||||||
scale,
|
scale,
|
||||||
shadow,
|
|
||||||
shadowSpread,
|
|
||||||
zIndex,
|
zIndex,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
children,
|
}: Props<M, R>) => {
|
||||||
}: Props<T>) => {
|
const ref = useRef<R | null>(null);
|
||||||
const ref = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
useDrag((state) => onDragRef?.current!(id, state), {
|
useDrag((state) => onDrag?.current!(id, state), {
|
||||||
target: ref,
|
target: ref,
|
||||||
filterTaps: true,
|
filterTaps: true,
|
||||||
preventScroll: true,
|
preventScroll: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Tile
|
||||||
{children({
|
ref={ref}
|
||||||
ref,
|
className={classNames(styles.tile, { [styles.draggable]: onDrag })}
|
||||||
style: {
|
style={{
|
||||||
opacity,
|
opacity,
|
||||||
scale,
|
scale,
|
||||||
zIndex,
|
zIndex,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
boxShadow: to(
|
}}
|
||||||
[shadow, shadowSpread],
|
targetWidth={targetWidth}
|
||||||
(s, ss) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px ${ss}px`,
|
targetHeight={targetHeight}
|
||||||
),
|
model={model}
|
||||||
},
|
/>
|
||||||
targetWidth,
|
|
||||||
targetHeight,
|
|
||||||
data,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -104,4 +97,6 @@ TileWrapper_.displayName = "TileWrapper";
|
|||||||
// We pretend this component is a simple function rather than a
|
// We pretend this component is a simple function rather than a
|
||||||
// NamedExoticComponent, because that's the only way we can fit in a type
|
// NamedExoticComponent, because that's the only way we can fit in a type
|
||||||
// parameter
|
// parameter
|
||||||
export const TileWrapper = TileWrapper_ as <T>(props: Props<T>) => JSX.Element;
|
export const TileWrapper = TileWrapper_ as <M, R extends HTMLElement>(
|
||||||
|
props: Props<M, R>,
|
||||||
|
) => JSX.Element;
|
||||||
@@ -26,7 +26,7 @@ import styles from "./CallList.module.css";
|
|||||||
import { getAbsoluteRoomUrl, getRelativeRoomUrl } from "../matrix-utils";
|
import { getAbsoluteRoomUrl, getRelativeRoomUrl } from "../matrix-utils";
|
||||||
import { Body } from "../typography/Typography";
|
import { Body } from "../typography/Typography";
|
||||||
import { GroupCallRoom } from "./useGroupCallRooms";
|
import { GroupCallRoom } from "./useGroupCallRooms";
|
||||||
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
|
|
||||||
interface CallListProps {
|
interface CallListProps {
|
||||||
rooms: GroupCallRoom[];
|
rooms: GroupCallRoom[];
|
||||||
@@ -66,16 +66,11 @@ interface CallTileProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CallTile: FC<CallTileProps> = ({ name, avatarUrl, room }) => {
|
const CallTile: FC<CallTileProps> = ({ name, avatarUrl, room }) => {
|
||||||
const roomSharedKey = useRoomSharedKey(room.roomId);
|
const roomEncryptionSystem = useRoomEncryptionSystem(room.roomId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.callTile}>
|
<div className={styles.callTile}>
|
||||||
<Link
|
<Link
|
||||||
to={getRelativeRoomUrl(
|
to={getRelativeRoomUrl(room.roomId, roomEncryptionSystem, room.name)}
|
||||||
room.roomId,
|
|
||||||
room.name,
|
|
||||||
roomSharedKey ?? undefined,
|
|
||||||
)}
|
|
||||||
className={styles.callTileLink}
|
className={styles.callTileLink}
|
||||||
>
|
>
|
||||||
<Avatar id={room.roomId} name={name} size={Size.LG} src={avatarUrl} />
|
<Avatar id={room.roomId} name={name} size={Size.LG} src={avatarUrl} />
|
||||||
@@ -89,11 +84,8 @@ const CallTile: FC<CallTileProps> = ({ name, avatarUrl, room }) => {
|
|||||||
<CopyButton
|
<CopyButton
|
||||||
className={styles.copyButton}
|
className={styles.copyButton}
|
||||||
variant="icon"
|
variant="icon"
|
||||||
value={getAbsoluteRoomUrl(
|
// Todo add the viaServers to the created link
|
||||||
room.roomId,
|
value={getAbsoluteRoomUrl(room.roomId, roomEncryptionSystem, room.name)}
|
||||||
room.name,
|
|
||||||
roomSharedKey ?? undefined,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,9 +38,12 @@ import { UserMenuContainer } from "../UserMenuContainer";
|
|||||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||||
import { Caption } from "../typography/Typography";
|
import { Caption } from "../typography/Typography";
|
||||||
import { Form } from "../form/Form";
|
import { Form } from "../form/Form";
|
||||||
import { useOptInAnalytics } from "../settings/useSetting";
|
|
||||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import {
|
||||||
|
useSetting,
|
||||||
|
optInAnalytics as optInAnalyticsSetting,
|
||||||
|
} from "../settings/settings";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -49,7 +52,7 @@ interface Props {
|
|||||||
export const RegisteredView: FC<Props> = ({ client }) => {
|
export const RegisteredView: FC<Props> = ({ client }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error>();
|
const [error, setError] = useState<Error>();
|
||||||
const [optInAnalytics] = useOptInAnalytics();
|
const [optInAnalytics] = useSetting(optInAnalyticsSetting);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
|
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
|
||||||
@@ -78,12 +81,14 @@ export const RegisteredView: FC<Props> = ({ client }) => {
|
|||||||
roomName,
|
roomName,
|
||||||
E2eeType.SHARED_KEY,
|
E2eeType.SHARED_KEY,
|
||||||
);
|
);
|
||||||
|
if (!createRoomResult.password)
|
||||||
|
throw new Error("Failed to create room with shared secret");
|
||||||
|
|
||||||
history.push(
|
history.push(
|
||||||
getRelativeRoomUrl(
|
getRelativeRoomUrl(
|
||||||
createRoomResult.roomId,
|
createRoomResult.roomId,
|
||||||
|
{ kind: E2eeType.SHARED_KEY, secret: createRoomResult.password },
|
||||||
roomName,
|
roomName,
|
||||||
createRoomResult.password,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,15 +41,18 @@ import styles from "./UnauthenticatedView.module.css";
|
|||||||
import commonStyles from "./common.module.css";
|
import commonStyles from "./common.module.css";
|
||||||
import { generateRandomName } from "../auth/generateRandomName";
|
import { generateRandomName } from "../auth/generateRandomName";
|
||||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||||
import { useOptInAnalytics } from "../settings/useSetting";
|
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import {
|
||||||
|
useSetting,
|
||||||
|
optInAnalytics as optInAnalyticsSetting,
|
||||||
|
} from "../settings/settings";
|
||||||
|
|
||||||
export const UnauthenticatedView: FC = () => {
|
export const UnauthenticatedView: FC = () => {
|
||||||
const { setClient } = useClient();
|
const { setClient } = useClient();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error>();
|
const [error, setError] = useState<Error>();
|
||||||
const [optInAnalytics] = useOptInAnalytics();
|
const [optInAnalytics] = useSetting(optInAnalyticsSetting);
|
||||||
const { recaptchaKey, register } = useInteractiveRegistration();
|
const { recaptchaKey, register } = useInteractiveRegistration();
|
||||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||||
|
|
||||||
@@ -116,13 +119,15 @@ export const UnauthenticatedView: FC = () => {
|
|||||||
if (!setClient) {
|
if (!setClient) {
|
||||||
throw new Error("setClient is undefined");
|
throw new Error("setClient is undefined");
|
||||||
}
|
}
|
||||||
|
if (!createRoomResult.password)
|
||||||
|
throw new Error("Failed to create room with shared secret");
|
||||||
|
|
||||||
setClient({ client, session });
|
setClient({ client, session });
|
||||||
history.push(
|
history.push(
|
||||||
getRelativeRoomUrl(
|
getRelativeRoomUrl(
|
||||||
createRoomResult.roomId,
|
createRoomResult.roomId,
|
||||||
|
{ kind: E2eeType.SHARED_KEY, secret: createRoomResult.password },
|
||||||
roomName,
|
roomName,
|
||||||
createRoomResult.password,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,20 +15,22 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
|
||||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||||
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { EventTimeline, EventType, JoinRule } from "matrix-js-sdk";
|
||||||
|
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||||
|
import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager";
|
||||||
|
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||||
|
|
||||||
import { getKeyForRoom, isRoomE2EE } from "../e2ee/sharedKeyManagement";
|
import { getKeyForRoom } from "../e2ee/sharedKeyManagement";
|
||||||
|
|
||||||
export interface GroupCallRoom {
|
export interface GroupCallRoom {
|
||||||
roomAlias?: string;
|
roomAlias?: string;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
room: Room;
|
room: Room;
|
||||||
groupCall: GroupCall;
|
session: MatrixRTCSession;
|
||||||
participants: RoomMember[];
|
participants: RoomMember[];
|
||||||
}
|
}
|
||||||
const tsCache: { [index: string]: number } = {};
|
const tsCache: { [index: string]: number } = {};
|
||||||
@@ -46,7 +48,7 @@ function getLastTs(client: MatrixClient, r: Room): number {
|
|||||||
|
|
||||||
const myUserId = client.getUserId()!;
|
const myUserId = client.getUserId()!;
|
||||||
|
|
||||||
if (r.getMyMembership() !== "join") {
|
if (r.getMyMembership() !== KnownMembership.Join) {
|
||||||
const membershipEvent = r.currentState.getStateEvents(
|
const membershipEvent = r.currentState.getStateEvents(
|
||||||
"m.room.member",
|
"m.room.member",
|
||||||
myUserId,
|
myUserId,
|
||||||
@@ -80,38 +82,77 @@ function sortRooms(client: MatrixClient, rooms: Room[]): Room[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function roomIsJoinable(room: Room): boolean {
|
const roomIsJoinable = (room: Room): boolean => {
|
||||||
if (isRoomE2EE(room)) {
|
if (!room.hasEncryptionStateEvent() && !getKeyForRoom(room.roomId)) {
|
||||||
return Boolean(getKeyForRoom(room.roomId));
|
// if we have an non encrypted room (no encryption state event) we need a locally stored shared key.
|
||||||
} else {
|
// in case this key also does not exists we cannot join the room.
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
// otherwise we can always join rooms because we will automatically decide if we want to use perParticipant or password
|
||||||
|
switch (room.getJoinRule()) {
|
||||||
|
case JoinRule.Public:
|
||||||
|
return true;
|
||||||
|
case JoinRule.Knock:
|
||||||
|
switch (room.getMyMembership()) {
|
||||||
|
case KnownMembership.Join:
|
||||||
|
case KnownMembership.Knock:
|
||||||
|
return true;
|
||||||
|
case KnownMembership.Invite:
|
||||||
|
return (
|
||||||
|
room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getState(EventTimeline.FORWARDS)
|
||||||
|
?.getStateEvents(EventType.RoomMember, room.myUserId)
|
||||||
|
?.getPrevContent().membership === JoinRule.Knock
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// TODO: check JoinRule.Restricted and return true if join condition is satisfied
|
||||||
|
default:
|
||||||
|
return room.getMyMembership() === KnownMembership.Join;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const roomHasCallMembershipEvents = (room: Room): boolean => {
|
||||||
|
switch (room.getMyMembership()) {
|
||||||
|
case KnownMembership.Join:
|
||||||
|
return !!room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getState(EventTimeline.FORWARDS)
|
||||||
|
?.events?.get(EventType.GroupCallMemberPrefix);
|
||||||
|
case KnownMembership.Knock:
|
||||||
|
// Assume that a room you've knocked on is able to hold calls
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||||
const [rooms, setRooms] = useState<GroupCallRoom[]>([]);
|
const [rooms, setRooms] = useState<GroupCallRoom[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function updateRooms(): void {
|
function updateRooms(): void {
|
||||||
if (!client.groupCallEventHandler) {
|
// We want to show all rooms that historically had a call and which we are (can become) part of.
|
||||||
return;
|
const rooms = client
|
||||||
}
|
.getRooms()
|
||||||
|
.filter(roomHasCallMembershipEvents)
|
||||||
const groupCalls = client.groupCallEventHandler.groupCalls.values();
|
|
||||||
const rooms = Array.from(groupCalls)
|
|
||||||
.map((groupCall) => groupCall.room)
|
|
||||||
.filter(roomIsJoinable);
|
.filter(roomIsJoinable);
|
||||||
const sortedRooms = sortRooms(client, rooms);
|
const sortedRooms = sortRooms(client, rooms);
|
||||||
const items = sortedRooms.map((room) => {
|
const items = sortedRooms.map((room) => {
|
||||||
const groupCall = client.getGroupCallForRoom(room.roomId)!;
|
const session = client.matrixRTC.getRoomSession(room);
|
||||||
|
session.memberships;
|
||||||
return {
|
return {
|
||||||
roomAlias: room.getCanonicalAlias() ?? undefined,
|
roomAlias: room.getCanonicalAlias() ?? undefined,
|
||||||
roomName: room.name,
|
roomName: room.name,
|
||||||
avatarUrl: room.getMxcAvatarUrl()!,
|
avatarUrl: room.getMxcAvatarUrl()!,
|
||||||
room,
|
room,
|
||||||
groupCall,
|
session,
|
||||||
participants: [...groupCall!.participants.keys()],
|
participants: session.memberships
|
||||||
|
.filter((m) => m.sender)
|
||||||
|
.map((m) => room.getMember(m.sender!))
|
||||||
|
.filter((m) => m) as RoomMember[],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,15 +161,17 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
|||||||
|
|
||||||
updateRooms();
|
updateRooms();
|
||||||
|
|
||||||
client.on(GroupCallEventHandlerEvent.Incoming, updateRooms);
|
client.matrixRTC.on(
|
||||||
client.on(GroupCallEventHandlerEvent.Participants, updateRooms);
|
MatrixRTCSessionManagerEvents.SessionStarted,
|
||||||
|
updateRooms,
|
||||||
return () => {
|
);
|
||||||
client.removeListener(GroupCallEventHandlerEvent.Incoming, updateRooms);
|
client.on(RoomEvent.MyMembership, updateRooms);
|
||||||
client.removeListener(
|
return (): void => {
|
||||||
GroupCallEventHandlerEvent.Participants,
|
client.matrixRTC.off(
|
||||||
|
MatrixRTCSessionManagerEvents.SessionStarted,
|
||||||
updateRooms,
|
updateRooms,
|
||||||
);
|
);
|
||||||
|
client.off(RoomEvent.MyMembership, updateRooms);
|
||||||
};
|
};
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,12 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* We use this to not render the page at all until we know the theme.*/
|
||||||
|
.no-theme {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserTracing } from "@sentry/browser";
|
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
@@ -33,8 +32,6 @@ enum LoadState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DependencyLoadStates {
|
class DependencyLoadStates {
|
||||||
// TODO: decide where olm should be initialized (see TODO comment below)
|
|
||||||
// olm: LoadState = LoadState.None;
|
|
||||||
public config: LoadState = LoadState.None;
|
public config: LoadState = LoadState.None;
|
||||||
public sentry: LoadState = LoadState.None;
|
public sentry: LoadState = LoadState.None;
|
||||||
public openTelemetry: LoadState = LoadState.None;
|
public openTelemetry: LoadState = LoadState.None;
|
||||||
@@ -55,7 +52,7 @@ export class Initializer {
|
|||||||
public static initBeforeReact(): void {
|
public static initBeforeReact(): void {
|
||||||
// this maybe also needs to return a promise in the future,
|
// this maybe also needs to return a promise in the future,
|
||||||
// if we have to do async inits before showing the loading screen
|
// if we have to do async inits before showing the loading screen
|
||||||
// but this should be avioded if possible
|
// but this should be avoided if possible
|
||||||
|
|
||||||
//i18n
|
//i18n
|
||||||
const languageDetector = new LanguageDetector();
|
const languageDetector = new LanguageDetector();
|
||||||
@@ -119,7 +116,7 @@ export class Initializer {
|
|||||||
}
|
}
|
||||||
Initializer.internalInstance = new Initializer();
|
Initializer.internalInstance = new Initializer();
|
||||||
Initializer.internalInstance.initPromise = new Promise<void>((resolve) => {
|
Initializer.internalInstance.initPromise = new Promise<void>((resolve) => {
|
||||||
// initStep calls itself recursivly until everything is initialized in the correct order.
|
// initStep calls itself recursively until everything is initialized in the correct order.
|
||||||
// Then the promise gets resolved.
|
// Then the promise gets resolved.
|
||||||
Initializer.internalInstance.initStep(resolve);
|
Initializer.internalInstance.initStep(resolve);
|
||||||
});
|
});
|
||||||
@@ -129,18 +126,6 @@ export class Initializer {
|
|||||||
private loadStates = new DependencyLoadStates();
|
private loadStates = new DependencyLoadStates();
|
||||||
|
|
||||||
private initStep(resolve: (value: void | PromiseLike<void>) => void): void {
|
private initStep(resolve: (value: void | PromiseLike<void>) => void): void {
|
||||||
// TODO: Olm is initialized with the client currently (see `initClient()` and `olm.ts`)
|
|
||||||
// we need to decide if we want to init it here or keep it in initClient
|
|
||||||
// if (this.loadStates.olm === LoadState.None) {
|
|
||||||
// this.loadStates.olm = LoadState.Loading;
|
|
||||||
// // TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
|
||||||
// window.OLM_OPTIONS = {};
|
|
||||||
// Olm.init({ locateFile: () => olmWasmPath }).then(() => {
|
|
||||||
// this.loadStates.olm = LoadState.Loaded;
|
|
||||||
// this.initStep(resolve);
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// config
|
// config
|
||||||
if (this.loadStates.config === LoadState.None) {
|
if (this.loadStates.config === LoadState.None) {
|
||||||
this.loadStates.config = LoadState.Loading;
|
this.loadStates.config = LoadState.Loading;
|
||||||
@@ -160,10 +145,7 @@ export class Initializer {
|
|||||||
dsn: Config.get().sentry?.DSN,
|
dsn: Config.get().sentry?.DSN,
|
||||||
environment: Config.get().sentry?.environment,
|
environment: Config.get().sentry?.environment,
|
||||||
integrations: [
|
integrations: [
|
||||||
new BrowserTracing({
|
Sentry.reactRouterV5BrowserTracingIntegration({ history }),
|
||||||
routingInstrumentation:
|
|
||||||
Sentry.reactRouterV5Instrumentation(history),
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 1.0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2023 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 { Focus } from "matrix-js-sdk/src/matrixrtc/focus";
|
|
||||||
|
|
||||||
export interface LivekitFocus extends Focus {
|
|
||||||
type: "livekit";
|
|
||||||
livekit_service_url: string;
|
|
||||||
livekit_alias: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ import { Observable } from "rxjs";
|
|||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isFirefox,
|
useSetting,
|
||||||
useAudioInput,
|
audioInput as audioInputSetting,
|
||||||
useAudioOutput,
|
audioOutput as audioOutputSetting,
|
||||||
useVideoInput,
|
videoInput as videoInputSetting,
|
||||||
} from "../settings/useSetting";
|
} from "../settings/settings";
|
||||||
|
import { isFirefox } from "../Platform";
|
||||||
|
|
||||||
export interface MediaDevice {
|
export interface MediaDevice {
|
||||||
available: MediaDeviceInfo[];
|
available: MediaDeviceInfo[];
|
||||||
@@ -59,7 +60,7 @@ function useObservableState<T>(
|
|||||||
// observable state doesn't run in SSR
|
// observable state doesn't run in SSR
|
||||||
if (typeof window === "undefined" || !observable) return;
|
if (typeof window === "undefined" || !observable) return;
|
||||||
const subscription = observable.subscribe(setState);
|
const subscription = observable.subscribe(setState);
|
||||||
return () => subscription.unsubscribe();
|
return (): void => subscription.unsubscribe();
|
||||||
}, [observable]);
|
}, [observable]);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -145,43 +146,36 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
|||||||
// for ouput devices because the selector wont be shown on FF.
|
// for ouput devices because the selector wont be shown on FF.
|
||||||
const useOutputNames = usingNames && !isFirefox();
|
const useOutputNames = usingNames && !isFirefox();
|
||||||
|
|
||||||
const [audioInputSetting, setAudioInputSetting] = useAudioInput();
|
const [storedAudioInput, setStoredAudioInput] = useSetting(audioInputSetting);
|
||||||
const [audioOutputSetting, setAudioOutputSetting] = useAudioOutput();
|
const [storedAudioOutput, setStoredAudioOutput] =
|
||||||
const [videoInputSetting, setVideoInputSetting] = useVideoInput();
|
useSetting(audioOutputSetting);
|
||||||
|
const [storedVideoInput, setStoredVideoInput] = useSetting(videoInputSetting);
|
||||||
|
|
||||||
const audioInput = useMediaDevice(
|
const audioInput = useMediaDevice("audioinput", storedAudioInput, usingNames);
|
||||||
"audioinput",
|
|
||||||
audioInputSetting,
|
|
||||||
usingNames,
|
|
||||||
);
|
|
||||||
const audioOutput = useMediaDevice(
|
const audioOutput = useMediaDevice(
|
||||||
"audiooutput",
|
"audiooutput",
|
||||||
audioOutputSetting,
|
storedAudioOutput,
|
||||||
useOutputNames,
|
useOutputNames,
|
||||||
alwaysUseDefaultAudio,
|
alwaysUseDefaultAudio,
|
||||||
);
|
);
|
||||||
const videoInput = useMediaDevice(
|
const videoInput = useMediaDevice("videoinput", storedVideoInput, usingNames);
|
||||||
"videoinput",
|
|
||||||
videoInputSetting,
|
|
||||||
usingNames,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioInput.selectedId !== undefined)
|
if (audioInput.selectedId !== undefined)
|
||||||
setAudioInputSetting(audioInput.selectedId);
|
setStoredAudioInput(audioInput.selectedId);
|
||||||
}, [setAudioInputSetting, audioInput.selectedId]);
|
}, [setStoredAudioInput, audioInput.selectedId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip setting state for ff output. Redundent since it is set to always return 'undefined'
|
// Skip setting state for ff output. Redundent since it is set to always return 'undefined'
|
||||||
// but makes it clear while debugging that this is not happening on FF. + perf ;)
|
// but makes it clear while debugging that this is not happening on FF. + perf ;)
|
||||||
if (audioOutput.selectedId !== undefined && !isFirefox())
|
if (audioOutput.selectedId !== undefined && !isFirefox())
|
||||||
setAudioOutputSetting(audioOutput.selectedId);
|
setStoredAudioOutput(audioOutput.selectedId);
|
||||||
}, [setAudioOutputSetting, audioOutput.selectedId]);
|
}, [setStoredAudioOutput, audioOutput.selectedId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoInput.selectedId !== undefined)
|
if (videoInput.selectedId !== undefined)
|
||||||
setVideoInputSetting(videoInput.selectedId);
|
setStoredVideoInput(videoInput.selectedId);
|
||||||
}, [setVideoInputSetting, videoInput.selectedId]);
|
}, [setStoredVideoInput, videoInput.selectedId]);
|
||||||
|
|
||||||
const startUsingDeviceNames = useCallback(
|
const startUsingDeviceNames = useCallback(
|
||||||
() => setNumCallersUsingNames((n) => n + 1),
|
() => setNumCallersUsingNames((n) => n + 1),
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import { IOpenIDToken, MatrixClient } from "matrix-js-sdk";
|
|||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
|
||||||
|
|
||||||
import { LivekitFocus } from "./LivekitFocus";
|
import { useActiveLivekitFocus } from "../room/useActiveFocus";
|
||||||
import { useActiveFocus } from "../room/useActiveFocus";
|
|
||||||
|
|
||||||
export interface SFUConfig {
|
export interface SFUConfig {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -46,7 +46,7 @@ export function useOpenIDSFU(
|
|||||||
): SFUConfig | undefined {
|
): SFUConfig | undefined {
|
||||||
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
|
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
|
||||||
|
|
||||||
const activeFocus = useActiveFocus(rtcSession);
|
const activeFocus = useActiveLivekitFocus(rtcSession);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async (): Promise<void> => {
|
(async (): Promise<void> => {
|
||||||
|
|||||||
@@ -59,4 +59,5 @@ export const defaultLiveKitOptions: RoomOptions = {
|
|||||||
stopLocalTrackOnUnpublish: true,
|
stopLocalTrackOnUnpublish: true,
|
||||||
reconnectPolicy: new DefaultReconnectPolicy(),
|
reconnectPolicy: new DefaultReconnectPolicy(),
|
||||||
disconnectOnPageLeave: true,
|
disconnectOnPageLeave: true,
|
||||||
|
webAudioMix: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ import { logger } from "matrix-js-sdk/src/logger";
|
|||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
|
|
||||||
import { SFUConfig, sfuConfigEquals } from "./openIDSFU";
|
import { SFUConfig, sfuConfigEquals } from "./openIDSFU";
|
||||||
|
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
peerConnectionTimeout?: number;
|
||||||
|
websocketTimeout?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Additional values for states that a call can be in, beyond what livekit
|
* Additional values for states that a call can be in, beyond what livekit
|
||||||
@@ -124,7 +132,21 @@ async function connectAndPublish(
|
|||||||
micTrack: LocalTrack | undefined,
|
micTrack: LocalTrack | undefined,
|
||||||
screenshareTracks: MediaStreamTrack[],
|
screenshareTracks: MediaStreamTrack[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt);
|
const tracker = PosthogAnalytics.instance.eventCallConnectDuration;
|
||||||
|
// Track call connect duration
|
||||||
|
tracker.cacheConnectStart();
|
||||||
|
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||||
|
|
||||||
|
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
|
||||||
|
// Due to stability issues on Firefox we are testing the effect of different
|
||||||
|
// timeouts, and allow these values to be set through the console
|
||||||
|
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
|
||||||
|
websocketTimeout: window.websocketTimeout ?? 45000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// remove listener in case the connect promise rejects before `SignalConnected` is emitted.
|
||||||
|
livekitRoom.off(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||||
|
tracker.track({ log: true });
|
||||||
|
|
||||||
if (micTrack) {
|
if (micTrack) {
|
||||||
logger.info(`Publishing precreated mic track`);
|
logger.info(`Publishing precreated mic track`);
|
||||||
@@ -170,7 +192,7 @@ export function useECConnectionState(
|
|||||||
livekitRoom.on(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
livekitRoom.on(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return (): void => {
|
||||||
if (oldRoom)
|
if (oldRoom)
|
||||||
oldRoom.off(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
oldRoom.off(RoomEvent.ConnectionStateChanged, onConnStateChanged);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,11 +41,7 @@ import {
|
|||||||
} from "./useECConnectionState";
|
} from "./useECConnectionState";
|
||||||
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
|
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
export type E2EEConfig = {
|
|
||||||
mode: E2eeType;
|
|
||||||
sharedKey?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseLivekitResult {
|
interface UseLivekitResult {
|
||||||
livekitRoom?: Room;
|
livekitRoom?: Room;
|
||||||
@@ -56,41 +52,35 @@ export function useLiveKit(
|
|||||||
rtcSession: MatrixRTCSession,
|
rtcSession: MatrixRTCSession,
|
||||||
muteStates: MuteStates,
|
muteStates: MuteStates,
|
||||||
sfuConfig: SFUConfig | undefined,
|
sfuConfig: SFUConfig | undefined,
|
||||||
e2eeConfig: E2EEConfig,
|
e2eeSystem: EncryptionSystem,
|
||||||
): UseLivekitResult {
|
): UseLivekitResult {
|
||||||
const e2eeOptions = useMemo((): E2EEOptions | undefined => {
|
const e2eeOptions = useMemo((): E2EEOptions | undefined => {
|
||||||
if (e2eeConfig.mode === E2eeType.NONE) return undefined;
|
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
|
||||||
|
|
||||||
if (e2eeConfig.mode === E2eeType.PER_PARTICIPANT) {
|
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
return {
|
return {
|
||||||
keyProvider: new MatrixKeyProvider(),
|
keyProvider: new MatrixKeyProvider(),
|
||||||
worker: new E2EEWorker(),
|
worker: new E2EEWorker(),
|
||||||
};
|
};
|
||||||
} else if (
|
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||||
e2eeConfig.mode === E2eeType.SHARED_KEY &&
|
|
||||||
e2eeConfig.sharedKey
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
keyProvider: new ExternalE2EEKeyProvider(),
|
keyProvider: new ExternalE2EEKeyProvider(),
|
||||||
worker: new E2EEWorker(),
|
worker: new E2EEWorker(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [e2eeConfig]);
|
}, [e2eeSystem]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (e2eeConfig.mode === E2eeType.NONE || !e2eeOptions) return;
|
if (e2eeSystem.kind === E2eeType.NONE || !e2eeOptions) return;
|
||||||
|
|
||||||
if (e2eeConfig.mode === E2eeType.PER_PARTICIPANT) {
|
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
|
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
|
||||||
} else if (
|
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||||
e2eeConfig.mode === E2eeType.SHARED_KEY &&
|
|
||||||
e2eeConfig.sharedKey
|
|
||||||
) {
|
|
||||||
(e2eeOptions.keyProvider as ExternalE2EEKeyProvider).setKey(
|
(e2eeOptions.keyProvider as ExternalE2EEKeyProvider).setKey(
|
||||||
e2eeConfig.sharedKey,
|
e2eeSystem.secret,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [e2eeOptions, e2eeConfig, rtcSession]);
|
}, [e2eeOptions, e2eeSystem, rtcSession]);
|
||||||
|
|
||||||
const initialMuteStates = useRef<MuteStates>(muteStates);
|
const initialMuteStates = useRef<MuteStates>(muteStates);
|
||||||
const devices = useMediaDevices();
|
const devices = useMediaDevices();
|
||||||
@@ -131,9 +121,9 @@ export function useLiveKit(
|
|||||||
// useEffect() with an argument that references itself, if E2EE is enabled
|
// useEffect() with an argument that references itself, if E2EE is enabled
|
||||||
const room = useMemo(() => {
|
const room = useMemo(() => {
|
||||||
const r = new Room(roomOptions);
|
const r = new Room(roomOptions);
|
||||||
r.setE2EEEnabled(e2eeConfig.mode !== E2eeType.NONE);
|
r.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE);
|
||||||
return r;
|
return r;
|
||||||
}, [roomOptions, e2eeConfig]);
|
}, [roomOptions, e2eeSystem]);
|
||||||
|
|
||||||
const connectionState = useECConnectionState(
|
const connectionState = useECConnectionState(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,25 +19,24 @@ import { MemoryStore } from "matrix-js-sdk/src/store/memory";
|
|||||||
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
|
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
|
||||||
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
|
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
|
||||||
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
|
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
|
||||||
import { createClient, ICreateClientOpts } from "matrix-js-sdk/src/matrix";
|
import {
|
||||||
|
createClient,
|
||||||
|
ICreateClientOpts,
|
||||||
|
Preset,
|
||||||
|
Visibility,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { ClientEvent } from "matrix-js-sdk/src/client";
|
import { ClientEvent } from "matrix-js-sdk/src/client";
|
||||||
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
|
|
||||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import {
|
|
||||||
GroupCallIntent,
|
|
||||||
GroupCallType,
|
|
||||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
|
||||||
import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring";
|
import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring";
|
||||||
|
|
||||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
||||||
import { getUrlParams, PASSWORD_STRING } from "./UrlParams";
|
import { generateUrlSearchParams, getUrlParams } from "./UrlParams";
|
||||||
import { loadOlm } from "./olm";
|
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
import { E2eeType } from "./e2ee/e2eeType";
|
import { E2eeType } from "./e2ee/e2eeType";
|
||||||
import { saveKeyForRoom } from "./e2ee/sharedKeyManagement";
|
import { EncryptionSystem, saveKeyForRoom } from "./e2ee/sharedKeyManagement";
|
||||||
|
|
||||||
export const fallbackICEServerAllowed =
|
export const fallbackICEServerAllowed =
|
||||||
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
|
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
|
||||||
@@ -55,7 +54,13 @@ const SYNC_STORE_NAME = "element-call-sync";
|
|||||||
// (It's a good opportunity to make the database names consistent.)
|
// (It's a good opportunity to make the database names consistent.)
|
||||||
const CRYPTO_STORE_NAME = "element-call-crypto";
|
const CRYPTO_STORE_NAME = "element-call-crypto";
|
||||||
|
|
||||||
function waitForSync(client: MatrixClient): Promise<void> {
|
async function waitForSync(client: MatrixClient): Promise<void> {
|
||||||
|
// If there is a saved sync, the client will fire an additional sync event
|
||||||
|
// for restoring it before it runs the first network sync.
|
||||||
|
// However, the sync we want to wait for is the network sync,
|
||||||
|
// as the saved sync may be missing some state.
|
||||||
|
// Thus, don't resolve on the first sync when we know it's for the saved sync.
|
||||||
|
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const onSync = (
|
const onSync = (
|
||||||
state: SyncState,
|
state: SyncState,
|
||||||
@@ -63,8 +68,12 @@ function waitForSync(client: MatrixClient): Promise<void> {
|
|||||||
data?: ISyncStateData,
|
data?: ISyncStateData,
|
||||||
): void => {
|
): void => {
|
||||||
if (state === "PREPARED") {
|
if (state === "PREPARED") {
|
||||||
client.removeListener(ClientEvent.Sync, onSync);
|
if (waitForSavedSync) {
|
||||||
resolve();
|
waitForSavedSync = false;
|
||||||
|
} else {
|
||||||
|
client.removeListener(ClientEvent.Sync, onSync);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
} else if (state === "ERROR") {
|
} else if (state === "ERROR") {
|
||||||
client.removeListener(ClientEvent.Sync, onSync);
|
client.removeListener(ClientEvent.Sync, onSync);
|
||||||
reject(data?.error);
|
reject(data?.error);
|
||||||
@@ -87,8 +96,6 @@ export async function initClient(
|
|||||||
clientOptions: ICreateClientOpts,
|
clientOptions: ICreateClientOpts,
|
||||||
restore: boolean,
|
restore: boolean,
|
||||||
): Promise<MatrixClient> {
|
): Promise<MatrixClient> {
|
||||||
await loadOlm();
|
|
||||||
|
|
||||||
let indexedDB: IDBFactory | undefined;
|
let indexedDB: IDBFactory | undefined;
|
||||||
try {
|
try {
|
||||||
indexedDB = window.indexedDB;
|
indexedDB = window.indexedDB;
|
||||||
@@ -188,10 +195,16 @@ export async function initClient(
|
|||||||
await client.store.startup();
|
await client.store.startup();
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.initCrypto();
|
await client.initRustCrypto();
|
||||||
client.setGlobalErrorOnUnknownDevices(false);
|
client.setGlobalErrorOnUnknownDevices(false);
|
||||||
await client.startClient();
|
// Once startClient is called, syncs are run asynchronously.
|
||||||
await waitForSync(client);
|
// Also, sync completion is communicated only via events.
|
||||||
|
// So, apply the event listener *before* starting the client.
|
||||||
|
// Otherwise, a sync may complete before the listener gets applied,
|
||||||
|
// and we will miss it.
|
||||||
|
const syncPromise = waitForSync(client);
|
||||||
|
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
|
||||||
|
await syncPromise;
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
@@ -338,16 +351,6 @@ export async function createRoom(
|
|||||||
|
|
||||||
const result = await createPromise;
|
const result = await createPromise;
|
||||||
|
|
||||||
logger.log(`Creating group call in ${result.room_id}`);
|
|
||||||
|
|
||||||
await client.createGroupCall(
|
|
||||||
result.room_id,
|
|
||||||
GroupCallType.Video,
|
|
||||||
false,
|
|
||||||
GroupCallIntent.Room,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
let password;
|
let password;
|
||||||
if (e2ee == E2eeType.SHARED_KEY) {
|
if (e2ee == E2eeType.SHARED_KEY) {
|
||||||
password = secureRandomBase64Url(16);
|
password = secureRandomBase64Url(16);
|
||||||
@@ -365,39 +368,35 @@ export async function createRoom(
|
|||||||
* Returns an absolute URL to that will load Element Call with the given room
|
* Returns an absolute URL to that will load Element Call with the given room
|
||||||
* @param roomId ID of the room
|
* @param roomId ID of the room
|
||||||
* @param roomName Name of the room
|
* @param roomName Name of the room
|
||||||
* @param password e2e key for the room
|
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
|
||||||
*/
|
*/
|
||||||
export function getAbsoluteRoomUrl(
|
export function getAbsoluteRoomUrl(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
|
encryptionSystem: EncryptionSystem,
|
||||||
roomName?: string,
|
roomName?: string,
|
||||||
password?: string,
|
viaServers?: string[],
|
||||||
): string {
|
): string {
|
||||||
return `${window.location.protocol}//${
|
return `${window.location.protocol}//${
|
||||||
window.location.host
|
window.location.host
|
||||||
}${getRelativeRoomUrl(roomId, roomName, password)}`;
|
}${getRelativeRoomUrl(roomId, encryptionSystem, roomName, viaServers)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a relative URL to that will load Element Call with the given room
|
* Returns a relative URL to that will load Element Call with the given room
|
||||||
* @param roomId ID of the room
|
* @param roomId ID of the room
|
||||||
* @param roomName Name of the room
|
* @param roomName Name of the room
|
||||||
* @param password e2e key for the room
|
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
|
||||||
*/
|
*/
|
||||||
export function getRelativeRoomUrl(
|
export function getRelativeRoomUrl(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
|
encryptionSystem: EncryptionSystem,
|
||||||
roomName?: string,
|
roomName?: string,
|
||||||
password?: string,
|
viaServers?: string[],
|
||||||
): string {
|
): string {
|
||||||
// The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
|
const roomPart = roomName
|
||||||
// it in case it came from another client that generated a non url-safe one
|
? "/" + roomAliasLocalpartFromRoomName(roomName)
|
||||||
const encodedPassword = password ? encodeURIComponent(password) : undefined;
|
: "";
|
||||||
if (password && encodedPassword !== password) {
|
return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`;
|
||||||
logger.info("Encoded call password used non URL-safe chars: buggy client?");
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/room/#${
|
|
||||||
roomName ? "/" + roomAliasLocalpartFromRoomName(roomName) : ""
|
|
||||||
}?roomId=${roomId}${password ? "&" + PASSWORD_STRING + encodedPassword : ""}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvatarUrl(
|
export function getAvatarUrl(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Observable, defer, finalize, tap } from "rxjs";
|
import { Observable, defer, finalize, scan, startWith, tap } from "rxjs";
|
||||||
|
|
||||||
const nothing = Symbol("nothing");
|
const nothing = Symbol("nothing");
|
||||||
|
|
||||||
@@ -35,3 +35,15 @@ export function finalizeValue<T>(callback: (finalValue: T) => void) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RxJS operator that accumulates a state from a source of events. This is like
|
||||||
|
* scan, except it emits an initial value immediately before any events arrive.
|
||||||
|
*/
|
||||||
|
export function accumulate<State, Event>(
|
||||||
|
initial: State,
|
||||||
|
update: (state: State, event: Event) => State,
|
||||||
|
) {
|
||||||
|
return (events: Observable<Event>): Observable<State> =>
|
||||||
|
events.pipe(scan(update, initial), startWith(initial));
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import {
|
|||||||
SummaryStatsReport,
|
SummaryStatsReport,
|
||||||
CallFeedReport,
|
CallFeedReport,
|
||||||
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
|
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
|
||||||
import { setSpan } from "@opentelemetry/api/build/esm/trace/context-utils";
|
|
||||||
|
|
||||||
import { ElementCallOpenTelemetry } from "./otel";
|
import { ElementCallOpenTelemetry } from "./otel";
|
||||||
import { ObjectFlattener } from "./ObjectFlattener";
|
import { ObjectFlattener } from "./ObjectFlattener";
|
||||||
@@ -446,7 +445,7 @@ export class OTelGroupCallMembership {
|
|||||||
const type = OTelStatsReportType.SummaryReport;
|
const type = OTelStatsReportType.SummaryReport;
|
||||||
const data = ObjectFlattener.flattenSummaryStatsReportObject(statsReport);
|
const data = ObjectFlattener.flattenSummaryStatsReportObject(statsReport);
|
||||||
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
|
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
|
||||||
const ctx = setSpan(
|
const ctx = opentelemetry.trace.setSpan(
|
||||||
opentelemetry.context.active(),
|
opentelemetry.context.active(),
|
||||||
this.callMembershipSpan,
|
this.callMembershipSpan,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function useProfile(client: MatrixClient | undefined): UseProfile {
|
|||||||
user?.on(UserEvent.AvatarUrl, onChangeUser);
|
user?.on(UserEvent.AvatarUrl, onChangeUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return (): void => {
|
||||||
if (user) {
|
if (user) {
|
||||||
user.removeListener(UserEvent.DisplayName, onChangeUser);
|
user.removeListener(UserEvent.DisplayName, onChangeUser);
|
||||||
user.removeListener(UserEvent.AvatarUrl, onChangeUser);
|
user.removeListener(UserEvent.AvatarUrl, onChangeUser);
|
||||||
@@ -118,7 +118,7 @@ export function useProfile(client: MatrixClient | undefined): UseProfile {
|
|||||||
displayName,
|
displayName,
|
||||||
avatarUrl: removeAvatar
|
avatarUrl: removeAvatar
|
||||||
? undefined
|
? undefined
|
||||||
: mxcAvatarUrl ?? prev.avatarUrl,
|
: (mxcAvatarUrl ?? prev.avatarUrl),
|
||||||
loading: false,
|
loading: false,
|
||||||
success: true,
|
success: true,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -17,17 +17,18 @@ limitations under the License.
|
|||||||
import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
|
import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button, Text } from "@vector-im/compound-web";
|
import { Button, Text } from "@vector-im/compound-web";
|
||||||
import PopOutIcon from "@vector-im/compound-design-tokens/icons/pop-out.svg?react";
|
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
import { useIsRoomE2EE, useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { getAbsoluteRoomUrl } from "../matrix-utils";
|
import { getAbsoluteRoomUrl } from "../matrix-utils";
|
||||||
import styles from "./AppSelectionModal.module.css";
|
import styles from "./AppSelectionModal.module.css";
|
||||||
import { editFragmentQuery } from "../UrlParams";
|
import { editFragmentQuery } from "../UrlParams";
|
||||||
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
roomId: string | null;
|
roomId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
||||||
@@ -42,10 +43,9 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
|||||||
},
|
},
|
||||||
[setOpen],
|
[setOpen],
|
||||||
);
|
);
|
||||||
|
const e2eeSystem = useRoomEncryptionSystem(roomId);
|
||||||
|
|
||||||
const roomSharedKey = useRoomSharedKey(roomId ?? "");
|
if (e2eeSystem.kind === E2eeType.NONE) {
|
||||||
const roomIsEncrypted = useIsRoomE2EE(roomId ?? "");
|
|
||||||
if (roomIsEncrypted && roomSharedKey === undefined) {
|
|
||||||
logger.error(
|
logger.error(
|
||||||
"Generating app redirect URL for encrypted room but don't have key available!",
|
"Generating app redirect URL for encrypted room but don't have key available!",
|
||||||
);
|
);
|
||||||
@@ -60,7 +60,7 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
|||||||
const url = new URL(
|
const url = new URL(
|
||||||
roomId === null
|
roomId === null
|
||||||
? window.location.href
|
? window.location.href
|
||||||
: getAbsoluteRoomUrl(roomId, undefined, roomSharedKey ?? undefined),
|
: getAbsoluteRoomUrl(roomId, e2eeSystem),
|
||||||
);
|
);
|
||||||
// Edit the URL to prevent the app selection prompt from appearing a second
|
// Edit the URL to prevent the app selection prompt from appearing a second
|
||||||
// time within the app, and to keep the user confined to the current room
|
// time within the app, and to keep the user confined to the current room
|
||||||
@@ -73,7 +73,7 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
|||||||
const result = new URL("io.element.call:/");
|
const result = new URL("io.element.call:/");
|
||||||
result.searchParams.set("url", url.toString());
|
result.searchParams.set("url", url.toString());
|
||||||
return result.toString();
|
return result.toString();
|
||||||
}, [roomId, roomSharedKey]);
|
}, [e2eeSystem, roomId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ export const CallEndedView: FC<Props> = ({
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const { displayName } = useProfile(client);
|
const { displayName } = useProfile(client);
|
||||||
const [surveySubmitted, setSurverySubmitted] = useState(false);
|
const [surveySubmitted, setSurveySubmitted] = useState(false);
|
||||||
const [starRating, setStarRating] = useState(0);
|
const [starRating, setStarRating] = useState(0);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitDone, setSubmitDone] = useState(false);
|
const [submitDone, setSubmitDone] = useState(false);
|
||||||
const submitSurvery: FormEventHandler<HTMLFormElement> = useCallback(
|
const submitSurvey: FormEventHandler<HTMLFormElement> = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const data = new FormData(e.target as HTMLFormElement);
|
const data = new FormData(e.target as HTMLFormElement);
|
||||||
@@ -75,7 +75,7 @@ export const CallEndedView: FC<Props> = ({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isPasswordlessUser) {
|
if (isPasswordlessUser) {
|
||||||
// setting this renders the callEndedView with the invitation to create an account
|
// setting this renders the callEndedView with the invitation to create an account
|
||||||
setSurverySubmitted(true);
|
setSurveySubmitted(true);
|
||||||
} else if (!confineToRoom) {
|
} else if (!confineToRoom) {
|
||||||
// if the user already has an account immediately go back to the home screen
|
// if the user already has an account immediately go back to the home screen
|
||||||
history.push("/");
|
history.push("/");
|
||||||
@@ -113,7 +113,7 @@ export const CallEndedView: FC<Props> = ({
|
|||||||
We'd love to hear your feedback so we can improve your experience.
|
We'd love to hear your feedback so we can improve your experience.
|
||||||
</p>
|
</p>
|
||||||
</Trans>
|
</Trans>
|
||||||
<form onSubmit={submitSurvery}>
|
<form onSubmit={submitSurvey}>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<StarRatingInput starCount={5} onChange={setStarRating} required />
|
<StarRatingInput starCount={5} onChange={setStarRating} required />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2023 New Vector Ltd
|
Copyright 2023-2024 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.
|
||||||
@@ -17,8 +17,10 @@ limitations under the License.
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { Tooltip } from "@vector-im/compound-web";
|
import { Tooltip } from "@vector-im/compound-web";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import LockSolidIcon from "@vector-im/compound-design-tokens/icons/lock-solid.svg?react";
|
import {
|
||||||
import LockOffIcon from "@vector-im/compound-design-tokens/icons/lock-off.svg?react";
|
LockSolidIcon,
|
||||||
|
LockOffIcon,
|
||||||
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import styles from "./EncryptionLock.module.css";
|
import styles from "./EncryptionLock.module.css";
|
||||||
|
|
||||||
@@ -32,13 +34,13 @@ export const EncryptionLock: FC<Props> = ({ encrypted }) => {
|
|||||||
const label = encrypted ? t("common.encrypted") : t("common.unencrypted");
|
const label = encrypted ? t("common.encrypted") : t("common.unencrypted");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} side="right" isTriggerInteractive={false}>
|
<Tooltip label={label} placement="right" isTriggerInteractive={false}>
|
||||||
<Icon
|
<Icon
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
className={styles.lock}
|
className={styles.lock}
|
||||||
data-encrypted={encrypted}
|
data-encrypted={encrypted}
|
||||||
aria-label={label}
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,22 +14,25 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactNode, useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
|
||||||
import { MatrixError } from "matrix-js-sdk";
|
import { MatrixError } from "matrix-js-sdk";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { Heading, Link, Text } from "@vector-im/compound-web";
|
import { Heading, Link, Text } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import { useLoadGroupCall } from "./useLoadGroupCall";
|
import {
|
||||||
|
useLoadGroupCall,
|
||||||
|
GroupCallStatus,
|
||||||
|
CallTerminatedMessage,
|
||||||
|
} from "./useLoadGroupCall";
|
||||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
roomIdOrAlias: string;
|
roomIdOrAlias: string;
|
||||||
viaServers: string[];
|
viaServers: string[];
|
||||||
children: (rtcSession: MatrixRTCSession) => ReactNode;
|
children: (groupCallState: GroupCallStatus) => JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupCallLoader({
|
export function GroupCallLoader({
|
||||||
@@ -51,20 +54,22 @@ export function GroupCallLoader({
|
|||||||
);
|
);
|
||||||
|
|
||||||
switch (groupCallState.kind) {
|
switch (groupCallState.kind) {
|
||||||
|
case "loaded":
|
||||||
|
case "waitForInvite":
|
||||||
|
case "canKnock":
|
||||||
|
return children(groupCallState);
|
||||||
case "loading":
|
case "loading":
|
||||||
return (
|
return (
|
||||||
<FullScreenView>
|
<FullScreenView>
|
||||||
<h1>{t("common.loading")}</h1>
|
<h1>{t("common.loading")}</h1>
|
||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
case "loaded":
|
|
||||||
return <>{children(groupCallState.rtcSession)}</>;
|
|
||||||
case "failed":
|
case "failed":
|
||||||
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||||
return (
|
return (
|
||||||
<FullScreenView>
|
<FullScreenView>
|
||||||
<Heading>{t("group_call_loader_failed_heading")}</Heading>
|
<Heading>{t("group_call_loader.failed_heading")}</Heading>
|
||||||
<Text>{t("group_call_loader_failed_text")}</Text>
|
<Text>{t("group_call_loader.failed_text")}</Text>
|
||||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||||
dupes of this flow, let's make a common component and put it here. */}
|
dupes of this flow, let's make a common component and put it here. */}
|
||||||
<Link href="/" onClick={onHomeClick}>
|
<Link href="/" onClick={onHomeClick}>
|
||||||
@@ -72,6 +77,22 @@ export function GroupCallLoader({
|
|||||||
</Link>
|
</Link>
|
||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
|
} else if (groupCallState.error instanceof CallTerminatedMessage) {
|
||||||
|
return (
|
||||||
|
<FullScreenView>
|
||||||
|
<Heading>{groupCallState.error.message}</Heading>
|
||||||
|
<Text>{groupCallState.error.messageBody}</Text>
|
||||||
|
{groupCallState.error.reason && (
|
||||||
|
<>
|
||||||
|
{t("group_call_loader.reason")}:
|
||||||
|
<Text size="sm">"{groupCallState.error.reason}"</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link href="/" onClick={onHomeClick}>
|
||||||
|
{t("common.home")}
|
||||||
|
</Link>
|
||||||
|
</FullScreenView>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return <ErrorView error={groupCallState.error} />;
|
return <ErrorView error={groupCallState.error} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ limitations under the License.
|
|||||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { Room, isE2EESupported } from "livekit-client";
|
import {
|
||||||
|
Room,
|
||||||
|
isE2EESupported as isE2EESupportedBrowser,
|
||||||
|
} from "livekit-client";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||||
import { JoinRule } from "matrix-js-sdk/src/matrix";
|
import { JoinRule } from "matrix-js-sdk/src/matrix";
|
||||||
@@ -26,7 +29,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||||
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
|
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
|
||||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
import { FullScreenView } from "../FullScreenView";
|
||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
import { MatrixInfo } from "./VideoPreview";
|
import { MatrixInfo } from "./VideoPreview";
|
||||||
import { CallEndedView } from "./CallEndedView";
|
import { CallEndedView } from "./CallEndedView";
|
||||||
@@ -34,17 +37,16 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
|||||||
import { useProfile } from "../profile/useProfile";
|
import { useProfile } from "../profile/useProfile";
|
||||||
import { findDeviceByName } from "../media-utils";
|
import { findDeviceByName } from "../media-utils";
|
||||||
import { ActiveCall } from "./InCallView";
|
import { ActiveCall } from "./InCallView";
|
||||||
import { MuteStates, useMuteStates } from "./MuteStates";
|
import { MUTE_PARTICIPANT_COUNT, MuteStates } from "./MuteStates";
|
||||||
import { useMediaDevices, MediaDevices } from "../livekit/MediaDevicesContext";
|
import { useMediaDevices, MediaDevices } from "../livekit/MediaDevicesContext";
|
||||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||||
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
||||||
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
|
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
|
||||||
import { useIsRoomE2EE, useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { useRoomAvatar } from "./useRoomAvatar";
|
import { useRoomAvatar } from "./useRoomAvatar";
|
||||||
import { useRoomName } from "./useRoomName";
|
import { useRoomName } from "./useRoomName";
|
||||||
import { useJoinRule } from "./useJoinRule";
|
import { useJoinRule } from "./useJoinRule";
|
||||||
import { InviteModal } from "./InviteModal";
|
import { InviteModal } from "./InviteModal";
|
||||||
import { E2EEConfig } from "../livekit/useLiveKit";
|
|
||||||
import { useUrlParams } from "../UrlParams";
|
import { useUrlParams } from "../UrlParams";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ interface Props {
|
|||||||
skipLobby: boolean;
|
skipLobby: boolean;
|
||||||
hideHeader: boolean;
|
hideHeader: boolean;
|
||||||
rtcSession: MatrixRTCSession;
|
rtcSession: MatrixRTCSession;
|
||||||
|
muteStates: MuteStates;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GroupCallView: FC<Props> = ({
|
export const GroupCallView: FC<Props> = ({
|
||||||
@@ -72,13 +75,21 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
skipLobby,
|
skipLobby,
|
||||||
hideHeader,
|
hideHeader,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
|
muteStates,
|
||||||
}) => {
|
}) => {
|
||||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||||
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
|
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
|
||||||
|
|
||||||
|
// This should use `useEffectEvent` (only available in experimental versions)
|
||||||
|
useEffect(() => {
|
||||||
|
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
|
||||||
|
muteStates.audio.setEnabled?.(false);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.rtcSession = rtcSession;
|
window.rtcSession = rtcSession;
|
||||||
return () => {
|
return (): void => {
|
||||||
delete window.rtcSession;
|
delete window.rtcSession;
|
||||||
};
|
};
|
||||||
}, [rtcSession]);
|
}, [rtcSession]);
|
||||||
@@ -86,10 +97,8 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
const { displayName, avatarUrl } = useProfile(client);
|
const { displayName, avatarUrl } = useProfile(client);
|
||||||
const roomName = useRoomName(rtcSession.room);
|
const roomName = useRoomName(rtcSession.room);
|
||||||
const roomAvatar = useRoomAvatar(rtcSession.room);
|
const roomAvatar = useRoomAvatar(rtcSession.room);
|
||||||
const e2eeSharedKey = useRoomSharedKey(rtcSession.room.roomId);
|
|
||||||
const { perParticipantE2EE, returnToLobby } = useUrlParams();
|
const { perParticipantE2EE, returnToLobby } = useUrlParams();
|
||||||
const roomEncrypted =
|
const e2eeSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
||||||
useIsRoomE2EE(rtcSession.room.roomId) || perParticipantE2EE;
|
|
||||||
|
|
||||||
const matrixInfo = useMemo((): MatrixInfo => {
|
const matrixInfo = useMemo((): MatrixInfo => {
|
||||||
return {
|
return {
|
||||||
@@ -100,16 +109,16 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
roomName,
|
roomName,
|
||||||
roomAlias: rtcSession.room.getCanonicalAlias(),
|
roomAlias: rtcSession.room.getCanonicalAlias(),
|
||||||
roomAvatar,
|
roomAvatar,
|
||||||
roomEncrypted,
|
e2eeSystem,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
client,
|
||||||
displayName,
|
displayName,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
rtcSession,
|
rtcSession.room,
|
||||||
roomName,
|
roomName,
|
||||||
roomAvatar,
|
roomAvatar,
|
||||||
roomEncrypted,
|
e2eeSystem,
|
||||||
client,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Count each member only once, regardless of how many devices they use
|
// Count each member only once, regardless of how many devices they use
|
||||||
@@ -122,20 +131,9 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
const latestDevices = useRef<MediaDevices>();
|
const latestDevices = useRef<MediaDevices>();
|
||||||
latestDevices.current = deviceContext;
|
latestDevices.current = deviceContext;
|
||||||
|
|
||||||
const muteStates = useMuteStates(memberships.length);
|
|
||||||
const latestMuteStates = useRef<MuteStates>();
|
const latestMuteStates = useRef<MuteStates>();
|
||||||
latestMuteStates.current = muteStates;
|
latestMuteStates.current = muteStates;
|
||||||
|
|
||||||
const e2eeConfig = useMemo((): E2EEConfig => {
|
|
||||||
if (perParticipantE2EE) {
|
|
||||||
return { mode: E2eeType.PER_PARTICIPANT };
|
|
||||||
} else if (e2eeSharedKey) {
|
|
||||||
return { mode: E2eeType.SHARED_KEY, sharedKey: e2eeSharedKey };
|
|
||||||
} else {
|
|
||||||
return { mode: E2eeType.NONE };
|
|
||||||
}
|
|
||||||
}, [perParticipantE2EE, e2eeSharedKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const defaultDeviceSetup = async (
|
const defaultDeviceSetup = async (
|
||||||
requestedDeviceData: JoinCallData,
|
requestedDeviceData: JoinCallData,
|
||||||
@@ -192,18 +190,21 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
const onJoin = async (
|
const onJoin = async (
|
||||||
ev: CustomEvent<IWidgetApiRequest>,
|
ev: CustomEvent<IWidgetApiRequest>,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||||
enterRTCSession(rtcSession, perParticipantE2EE);
|
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||||
await widget!.api.transport.reply(ev.detail, {});
|
await widget!.api.transport.reply(ev.detail, {});
|
||||||
};
|
};
|
||||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||||
return () => {
|
return (): void => {
|
||||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||||
};
|
};
|
||||||
} else if (widget && !preload && skipLobby) {
|
} else if (widget && !preload && skipLobby) {
|
||||||
// No lobby and no preload: we enter the rtc session right away
|
const join = async (): Promise<void> => {
|
||||||
defaultDeviceSetup({ audioInput: null, videoInput: null });
|
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||||
enterRTCSession(rtcSession, perParticipantE2EE);
|
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||||
|
};
|
||||||
|
// No lobby and no preload: we enter the RTC Session right away.
|
||||||
|
join();
|
||||||
}
|
}
|
||||||
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
|
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
|
||||||
|
|
||||||
@@ -252,7 +253,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
await leaveRTCSession(rtcSession);
|
await leaveRTCSession(rtcSession);
|
||||||
};
|
};
|
||||||
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
|
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
|
||||||
return () => {
|
return (): void => {
|
||||||
widget!.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
|
widget!.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -288,17 +289,8 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (roomEncrypted && !perParticipantE2EE && !e2eeSharedKey) {
|
if (!isE2EESupportedBrowser() && e2eeSystem.kind !== E2eeType.NONE) {
|
||||||
return (
|
// If we have a encryption system but the browser does not support it.
|
||||||
<ErrorView
|
|
||||||
error={
|
|
||||||
new Error(
|
|
||||||
"No E2EE key provided: please make sure the URL you're using to join this call has been retrieved using the in-app button.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (!isE2EESupported() && roomEncrypted) {
|
|
||||||
return (
|
return (
|
||||||
<FullScreenView>
|
<FullScreenView>
|
||||||
<Heading>{t("browser_media_e2ee_unsupported_heading")}</Heading>
|
<Heading>{t("browser_media_e2ee_unsupported_heading")}</Heading>
|
||||||
@@ -324,7 +316,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
client={client}
|
client={client}
|
||||||
matrixInfo={matrixInfo}
|
matrixInfo={matrixInfo}
|
||||||
muteStates={muteStates}
|
muteStates={muteStates}
|
||||||
onEnter={(): void => enterRTCSession(rtcSession, perParticipantE2EE)}
|
onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)}
|
||||||
confineToRoom={confineToRoom}
|
confineToRoom={confineToRoom}
|
||||||
hideHeader={hideHeader}
|
hideHeader={hideHeader}
|
||||||
participantCount={participantCount}
|
participantCount={participantCount}
|
||||||
@@ -345,7 +337,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
onLeave={onLeave}
|
onLeave={onLeave}
|
||||||
hideHeader={hideHeader}
|
hideHeader={hideHeader}
|
||||||
muteStates={muteStates}
|
muteStates={muteStates}
|
||||||
e2eeConfig={e2eeConfig}
|
e2eeSystem={e2eeSystem}
|
||||||
//otelGroupCallMembership={otelGroupCallMembership}
|
//otelGroupCallMembership={otelGroupCallMembership}
|
||||||
onShareClick={onShareClick}
|
onShareClick={onShareClick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2021 New Vector Ltd
|
Copyright 2021-2024 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.
|
||||||
@@ -19,36 +19,31 @@ limitations under the License.
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlsOverlay {
|
.header {
|
||||||
position: relative;
|
position: sticky;
|
||||||
flex: 1;
|
flex-shrink: 0;
|
||||||
display: flex;
|
inset-block-start: 0;
|
||||||
flex-direction: column;
|
z-index: 1;
|
||||||
overflow: auto;
|
background: linear-gradient(
|
||||||
overflow-inline: hidden;
|
0deg,
|
||||||
/* There used to be a contain: strict here, but due to some bugs in Firefox,
|
rgba(0, 0, 0, 0) 0%,
|
||||||
this was causing the Z-ordering of modals to glitch out. It can be added back
|
var(--cpd-color-bg-canvas-default) 100%
|
||||||
if those issues appear to be resolved. */
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.centerMessage {
|
.header.filler {
|
||||||
display: flex;
|
block-size: var(--cpd-space-6x);
|
||||||
flex: 1;
|
background: none;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.centerMessage p {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
inset-block-end: 0;
|
inset-block-end: 0;
|
||||||
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
grid-template-areas: "logo buttons layout";
|
grid-template-areas: "logo buttons layout";
|
||||||
@@ -63,6 +58,24 @@ limitations under the License.
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: 0;
|
||||||
|
inset-inline: 0;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer.overlay.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer.overlay:has(:focus-visible) {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: initial;
|
||||||
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
grid-area: logo;
|
grid-area: logo;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
@@ -85,13 +98,13 @@ limitations under the License.
|
|||||||
|
|
||||||
@media (min-height: 400px) {
|
@media (min-height: 400px) {
|
||||||
.footer {
|
.footer {
|
||||||
padding-block: var(--cpd-space-10x);
|
padding-block: var(--cpd-space-8x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-height: 800px) {
|
@media (min-height: 800px) {
|
||||||
.footer {
|
.footer {
|
||||||
padding-block: var(--cpd-space-15x);
|
padding-block: var(--cpd-space-10x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,11 +114,37 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footerThin {
|
.fixedGrid {
|
||||||
padding-top: var(--cpd-space-3x);
|
position: absolute;
|
||||||
padding-bottom: var(--cpd-space-5x);
|
inline-size: 100%;
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footerHidden {
|
.scrollingGrid {
|
||||||
display: none;
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
|
inline-size: 100%;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixedGrid,
|
||||||
|
.scrollingGrid {
|
||||||
|
/* Disable pointer events so the overlay doesn't block interaction with
|
||||||
|
elements behind it */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixedGrid > :not(:first-child),
|
||||||
|
.scrollingGrid > :not(:first-child) {
|
||||||
|
pointer-events: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.maximised {
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user