Compare commits
427 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
568c989ff7 | ||
|
|
8451296f3a | ||
|
|
606358c51b | ||
|
|
fd0956bbc5 | ||
|
|
c01e363639 | ||
|
|
0114db7d2d | ||
|
|
e93dfb54d2 | ||
|
|
f1ee3604de | ||
|
|
d3111758b7 | ||
|
|
abdb85226f | ||
|
|
a56ef52eb0 | ||
|
|
01f20bf6c0 | ||
|
|
aae86ed830 | ||
|
|
172ff266a6 | ||
|
|
d7ecbff9ed | ||
|
|
f0f2ffe972 | ||
|
|
491b0991cb | ||
|
|
a961647e86 | ||
|
|
207554f067 | ||
|
|
b0ba366a2c | ||
|
|
047fc822d6 | ||
|
|
7d454645d0 | ||
|
|
fcb923f6db | ||
|
|
e3c34a7145 | ||
|
|
1c9b2a24d9 | ||
|
|
7ab94cb003 | ||
|
|
239095321a | ||
|
|
caf90d851e | ||
|
|
a74733f6bc | ||
|
|
440c617738 | ||
|
|
0f7ee3b860 | ||
|
|
dc15fbc8c9 | ||
|
|
d7e6c8c913 | ||
|
|
7c5c4d1870 | ||
|
|
2df8488c20 | ||
|
|
d270756443 | ||
|
|
8dd58d7e5c | ||
|
|
d148a81f91 | ||
|
|
9587dd7352 | ||
|
|
099dcd28c7 | ||
|
|
e925e7e060 | ||
|
|
e3d5c84b17 | ||
|
|
6f4ab0d3ab | ||
|
|
283b5d4504 | ||
|
|
6dc26392d7 | ||
|
|
4572e35339 | ||
|
|
4181780040 | ||
|
|
b19150bbba | ||
|
|
f6c95461e4 | ||
|
|
bfe65adb51 | ||
|
|
e031340ccb | ||
|
|
c70536996e | ||
|
|
a71a1c5c93 | ||
|
|
f7fc03cdc9 | ||
|
|
52dccff229 | ||
|
|
9c8692d99e | ||
|
|
69826b4de0 | ||
|
|
f2a7de2d8e | ||
|
|
2318f2c4a0 | ||
|
|
515e00b763 | ||
|
|
836c3b5614 | ||
|
|
403ee79d61 | ||
|
|
ecf5f9916c | ||
|
|
4e8146bc9c | ||
|
|
323dba620d | ||
|
|
8d56a8e8fc | ||
|
|
e3a3859739 | ||
|
|
15350b6989 | ||
|
|
4c33a29aef | ||
|
|
ecc3693c47 | ||
|
|
7153ead8cb | ||
|
|
4ddf9ce29c | ||
|
|
32476571fb | ||
|
|
0df028888e | ||
|
|
8ab7ee9298 | ||
|
|
3c7cb3cb96 | ||
|
|
c59c9053c9 | ||
|
|
f1ad2e2f8a | ||
|
|
f30dc9593f | ||
|
|
a0bed20576 | ||
|
|
742482d0a8 | ||
|
|
ac17ae6557 | ||
|
|
1c0dedc27f | ||
|
|
4224916b81 | ||
|
|
12633f8e12 | ||
|
|
6b14e622dd | ||
|
|
060a50d27a | ||
|
|
a9e63ddbcc | ||
|
|
4459eaeb9d | ||
|
|
f5e2161a9e | ||
|
|
8437e263af | ||
|
|
4719a92ffc | ||
|
|
9b398590b9 | ||
|
|
cfcd7e6b22 | ||
|
|
d96643d003 | ||
|
|
d1aa34b2e0 | ||
|
|
1792ef7e38 | ||
|
|
1dd70ea22d | ||
|
|
28368da60a | ||
|
|
4114622d44 | ||
|
|
6f2b32ead5 | ||
|
|
6f13989819 | ||
|
|
1184b71396 | ||
|
|
56bd54a645 | ||
|
|
18fa1371d3 | ||
|
|
f6f0fce2b2 | ||
|
|
a1b6e91354 | ||
|
|
6ad4663508 | ||
|
|
30a224e20e | ||
|
|
3c7f01a510 | ||
|
|
2b5de6db03 | ||
|
|
8eafb1ae4a | ||
|
|
3da4b4eeef | ||
|
|
c31185ffef | ||
|
|
0de1aa74ee | ||
|
|
838137c83b | ||
|
|
f627835646 | ||
|
|
9442b314b2 | ||
|
|
7221b7c3a2 | ||
|
|
370a6579fb | ||
|
|
5bec960112 | ||
|
|
da7760d7ab | ||
|
|
a17ffcc327 | ||
|
|
d211d27817 | ||
|
|
0637804d61 | ||
|
|
a2b3e098b6 | ||
|
|
4bcddad316 | ||
|
|
e2293665f9 | ||
|
|
95eca18207 | ||
|
|
2f33902ea9 | ||
|
|
6999765f39 | ||
|
|
480e46c5b2 | ||
|
|
bb5c382fd0 | ||
|
|
2b71a6c4f4 | ||
|
|
dd1485a277 | ||
|
|
caea22fa89 | ||
|
|
858c68baf1 | ||
|
|
de3bad3810 | ||
|
|
88f3b30040 | ||
|
|
928f1c1d6f | ||
|
|
711cdf9a60 | ||
|
|
b2317dac84 | ||
|
|
fec299ab20 | ||
|
|
5e4aa53997 | ||
|
|
0dcaa90650 | ||
|
|
7b88c4330e | ||
|
|
b061cbfb2f | ||
|
|
2435846f66 | ||
|
|
23ddd73f4f | ||
|
|
390442a4c3 | ||
|
|
c824ea6f9a | ||
|
|
28196a2e9d | ||
|
|
5b70def4d2 | ||
|
|
2cd549cdc8 | ||
|
|
e0089a0aee | ||
|
|
61a0534984 | ||
|
|
29223b62ad | ||
|
|
a52251befa | ||
|
|
30f75c6cd2 | ||
|
|
8fa23b7da9 | ||
|
|
277081ee2a | ||
|
|
3a7983d2de | ||
|
|
3b06258e40 | ||
|
|
c53dbfde27 | ||
|
|
889a31489b | ||
|
|
cb0ba6d827 | ||
|
|
e18c69ec89 | ||
|
|
47e0ca2eda | ||
|
|
e870188be3 | ||
|
|
dd67a45671 | ||
|
|
707272bf19 | ||
|
|
dc725f90a9 | ||
|
|
a1aca7bdf2 | ||
|
|
773f2e009d | ||
|
|
5e6c33b3b5 | ||
|
|
72403d1aea | ||
|
|
74b218af8c | ||
|
|
c2b78d59c6 | ||
|
|
21458c8840 | ||
|
|
f96ce8985d | ||
|
|
848e28ef92 | ||
|
|
4bf1fbfd8e | ||
|
|
34a72679a1 | ||
|
|
77c6357b08 | ||
|
|
15e4c01c5d | ||
|
|
1307d89175 | ||
|
|
d53be695f9 | ||
|
|
247d15cbb5 | ||
|
|
66c3d05ae9 | ||
|
|
c4f029ae4f | ||
|
|
8978f94fe4 | ||
|
|
40f5c53c05 | ||
|
|
5f41f9476b | ||
|
|
d1ba5dff38 | ||
|
|
313ebe258e | ||
|
|
71b1d0c6b1 | ||
|
|
76c0277301 | ||
|
|
698bea93e3 | ||
|
|
48493a96e1 | ||
|
|
ec88907981 | ||
|
|
9c0adfd32e | ||
|
|
f6fb65be49 | ||
|
|
3d6ae3fbc3 | ||
|
|
359e055314 | ||
|
|
6696af9b3f | ||
|
|
9b02d17224 | ||
|
|
6b36604c84 | ||
|
|
ef9934ce6b | ||
|
|
e7a7cf3eb8 | ||
|
|
63ede0b51a | ||
|
|
2d91b43a7d | ||
|
|
ff90e507a6 | ||
|
|
f8f5d2011d | ||
|
|
1bf1813a77 | ||
|
|
521b0a857a | ||
|
|
31450219c8 | ||
|
|
22d2404370 | ||
|
|
c519e13885 | ||
|
|
1e2cd97764 | ||
|
|
0cca5ae174 | ||
|
|
971eca59ff | ||
|
|
7aa902853a | ||
|
|
49b23160f3 | ||
|
|
b68d4bf51b | ||
|
|
4c59638d00 | ||
|
|
3e081ac936 | ||
|
|
7e1d5fff14 | ||
|
|
ca1f502e62 | ||
|
|
1913abc682 | ||
|
|
345891dbdf | ||
|
|
29082adb73 | ||
|
|
3dc288574e | ||
|
|
03c15d5b07 | ||
|
|
6fceeec323 | ||
|
|
44762afd2a | ||
|
|
fa97f51907 | ||
|
|
a15ce32ecf | ||
|
|
4a3b7bc6dd | ||
|
|
a1cb52c613 | ||
|
|
4cfa726df1 | ||
|
|
e31de96679 | ||
|
|
16ec643e84 | ||
|
|
fa62b97b27 | ||
|
|
3e072aed3c | ||
|
|
bac795e1e8 | ||
|
|
ba48ade3b7 | ||
|
|
0fab702d70 | ||
|
|
7f8c0153ed | ||
|
|
d35c13b903 | ||
|
|
ee4760351c | ||
|
|
0c66b32b49 | ||
|
|
5eb552e36a | ||
|
|
29e41c7227 | ||
|
|
58d87db55f | ||
|
|
0423a494c4 | ||
|
|
2454daeef9 | ||
|
|
64703fd3cc | ||
|
|
53bc8eb82f | ||
|
|
cdf2d560b8 | ||
|
|
9dff42b437 | ||
|
|
1fc181dc28 | ||
|
|
fbc72283d4 | ||
|
|
9a0dfad5f9 | ||
|
|
efbf319fa1 | ||
|
|
ef4a62ca62 | ||
|
|
8c818b9ce1 | ||
|
|
58ed372afa | ||
|
|
69e6ba93c1 | ||
|
|
b2b2f0bb15 | ||
|
|
8d0bf4cacc | ||
|
|
5448744871 | ||
|
|
933dc4e2d3 | ||
|
|
605dd44df0 | ||
|
|
07a4de638f | ||
|
|
33dd2758d7 | ||
|
|
eda11cfc08 | ||
|
|
d40e467b7d | ||
|
|
d852e33413 | ||
|
|
8d46687a54 | ||
|
|
978b0f08e8 | ||
|
|
374c68e3c0 | ||
|
|
82ac775124 | ||
|
|
6adcf95aaa | ||
|
|
206730ffc0 | ||
|
|
1e858f6ba3 | ||
|
|
22382413dc | ||
|
|
6cd939db0c | ||
|
|
42e4f6ce83 | ||
|
|
aabca7ebff | ||
|
|
579b91abff | ||
|
|
e3b4a695d6 | ||
|
|
e1abbd5291 | ||
|
|
4fc8598e36 | ||
|
|
6784d2ba97 | ||
|
|
0915e327e1 | ||
|
|
eedf8a6d1b | ||
|
|
d7db845f3b | ||
|
|
82c7293308 | ||
|
|
0166eb67fb | ||
|
|
e3081c1c06 | ||
|
|
f540f48461 | ||
|
|
55dece274f | ||
|
|
b12e52d972 | ||
|
|
82f2fd05b5 | ||
|
|
b4c6684ff5 | ||
|
|
4e73c07cb2 | ||
|
|
3805a2f20e | ||
|
|
4e35984900 | ||
|
|
e99294c3f1 | ||
|
|
32fb14107f | ||
|
|
14a5e53e65 | ||
|
|
24fea189dc | ||
|
|
0f73527ccf | ||
|
|
e0b94b51ab | ||
|
|
ee0c623866 | ||
|
|
c321deecba | ||
|
|
b7ac131614 | ||
|
|
acade92d70 | ||
|
|
6e275b9221 | ||
|
|
42d5db6d0f | ||
|
|
8912daa922 | ||
|
|
045103dbc9 | ||
|
|
0f2a62a59f | ||
|
|
d2631a3e02 | ||
|
|
41b72440a0 | ||
|
|
d65464e4db | ||
|
|
59f3b05c07 | ||
|
|
4f0a780ecf | ||
|
|
b8c1dd4c78 | ||
|
|
cf5e9ba2f9 | ||
|
|
4f8bd18efd | ||
|
|
f56177b96a | ||
|
|
85b206c270 | ||
|
|
bf7c45b0bc | ||
|
|
906fcdf72e | ||
|
|
17a3e14d09 | ||
|
|
26e1772c75 | ||
|
|
edfb8709d1 | ||
|
|
7798128cbd | ||
|
|
5c3c15266a | ||
|
|
ea7bfb5afb | ||
|
|
f940063e03 | ||
|
|
a56d974f48 | ||
|
|
435f6f1ae9 | ||
|
|
375db2a47b | ||
|
|
ed1b1c3d3e | ||
|
|
ddb3637d79 | ||
|
|
85d5946d6a | ||
|
|
3cdb413587 | ||
|
|
5ac1212988 | ||
|
|
c9c0ed85f8 | ||
|
|
e4b2180bc2 | ||
|
|
cab8a71ac2 | ||
|
|
ff5ff175fd | ||
|
|
26e1530882 | ||
|
|
35386b5e16 | ||
|
|
18fe2daea7 | ||
|
|
5f24bf0b9c | ||
|
|
990a08f4f6 | ||
|
|
af565ecd77 | ||
|
|
e3d72e1104 | ||
|
|
29ea2cfe90 | ||
|
|
008ecd7409 | ||
|
|
3220b030fb | ||
|
|
0b1689e6f7 | ||
|
|
74255d0554 | ||
|
|
a55046148f | ||
|
|
6acbf792fc | ||
|
|
1cbda01051 | ||
|
|
f61b2db80d | ||
|
|
17d273135f | ||
|
|
9e8dadcc44 | ||
|
|
785756dc91 | ||
|
|
785fa51e0c | ||
|
|
47c2e9e101 | ||
|
|
0bb18be4ef | ||
|
|
81997624d4 | ||
|
|
c2883e52bb | ||
|
|
7e1033f5a4 | ||
|
|
524f530dce | ||
|
|
46d1351d83 | ||
|
|
2318d75bc7 | ||
|
|
486674c442 | ||
|
|
d3fba7fd5f | ||
|
|
9437a00997 | ||
|
|
e1c4042d15 | ||
|
|
860aff4958 | ||
|
|
13b1dcf785 | ||
|
|
035498a8eb | ||
|
|
030ca29664 | ||
|
|
ecdeb97502 | ||
|
|
6168109894 | ||
|
|
a2a1a9032a | ||
|
|
abd909c03a | ||
|
|
be1db442d9 | ||
|
|
afdce66896 | ||
|
|
1b08a5cac3 | ||
|
|
843e7690fa | ||
|
|
a5977fc992 | ||
|
|
6277359d30 | ||
|
|
73682b67ba | ||
|
|
f2193302c1 | ||
|
|
9ba4ce429f | ||
|
|
d9b0e08ea2 | ||
|
|
5f26534496 | ||
|
|
30688715cd | ||
|
|
f20fc78bd7 | ||
|
|
741233909d | ||
|
|
4e0f4a8dc7 | ||
|
|
0d151452ba | ||
|
|
4fd76f9599 | ||
|
|
d123793deb | ||
|
|
449c1c9d79 | ||
|
|
de5b58792e | ||
|
|
7769074410 | ||
|
|
881054e265 | ||
|
|
767f9cdc4a | ||
|
|
946f564f84 | ||
|
|
468e389324 | ||
|
|
62e98f6c47 | ||
|
|
de31c099e3 | ||
|
|
49cae76387 | ||
|
|
d45ea78ddb | ||
|
|
dcbc3ed865 | ||
|
|
ff19135d4e | ||
|
|
de7343d16a | ||
|
|
c09fec5f88 |
24
.github/workflows/e2e.yml
vendored
Normal file
24
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Run E2E tests
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["deploy"]
|
||||
types:
|
||||
- completed
|
||||
branches-ignore:
|
||||
- "main"
|
||||
jobs:
|
||||
e2e:
|
||||
name: E2E tests runs on Element Call
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out test private repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: vector-im/static-call-participant
|
||||
ref: refs/heads/main
|
||||
path: static-call-participant
|
||||
token: ${{ secrets.GH_E2E_TEST_TOKEN }}
|
||||
- name: Build E2E Image
|
||||
run: "cd static-call-participant && docker build --no-cache --tag matrixdotorg/chrome-node-static-call-participant:latest ."
|
||||
- name: Run E2E tests in container
|
||||
run: "docker run --rm -v '${{ github.workspace }}/static-call-participant/callemshost-users.txt:/opt/app/callemshost-users.txt' matrixdotorg/chrome-node-static-call-participant:latest ./e2e.sh"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ dist-ssr
|
||||
*.local
|
||||
.idea/
|
||||
public/config.json
|
||||
/coverage
|
||||
|
||||
26
README.md
26
README.md
@@ -5,6 +5,8 @@
|
||||
|
||||
Full mesh group calls powered by [Matrix](https://matrix.org), implementing [MatrixRTC](https://github.com/matrix-org/matrix-spec-proposals/blob/matthew/group-voip/proposals/3401-group-voip.md).
|
||||
|
||||

|
||||
|
||||
To try it out, visit our hosted version at [call.element.io](https://call.element.io). You can also find the latest development version continuously deployed to [element-call.netlify.app](https://element-call.netlify.app).
|
||||
|
||||
## Host it yourself
|
||||
@@ -40,6 +42,28 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
By default, the app expects you to have a Matrix homeserver (such as [Synapse](https://matrix-org.github.io/synapse/latest/setup/installation.html)) installed locally and running on port 8008. If you wish to use a homeserver on a different URL or one that is hosted on a different server, you can add a config file as above, and include the homeserver URL that you'd like to use.
|
||||
|
||||
Element Call requires a homeserver with registration enabled without any 3pid or token requirements, if you want it to be used by unregistered users. Furthermore, it is not recommended to use it with an existing homeserver where user accounts have joined normal rooms, as it may not be able to handle those yet and it may behave unreliably.
|
||||
|
||||
Therefore, to use a self-hosted homeserver, this is recommended to be a new server where any user account created has not joined any normal rooms anywhere in the Matrix federated network. The homeserver used can be setup to disable federation, so as to prevent spam registrations (if you keep registrations open) and to ensure Element Call continues to work in case any user decides to log in to their Element Call account using the standard Element app and joins normal rooms that Element Call cannot handle.
|
||||
|
||||
### Features
|
||||
|
||||
#### Allow joining group calls without a camera and a microphone
|
||||
|
||||
You can allow joining a group call without video and audio enabling this feature in your `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
...
|
||||
|
||||
"features": {
|
||||
"feature_group_calls_without_video_and_audio": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Element Call is built against [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/pull/2553). To get started, clone, install, and link the package:
|
||||
@@ -60,8 +84,6 @@ yarn
|
||||
yarn link matrix-js-sdk
|
||||
```
|
||||
|
||||
By default, the app expects you to have [Synapse](https://matrix-org.github.io/synapse/latest/setup/installation.html) installed locally and running on port 8008. If you wish to use another homeserver, you can add a config file as above.
|
||||
|
||||
You're now ready to launch the development server:
|
||||
|
||||
```
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"server_name": "call.ems.host"
|
||||
}
|
||||
},
|
||||
"posthog": {
|
||||
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
|
||||
"api_host": "https://posthog-element-call.element.io"
|
||||
},
|
||||
"rageshake": {
|
||||
"submit_url": "https://element.io/bugreports/submit"
|
||||
}
|
||||
|
||||
@@ -2,9 +2,24 @@ server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
root /app;
|
||||
|
||||
location / {
|
||||
root /app;
|
||||
# disable cache entriely by default (apart from Etag which is accurate enough)
|
||||
add_header Cache-Control 'private no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
|
||||
if_modified_since off;
|
||||
expires 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
|
||||
add_header Last-Modified "";
|
||||
|
||||
try_files $uri /$uri /index.html;
|
||||
}
|
||||
|
||||
# assets can be cached because they have hashed filenames
|
||||
location /assets {
|
||||
expires 1w;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
config/otel_dev/README.md
Normal file
18
config/otel_dev/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# OpenTelemetry Collector for development
|
||||
|
||||
This directory contains a docker compose file that starts a jaeger all-in-one instance
|
||||
with an in-memory database, along with a standalone OpenTelemetry collector that forwards
|
||||
traces into the jaeger. Jaeger has a built-in OpenTelemetry collector, but it can't be
|
||||
configured to send CORS headers so can't be used from a browser. This sets the config on
|
||||
the collector to send CORS headers.
|
||||
|
||||
This also adds an nginx to add CORS headers to the jaeger query endpoint, such that it can
|
||||
be used from webapps like stalk (https://deniz.co/stalk/). The CORS enabled endpoint is
|
||||
exposed on port 16687. To use stalk, you should simply be able to navigate to it and add
|
||||
http://127.0.0.1:16687/api as a data source.
|
||||
|
||||
(Yes, we could enable the OTLP collector in jaeger all-in-one and passed this through
|
||||
the nginx to enable CORS too, rather than running a separate collector. There's no reason
|
||||
it's done this way other than that I'd already set up the separate collector.)
|
||||
|
||||
Running `docker compose up` in this directory should be all you need.
|
||||
41
config/otel_dev/collector-gateway.yaml
Normal file
41
config/otel_dev/collector-gateway.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
cors:
|
||||
allowed_origins:
|
||||
# This can't be '*' because opentelemetry-js uses sendBeacon which always operates
|
||||
# in 'withCredentials' mode, which browsers don't allow with an allow-origin of '*'
|
||||
#- "https://pr976--element-call.netlify.app"
|
||||
- "http://*"
|
||||
allowed_headers:
|
||||
- "*"
|
||||
processors:
|
||||
batch:
|
||||
timeout: 1s
|
||||
resource:
|
||||
attributes:
|
||||
- key: test.key
|
||||
value: "test-value"
|
||||
action: insert
|
||||
exporters:
|
||||
logging:
|
||||
loglevel: info
|
||||
jaeger:
|
||||
endpoint: jaeger-all-in-one:14250
|
||||
tls:
|
||||
insecure: true
|
||||
extensions:
|
||||
health_check:
|
||||
pprof:
|
||||
endpoint: :1888
|
||||
zpages:
|
||||
endpoint: :55679
|
||||
service:
|
||||
extensions: [pprof, zpages, health_check]
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [batch, resource]
|
||||
exporters: [logging, jaeger]
|
||||
29
config/otel_dev/docker-compose.yaml
Normal file
29
config/otel_dev/docker-compose.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
version: "2"
|
||||
services:
|
||||
# Jaeger
|
||||
jaeger-all-in-one:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
ports:
|
||||
- "16686:16686"
|
||||
- "14268"
|
||||
- "14250"
|
||||
# Collector
|
||||
collector-gateway:
|
||||
image: otel/opentelemetry-collector:latest
|
||||
volumes:
|
||||
- ./collector-gateway.yaml:/etc/collector-gateway.yaml
|
||||
command: ["--config=/etc/collector-gateway.yaml"]
|
||||
ports:
|
||||
- "1888:1888" # pprof extension
|
||||
- "13133:13133" # health_check extension
|
||||
- "4317:4317" # OTLP gRPC receiver
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
- "55670:55679" # zpages extension
|
||||
depends_on:
|
||||
- jaeger-all-in-one
|
||||
nginx:
|
||||
image: nginxinc/nginx-unprivileged:latest
|
||||
volumes:
|
||||
- ./nginx_otel.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "16687:8080"
|
||||
16
config/otel_dev/nginx_otel.conf
Normal file
16
config/otel_dev/nginx_otel.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
proxy_pass http://jaeger-all-in-one:16686/;
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Content-Type text/plain;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
package.json
17
package.json
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||
"serve": "vite preview",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"build-storybook": "build-storybook",
|
||||
@@ -18,7 +18,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@juggle/resize-observer": "^3.3.1",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
|
||||
"@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/context-zone": "^1.9.1",
|
||||
"@opentelemetry/exporter-jaeger": "^1.9.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.35.1",
|
||||
"@opentelemetry/instrumentation-document-load": "^0.31.1",
|
||||
"@opentelemetry/instrumentation-user-interaction": "^0.32.1",
|
||||
"@opentelemetry/sdk-trace-web": "^1.9.1",
|
||||
"@react-aria/button": "^3.3.4",
|
||||
"@react-aria/dialog": "^3.1.4",
|
||||
"@react-aria/focus": "^3.5.0",
|
||||
@@ -45,8 +52,9 @@
|
||||
"i18next": "^21.10.0",
|
||||
"i18next-browser-languagedetector": "^6.1.8",
|
||||
"i18next-http-backend": "^1.4.4",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#2c8eece5ca5333c6e6a14e8ed53f359ed0e9e9bf",
|
||||
"matrix-widget-api": "^1.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a7b1dcaf9514b2e424a387e266c6f383a5909927",
|
||||
"matrix-widget-api": "^1.3.1",
|
||||
"mermaid": "^8.13.8",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pako": "^2.0.4",
|
||||
@@ -62,6 +70,7 @@
|
||||
"react-use-clipboard": "^1.0.7",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"tinyqueue": "^2.0.3",
|
||||
"unique-names-generator": "^4.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Join call now": "Влез в разговора сега",
|
||||
"Join existing call?": "Присъединяване към съществуващ разговор?",
|
||||
"Leave": "Напусни",
|
||||
"Loading room…": "Напускане на стаята…",
|
||||
"Loading…": "Зареждане…",
|
||||
"Local volume": "Локална сила на звука",
|
||||
"Logging in…": "Влизане…",
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"Logging in…": "Přihlašování se…",
|
||||
"Local volume": "Lokální hlasitost",
|
||||
"Loading…": "Načítání…",
|
||||
"Loading room…": "Načítání místnosti…",
|
||||
"Leave": "Opustit hovor",
|
||||
"Join call now": "Připojit se k hovoru",
|
||||
"Join call": "Připojit se k hovoru",
|
||||
@@ -91,13 +90,10 @@
|
||||
"Walkie-talkie call name": "Jméno vysílačkového hovoru",
|
||||
"Walkie-talkie call": "Vysílačkový hovor",
|
||||
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Vysílačkový hovor",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Zapnout jedno-klávesové zkratky, např. 'm' pro vypnutí/zapnutí mikrofonu.",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "Toto bude odesílat anonymizovaná data (jako délku a počet účastníků hovoru) týmu Element Call, aby nám pomohly zlepšovat aplikaci podle toho, jak je používaná.",
|
||||
"Talking…": "Mluvení…",
|
||||
"Talk over speaker": "Mluvit přes mluvčího",
|
||||
"Spotlight": "Soustředěný mód",
|
||||
"Single-key keyboard shortcuts": "Jedno-klávesová klávesnice",
|
||||
"Release to stop": "Pusťte pro ukončení",
|
||||
"Release spacebar key to stop": "Pusťte mezerník pro ukončení",
|
||||
"Recaptcha not loaded": "Recaptcha se nenačetla",
|
||||
@@ -131,10 +127,14 @@
|
||||
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Kliknutím na \"Připojit se do hovoru\", odsouhlasíte naše <2>Terms and conditions</2>",
|
||||
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "Kliknutím na \"Pokračovat\", odsouhlasíte naše <2>Terms and conditions</2>",
|
||||
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Jiný uživatel v tomto hovoru má problémy. Abychom mohli diagnostikovat problém, rádi bychom shromáždili protokoly ladění.",
|
||||
"Allow analytics": "Povolit analytiku",
|
||||
"Advanced": "Pokročilé",
|
||||
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Proč neskončit nastavením hesla, abyste mohli účet použít znovu?</0><1>Budete si moci nechat své jméno a nastavit si avatar pro budoucí hovory </1>",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Připojit se</0><1>Or</1><2>Zkopírovat odkaz a připojit se později</2>",
|
||||
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Už máte účet?</0><1><0>Přihlásit se</0> Or <2>Jako host</2></1>",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Čekání na video...)"
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Čekání na video...)",
|
||||
"This feature is only supported on Firefox.": "Tato funkce je podporována jen ve Firefoxu.",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Odeslání ladících záznamů nám pomůže diagnostikovat problém.</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Oops, něco se pokazilo.</0>",
|
||||
"Use the upcoming grid system": "Používat nový systém pro zobrazení videí",
|
||||
"Expose developer settings in the settings window.": "Zobrazit vývojářské nastavení.",
|
||||
"Developer Settings": "Vývojářské nastavení"
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"Join call now": "Anruf beitreten",
|
||||
"Join existing call?": "An bestehendem Anruf teilnehmen?",
|
||||
"Leave": "Verlassen",
|
||||
"Loading room…": "Lade Raum …",
|
||||
"Loading…": "Lade …",
|
||||
"Local volume": "Lokale Lautstärke",
|
||||
"Logging in…": "Anmelden …",
|
||||
@@ -129,13 +128,15 @@
|
||||
"Sending debug logs…": "Sende Debug-Protokolle …",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Anruf beitreten</0><1>Oder</1><2>Anruflink kopieren und später beitreten</2>",
|
||||
"{{name}} (Connecting...)": "{{name}} (verbindet sich …)",
|
||||
"Allow analytics": "Analysedaten senden",
|
||||
"Advanced": "Erweitert",
|
||||
"Copy": "Kopieren",
|
||||
"Element Call Home": "Element Call-Startseite",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "Dies wird anonymisierte Daten (wie z. B. die Dauer eines Anrufs und die Zahl der Teilnehmenden) dem Element Call-Team senden, um uns bei der Optimierung der Anwendung basierend auf dem Nutzungsverhalten zu helfen.",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Ob Tastenkürzel mit nur einer Taste aktiviert sein sollen, z. B. „m“ um das Mikrofon stumm/aktiv zu schalten.",
|
||||
"Single-key keyboard shortcuts": "Ein-Tasten-Tastenkürzel",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Warte auf Video …)",
|
||||
"This feature is only supported on Firefox.": "Diese Funktion wird nur in Firefox unterstützt."
|
||||
"This feature is only supported on Firefox.": "Diese Funktion wird nur in Firefox unterstützt.",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Übermittelte Problemberichte helfen uns, Fehler zu beheben.</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Hoppla, etwas ist schiefgelaufen.</0>",
|
||||
"Use the upcoming grid system": "Nutze das kommende Rastersystem",
|
||||
"Expose developer settings in the settings window.": "Zeige die Entwicklereinstellungen im Einstellungsfenster.",
|
||||
"Developer Settings": "Entwicklereinstellungen",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Mit der Teilnahme an der Beta akzeptierst du die Sammlung von anonymen Daten, die wir zur Verbesserung des Produkts verwenden. Weitere Informationen zu den von uns erhobenen Daten findest du in unserer <2>Datenschutzerklärung</2> und unseren <5>Cookie-Richtlinien</5>.",
|
||||
"<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.": "<0></0><1></1>Du kannst deine Zustimmung durch Abwählen dieses Kästchens zurückziehen. Falls du dich aktuell in einem Anruf befindest, wird diese Einstellung nach dem Ende des Anrufs wirksam."
|
||||
}
|
||||
|
||||
100
public/locales/el/app.json
Normal file
100
public/locales/el/app.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"You can't talk at the same time": "Δεν μπορείς να μιλάς ταυτόχρονα",
|
||||
"Version: {{version}}": "Έκδοση: {{version}}",
|
||||
"User menu": "Μενού χρήστη",
|
||||
"Submit feedback": "Υποβάλετε σχόλια",
|
||||
"Stop sharing screen": "Διακοπή κοινής χρήσης οθόνης",
|
||||
"Sign in": "Σύνδεση",
|
||||
"Show call inspector": "Εμφάνιση του επιθεωρητή κλήσης",
|
||||
"Share screen": "Κοινή χρήση οθόνης",
|
||||
"Sending…": "Αποστολή…",
|
||||
"Select an option": "Επιλέξτε μια επιλογή",
|
||||
"Saving…": "Αποθήκευση…",
|
||||
"Remove": "Αφαίρεση",
|
||||
"Registering…": "Εγγραφή…",
|
||||
"Press and hold to talk": "Πατήστε παρατεταμένα για να μιλήσετε",
|
||||
"Not registered yet? <2>Create an account</2>": "Δεν έχετε εγγραφεί ακόμα; <2>Δημιουργήστε λογαριασμό</2>",
|
||||
"Login to your account": "Συνδεθείτε στο λογαριασμό σας",
|
||||
"Logging in…": "Σύνδεση…",
|
||||
"Invite people": "Προσκαλέστε άτομα",
|
||||
"Invite": "Πρόσκληση",
|
||||
"Inspector": "Επιθεωρητής",
|
||||
"Incompatible versions!": "Μη συμβατές εκδόσεις!",
|
||||
"Incompatible versions": "Μη συμβατές εκδόσεις",
|
||||
"Display name": "Εμφανιζόμενο όνομα",
|
||||
"Developer Settings": "Ρυθμίσεις προγραμματιστή",
|
||||
"Debug log request": "Αίτημα αρχείου καταγραφής",
|
||||
"Call link copied": "Ο σύνδεσμος κλήσης αντιγράφηκε",
|
||||
"Avatar": "Avatar",
|
||||
"Accept microphone permissions to join the call.": "Αποδεχτείτε τα δικαιώματα μικροφώνου για να συμμετάσχετε στην κλήση.",
|
||||
"Accept camera/microphone permissions to join the call.": "Αποδεχτείτε τα δικαιώματα κάμερας/μικροφώνου για να συμμετάσχετε στην κλήση.",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Ωχ, κάτι πήγε στραβά.</0>",
|
||||
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Δημιουργήστε λογαριασμό</0> Ή <2>Συμμετέχετε ως επισκέπτης</2>",
|
||||
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Έχετε ήδη λογαριασμό;</0><1><0>Συνδεθείτε</0> Ή <2>Συμμετέχετε ως επισκέπτης</2></1>",
|
||||
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Κλήση walkie-talkie",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"Your recent calls": "Οι πρόσφατες κλήσεις σας",
|
||||
"Yes, join call": "Ναι, συμμετοχή στην κλήση",
|
||||
"WebRTC is not supported or is being blocked in this browser.": "Το WebRTC δεν υποστηρίζεται ή έχει αποκλειστεί σε αυτό το πρόγραμμα περιήγησης.",
|
||||
"Walkie-talkie call name": "Όνομα κλήσης walkie-talkie",
|
||||
"Walkie-talkie call": "Κλήση walkie-talkie",
|
||||
"Waiting for other participants…": "Αναμονή για άλλους συμμετέχοντες…",
|
||||
"Waiting for network": "Αναμονή για δίκτυο",
|
||||
"Video call name": "Όνομα βίντεο κλήσης",
|
||||
"Video call": "Βίντεο κλήση",
|
||||
"Video": "Βίντεο",
|
||||
"Username": "Όνομα χρήστη",
|
||||
"Turn on camera": "Ενεργοποιήστε την κάμερα",
|
||||
"Turn off camera": "Απενεργοποιήστε την κάμερα",
|
||||
"This feature is only supported on Firefox.": "Αυτή η δυνατότητα υποστηρίζεται μόνο στον Firefox.",
|
||||
"This call already exists, would you like to join?": "Αυτή η κλήση υπάρχει ήδη, θα θέλατε να συμμετάσχετε;",
|
||||
"Speaker": "Ηχείο",
|
||||
"Spatial audio": "Χωρικός ήχος",
|
||||
"Sign out": "Αποσύνδεση",
|
||||
"Settings": "Ρυθμίσεις",
|
||||
"Save": "Αποθήκευση",
|
||||
"Return to home screen": "Επιστροφή στην αρχική οθόνη",
|
||||
"Register": "Εγγραφή",
|
||||
"Profile": "Προφίλ",
|
||||
"Press and hold spacebar to talk": "Για να μιλήσετε κρατήστε πατημένο το πλήκτρο διαστήματος",
|
||||
"Passwords must match": "Οι κωδικοί πρέπει να ταιριάζουν",
|
||||
"Password": "Κωδικός",
|
||||
"Not now, return to home screen": "Όχι τώρα, επιστροφή στην αρχική οθόνη",
|
||||
"No": "Όχι",
|
||||
"Mute microphone": "Σίγαση μικροφώνου",
|
||||
"More menu": "Μενού περισσότερα",
|
||||
"More": "Περισσότερα",
|
||||
"Microphone permissions needed to join the call.": "Απαιτούνται δικαιώματα μικροφώνου για συμμετοχή στην κλήση.",
|
||||
"Microphone {{n}}": "Μικρόφωνο {{n}}",
|
||||
"Microphone": "Μικρόφωνο",
|
||||
"Login": "Σύνδεση",
|
||||
"Loading…": "Φόρτωση…",
|
||||
"Leave": "Αποχώρηση",
|
||||
"Join existing call?": "Συμμετοχή στην υπάρχουσα κλήση;",
|
||||
"Join call now": "Συμμετοχή στην κλήση τώρα",
|
||||
"Join call": "Συμμετοχή στην κλήση",
|
||||
"Go": "Μετάβαση",
|
||||
"Full screen": "Πλήρη οθόνη",
|
||||
"Exit full screen": "Έξοδος από πλήρη οθόνη",
|
||||
"Details": "Λεπτομέρειες",
|
||||
"Description (optional)": "Περιγραφή (προαιρετική)",
|
||||
"Create account": "Δημιουργία λογαριασμού",
|
||||
"Copy and share this call link": "Αντιγράψτε και μοιραστείτε αυτόν τον σύνδεσμο κλήσης",
|
||||
"Copy": "Αντιγραφή",
|
||||
"Copied!": "Αντιγράφηκε!",
|
||||
"Connection lost": "Η σύνδεση χάθηκε",
|
||||
"Confirm password": "Επιβεβαίωση κωδικού",
|
||||
"Close": "Κλείσιμο",
|
||||
"Change layout": "Αλλαγή διάταξης",
|
||||
"Camera/microphone permissions needed to join the call.": "Απαιτούνται δικαιώματα κάμερας/μικροφώνου για να συμμετάσχετε στην κλήση.",
|
||||
"Camera {{n}}": "Κάμερα {{n}}",
|
||||
"Camera": "Κάμερα",
|
||||
"Audio": "Ήχος",
|
||||
"{{name}} is talking…": "{{name}} ομιλεί…",
|
||||
"{{name}} is presenting": "{{name}} παρουσιάζει",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Αναμονή για βίντεο...)",
|
||||
"{{name}} (Connecting...)": "{{name}} (Συνδέεται...)",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, η κλήση σας τερματίστηκε",
|
||||
"{{count}} people connected|other": "{{count}} άτομα συνδεδεμένα",
|
||||
"{{count}} people connected|one": "{{count}} άτομο συνδεδεμένο"
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"{{name}} is talking…": "{{name}} is talking…",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Walkie-talkie call",
|
||||
"<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.": "<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.",
|
||||
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>",
|
||||
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Create an account</0> Or <2>Access as a guest</2>",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>",
|
||||
@@ -16,13 +17,12 @@
|
||||
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",
|
||||
"Accept camera/microphone permissions to join the call.": "Accept camera/microphone permissions to join the call.",
|
||||
"Accept microphone permissions to join the call.": "Accept microphone permissions to join the call.",
|
||||
"Advanced": "Advanced",
|
||||
"Allow analytics": "Allow analytics",
|
||||
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.",
|
||||
"Audio": "Audio",
|
||||
"Avatar": "Avatar",
|
||||
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "By clicking \"Go\", you agree to our <2>Terms and conditions</2>",
|
||||
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.",
|
||||
"Call link copied": "Call link copied",
|
||||
"Call type menu": "Call type menu",
|
||||
"Camera": "Camera",
|
||||
@@ -41,10 +41,12 @@
|
||||
"Description (optional)": "Description (optional)",
|
||||
"Details": "Details",
|
||||
"Developer": "Developer",
|
||||
"Developer Settings": "Developer Settings",
|
||||
"Display name": "Display name",
|
||||
"Download debug logs": "Download debug logs",
|
||||
"Element Call Home": "Element Call Home",
|
||||
"Exit full screen": "Exit full screen",
|
||||
"Expose developer settings in the settings window.": "Expose developer settings in the settings window.",
|
||||
"Fetching group call timed out.": "Fetching group call timed out.",
|
||||
"Freedom": "Freedom",
|
||||
"Full screen": "Full screen",
|
||||
@@ -62,7 +64,6 @@
|
||||
"Join call now": "Join call now",
|
||||
"Join existing call?": "Join existing call?",
|
||||
"Leave": "Leave",
|
||||
"Loading room…": "Loading room…",
|
||||
"Loading…": "Loading…",
|
||||
"Local volume": "Local volume",
|
||||
"Logging in…": "Logging in…",
|
||||
@@ -104,7 +105,6 @@
|
||||
"Show call inspector": "Show call inspector",
|
||||
"Sign in": "Sign in",
|
||||
"Sign out": "Sign out",
|
||||
"Single-key keyboard shortcuts": "Single-key keyboard shortcuts",
|
||||
"Spatial audio": "Spatial audio",
|
||||
"Speaker": "Speaker",
|
||||
"Speaker {{n}}": "Speaker {{n}}",
|
||||
@@ -120,10 +120,10 @@
|
||||
"This feature is only supported on Firefox.": "This feature is only supported on Firefox.",
|
||||
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>",
|
||||
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.",
|
||||
"Turn off camera": "Turn off camera",
|
||||
"Turn on camera": "Turn on camera",
|
||||
"Unmute microphone": "Unmute microphone",
|
||||
"Use the upcoming grid system": "Use the upcoming grid system",
|
||||
"User ID": "User ID",
|
||||
"User menu": "User menu",
|
||||
"Username": "Username",
|
||||
@@ -136,7 +136,6 @@
|
||||
"Walkie-talkie call": "Walkie-talkie call",
|
||||
"Walkie-talkie call name": "Walkie-talkie call name",
|
||||
"WebRTC is not supported or is being blocked in this browser.": "WebRTC is not supported or is being blocked in this browser.",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.",
|
||||
"Yes, join call": "Yes, join call",
|
||||
"You can't talk at the same time": "You can't talk at the same time",
|
||||
"Your recent calls": "Your recent calls"
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
"Logging in…": "Iniciando sesión…",
|
||||
"Local volume": "Volumen local",
|
||||
"Loading…": "Cargando…",
|
||||
"Loading room…": "Cargando sala…",
|
||||
"Leave": "Abandonar",
|
||||
"Join existing call?": "¿Unirse a llamada existente?",
|
||||
"Join call now": "Unirse a la llamada ahora",
|
||||
@@ -129,12 +128,12 @@
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, tu llamada ha finalizado",
|
||||
"{{count}} people connected|other": "{{count}} personas conectadas",
|
||||
"{{count}} people connected|one": "{{count}} persona conectada",
|
||||
"Allow analytics": "Permitir analíticas",
|
||||
"Advanced": "Avanzado",
|
||||
"Element Call Home": "Inicio de Element Call",
|
||||
"Copy": "Copiar",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "Esto enviará datos anónimos (como la duración de la llamada y el número de participantes) al equipo de Element Call para ayudarnos a optimizar la aplicación dependiendo de cómo se use.",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Habilita los atajos de teclado de una sola tecla, por ejemplo 'm' para silenciar/desilenciar el micrófono.",
|
||||
"Single-key keyboard shortcuts": "Atajos de teclado de una sola tecla",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Esperando al video...)"
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Esperando al video...)",
|
||||
"This feature is only supported on Firefox.": "Esta característica solo está disponible en Firefox.",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Subir los registros de depuración nos ayudará a encontrar el problema.</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Ups, algo ha salido mal.</0>",
|
||||
"Expose developer settings in the settings window.": "Muestra los ajustes de desarrollador en la ventana de ajustes.",
|
||||
"Developer Settings": "Ajustes de desarrollador"
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"Logging in…": "Sisselogimine …",
|
||||
"Local volume": "Kohalik helitugevus",
|
||||
"Loading…": "Laadimine …",
|
||||
"Loading room…": "Ruumi laadimine …",
|
||||
"Leave": "Lahku",
|
||||
"Join existing call?": "Liitu juba käimasoleva kõnega?",
|
||||
"Join call now": "Kõnega liitumine",
|
||||
@@ -129,13 +128,15 @@
|
||||
"WebRTC is not supported or is being blocked in this browser.": "WebRTC pole kas selles brauseris toetatud või on keelatud.",
|
||||
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "Muudab kõneleja heli nii, nagu tuleks see sealt, kus on tema pilt ekraanil. (See on katseline funktsionaalsus ja võib mõjutada heli stabiilsust.)",
|
||||
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "Siin saidis on kasutusel ReCAPTCHA ning kehtivad Google <2>privaatsuspoliitika</2> ja <6>teenusetingimused</6>.<9></9>Klikkides „Registreeru“, nõustud meie <12>kasutustingimustega</12>",
|
||||
"Allow analytics": "Luba analüütika",
|
||||
"Advanced": "Lisaseadistused",
|
||||
"Element Call Home": "Element Call Home",
|
||||
"Copy": "Kopeeri",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "Me saadame kõne anonüümsed andmed (nagu kõne kestus ja osalejate arv) meie arendustiimile ja see võimaldab levinud kasutusmustrite alusel arendust optimeerida.",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Kas kasutame üheklahvilisi kiirklahve, näiteks „m“ mikrofoni sisse/välja lülitamiseks.",
|
||||
"Single-key keyboard shortcuts": "Üheklahvilised kiirklahvid",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Ootame videovoo algust...)",
|
||||
"This feature is only supported on Firefox.": "See funktsionaalsus on toetatud vaid Firefoxis."
|
||||
"This feature is only supported on Firefox.": "See funktsionaalsus on toetatud vaid Firefoxis.",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Kui saadad meile vealogid, siis on lihtsam vea põhjust otsida.</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Ohoo, midagi on nüüd katki.</0>",
|
||||
"Use the upcoming grid system": "Kasuta tulevast ruudustiku-põhist paigutust",
|
||||
"Expose developer settings in the settings window.": "Näita seadistuste aknas arendajale vajalikke seadeid.",
|
||||
"Developer Settings": "Arendaja seadistused",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Nõustudes selle beetaversiooni kasutamisega sa nõustud ka toote arendamiseks kasutatavate anonüümsete andmete kogumisega. Täpsemat teavet kogutavate andmete kohta leiad meie <2>Privaatsuspoliitikast</2> ja meie <5>Küpsiste kasutamise reeglitest</5>.",
|
||||
"<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.": "<0></0><1></1>Sa võid selle valiku eelmaldamisega alati oma nõusoleku tagasi võtta. Kui sul parasjagu on kõne pooleli, siis seadistuste muudatus jõustub pärast kõne lõppu."
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"Login to your account": "به حساب کاربری خود وارد شوید",
|
||||
"Login": "ورود",
|
||||
"Loading…": "بارگزاری…",
|
||||
"Loading room…": "بارگزاری اتاق…",
|
||||
"Leave": "خروج",
|
||||
"Join existing call?": "پیوست به تماس؟",
|
||||
"Join call now": "الان به تماس بپیوند",
|
||||
@@ -127,13 +126,8 @@
|
||||
"Submit feedback": "بازخورد ارائه دهید",
|
||||
"Stop sharing screen": "توقف اشتراکگذاری صفحه نمایش",
|
||||
"Spatial audio": "صدای جهتدار",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "این که میانبرهای صفحهکلید تککلیده مثل m برای خموشی و ناخموشی میکروفون به کار بیفتند یا نه.",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "دادههای ناشناس شده (از اطَلاعاتی مثل طول تماس و شمارهٔ طرفها) را به گروه تماس المنت فرستاده تا در بهینهسازی برنامه بر پایهٔ چگونگی استفادهاش یاریمان کنند.",
|
||||
"Single-key keyboard shortcuts": "میانبرهای صفحهکلید تککلیده",
|
||||
"Element Call Home": "خانهٔ تماس المنت",
|
||||
"Copy": "رونوشت",
|
||||
"Allow analytics": "نمایش تجزیهها",
|
||||
"Advanced": "پیش رفته",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>اکنون به تماس پیوسته</0><1>یا</1><2>پیوند تماس را رونوشت کرده و بعداً بپیوندید</2>",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (منتظر تصویر…)",
|
||||
"{{name}} (Connecting...)": "{{name}} (وصل شدن…)"
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"Join call now": "Rejoindre l’appel maintenant",
|
||||
"Join existing call?": "Rejoindre un appel existant ?",
|
||||
"Leave": "Partir",
|
||||
"Loading room…": "Chargement du salon…",
|
||||
"Loading…": "Chargement…",
|
||||
"Local volume": "Volume local",
|
||||
"Logging in…": "Connexion…",
|
||||
@@ -129,13 +128,15 @@
|
||||
"Sending debug logs…": "Envoi des journaux de débogage…",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Rejoindre l’appel maintenant</0><1>Ou</1><2>Copier le lien de l’appel et rejoindre plus tard</2>",
|
||||
"{{name}} (Connecting...)": "{{name}} (Connexion…)",
|
||||
"Allow analytics": "Autoriser les statistiques",
|
||||
"Advanced": "Avancé",
|
||||
"Element Call Home": "Accueil Element Call",
|
||||
"Copy": "Copier",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "Cela enverra des données anonymisées (telles que la durée d’un appel et le nombre de participants) à l’équipe de Element Call pour aider à optimiser l’application en fonction de l’utilisation.",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Bascule sur les raccourcis clavier à touche unique, par exemple « m » pour désactiver / activer le micro.",
|
||||
"Single-key keyboard shortcuts": "Raccourcis clavier en une touche",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (En attente de vidéo…)",
|
||||
"This feature is only supported on Firefox.": "Cette fonctionnalité est prise en charge dans Firefox uniquement."
|
||||
"This feature is only supported on Firefox.": "Cette fonctionnalité est prise en charge dans Firefox uniquement.",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Soumettre les journaux de débogage nous aidera à déterminer le problème.</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Oups, quelque chose s’est mal passé.</0>",
|
||||
"Use the upcoming grid system": "Utiliser le futur système de grille",
|
||||
"Expose developer settings in the settings window.": "Affiche les paramètres développeurs dans la fenêtre des paramètres.",
|
||||
"Developer Settings": "Paramètres développeurs",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "En participant à cette beta, vous consentez à la collecte de données anonymes, qui seront utilisées pour améliorer le produit. Vous trouverez plus d’informations sur les données collectées dans notre <2>Politique de vie privée</2> et notre <5>Politique de cookies</5>.",
|
||||
"<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.": "<0></0><1></1>Vous pouvez retirer votre consentement en décochant cette case. Si vous êtes actuellement en communication, ce paramètre prendra effet à la fin de l’appel."
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"Join call now": "Bergabung ke panggilan sekarang",
|
||||
"Join existing call?": "Bergabung ke panggilan yang sudah ada?",
|
||||
"Leave": "Keluar",
|
||||
"Loading room…": "Memuat ruangan…",
|
||||
"Loading…": "Memuat…",
|
||||
"Local volume": "Volume lokal",
|
||||
"Logging in…": "Memasuki…",
|
||||
@@ -129,13 +128,15 @@
|
||||
"Sending debug logs…": "Mengirimkan catatan pengawakutuan…",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Bergabung panggilan sekarang</0><1>Atau</1><2>Salin tautan dan bergabung nanti</2>",
|
||||
"{{name}} (Connecting...)": "{{name}} (Menghubungkan...)",
|
||||
"Allow analytics": "Perbolehkan analitik",
|
||||
"Advanced": "Tingkat lanjut",
|
||||
"Element Call Home": "Beranda Element Call",
|
||||
"Copy": "Salin",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "Ini akan mengirimkan data anonim (seperti durasi dan jumlah peserta panggilan) ke tim Element Call untuk membantu kami mengoptimalkan aplikasi berdasarkan bagaimana penggunaannya.",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Apakah pintasan papan ketik seharusnya diaktifkan, mis. 'm' untuk membisukan/menyuarakan mikrofon.",
|
||||
"Single-key keyboard shortcuts": "Pintasan papan ketik satu tombol",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Menunggu video...)",
|
||||
"This feature is only supported on Firefox.": "Fitur ini hanya didukung di Firefox."
|
||||
"This feature is only supported on Firefox.": "Fitur ini hanya didukung di Firefox.",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Mengirim catatan pengawakutuan akan membantu kami melacak masalahnya.</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Aduh, ada yang salah.</0>",
|
||||
"Use the upcoming grid system": "Gunakan sistem kisi yang akan segera datang",
|
||||
"Expose developer settings in the settings window.": "Ekspos pengaturan pengembang dalam jendela pengaturan.",
|
||||
"Developer Settings": "Pengaturan Pengembang",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Dengan bergabung dalam beta ini, Anda mengizinkan kami untuk mengumpulkan data anonim, yang kami gunakan untuk meningkatkan produk ini. Anda dapat mempelajari lebih lanjut tentang data apa yang kami lacak dalam <2>Kebijakan Privasi</2> dan <5>Kebijakan Kuki</5> kami.",
|
||||
"<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.": "<0></0><1></1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan."
|
||||
}
|
||||
|
||||
@@ -1 +1,113 @@
|
||||
{}
|
||||
{
|
||||
"{{name}} (Connecting...)": "{{name}}(接続しています…)",
|
||||
"{{count}} people connected|other": "{{count}}人が接続済",
|
||||
"{{count}} people connected|one": "{{count}}人が接続済",
|
||||
"{{name}} (Waiting for video...)": "{{name}}(ビデオを待機しています…)",
|
||||
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>既にアカウントをお持ちですか?</0><1><0>ログイン</0>または<2>ゲストとしてアクセス</2></1>",
|
||||
"{{roomName}} - Walkie-talkie call": "{{roomName}} - トランシーバー通話",
|
||||
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>アカウントを作成</0>または<2>ゲストとしてアクセス</2>",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>今すぐ通話に参加</0><1>または</1><2>通話リンクをコピーし、後で参加</2>",
|
||||
"Accept camera/microphone permissions to join the call.": "通話に参加するには、カメラ・マイクの許可が必要です。",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>何かがうまく行きませんでした。</0>",
|
||||
"Camera/microphone permissions needed to join the call.": "通話に参加する場合、カメラ・マイクの許可が必要です。",
|
||||
"Camera": "カメラ",
|
||||
"Call link copied": "通話リンクをコピーしました",
|
||||
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "「今すぐ通話に参加」をクリックすると、<2>利用規約</2>に同意したとみなされます",
|
||||
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "「続行」をクリックすると、 <2>利用規約</2>に同意したとみなされます",
|
||||
"Avatar": "アバター",
|
||||
"Accept microphone permissions to join the call.": "通話に参加するには、マイクの許可が必要です。",
|
||||
"Audio": "音声",
|
||||
"Connection lost": "接続が切断されました",
|
||||
"Confirm password": "パスワードを確認",
|
||||
"Close": "閉じる",
|
||||
"Change layout": "レイアウトを変更",
|
||||
"Copied!": "コピーしました!",
|
||||
"Copy and share this call link": "通話リンクをコピーし共有",
|
||||
"Copy": "コピー",
|
||||
"Description (optional)": "概要(任意)",
|
||||
"Debug log": "デバッグログ",
|
||||
"Create account": "アカウントを作成",
|
||||
"Having trouble? Help us fix it.": "問題が起きましたか?修正にご協力ください。",
|
||||
"Go": "続行",
|
||||
"Fetching group call timed out.": "グループ通話の取得がタイムアウトしました。",
|
||||
"Element Call Home": "Element Call ホーム",
|
||||
"Download debug logs": "デバッグログをダウンロード",
|
||||
"Display name": "表示名",
|
||||
"Developer": "開発者",
|
||||
"Details": "詳細",
|
||||
"Full screen": "全画面表示",
|
||||
"Exit full screen": "全画面表示を終了",
|
||||
"Include debug logs": "デバッグログを含める",
|
||||
"Home": "ホーム",
|
||||
"Incompatible versions!": "互換性のないバージョンです!",
|
||||
"Incompatible versions": "互換性のないバージョン",
|
||||
"Join existing call?": "既存の通話に参加しますか?",
|
||||
"Join call now": "今すぐ通話に参加",
|
||||
"Join call": "通話に参加",
|
||||
"Invite": "招待",
|
||||
"Invite people": "連絡先を招待",
|
||||
"Not registered yet? <2>Create an account</2>": "アカウントがありませんか? <2>アカウントを作成</2>",
|
||||
"Mute microphone": "マイクをミュート",
|
||||
"Microphone permissions needed to join the call.": "通話の参加にはマイクの許可が必要です。",
|
||||
"Microphone": "マイク",
|
||||
"Login": "ログイン",
|
||||
"Logging in…": "ログインしています…",
|
||||
"Loading…": "読み込んでいます…",
|
||||
"Leave": "退出",
|
||||
"Version: {{version}}": "バージョン:{{version}}",
|
||||
"Username": "ユーザー名",
|
||||
"User menu": "ユーザーメニュー",
|
||||
"User ID": "ユーザーID",
|
||||
"Unmute microphone": "マイクのミュートを解除",
|
||||
"Turn on camera": "カメラをつける",
|
||||
"Turn off camera": "カメラを切る",
|
||||
"Submitting feedback…": "フィードバックを送信しています…",
|
||||
"Submit feedback": "フィードバックを送信",
|
||||
"Stop sharing screen": "画面共有を停止",
|
||||
"Spotlight": "スポットライト",
|
||||
"Send debug logs": "デバッグログを送信",
|
||||
"Sign out": "サインアウト",
|
||||
"Sign in": "サインイン",
|
||||
"Share screen": "画面共有",
|
||||
"Settings": "設定",
|
||||
"Sending…": "送信しています…",
|
||||
"Sending debug logs…": "デバッグログを送信しています…",
|
||||
"Saving…": "保存しています…",
|
||||
"Save": "保存",
|
||||
"Return to home screen": "ホーム画面に戻る",
|
||||
"Registering…": "登録しています…",
|
||||
"Register": "登録",
|
||||
"Profile": "プロフィール",
|
||||
"Press and hold spacebar to talk": "スペースを長押しで会話",
|
||||
"Passwords must match": "パスワードが一致する必要があります",
|
||||
"Password": "パスワード",
|
||||
"Speaker": "スピーカー",
|
||||
"Video call name": "ビデオ通話の名称",
|
||||
"Video call": "ビデオ通話",
|
||||
"Video": "ビデオ",
|
||||
"Waiting for other participants…": "他の参加者を待機しています…",
|
||||
"Waiting for network": "ネットワークを待機しています",
|
||||
"Walkie-talkie call name": "トランシーバー通話の名称",
|
||||
"Walkie-talkie call": "トランシーバー通話",
|
||||
"Camera {{n}}": "カメラ {{n}}",
|
||||
"{{name}} is talking…": "{{name}}が話しています…",
|
||||
"Yes, join call": "はい、通話に参加",
|
||||
"Spatial audio": "空間オーディオ",
|
||||
"Select an option": "オプションを選択",
|
||||
"Debug log request": "デバッグログを要求",
|
||||
"Your recent calls": "最近の通話",
|
||||
"You can't talk at the same time": "同時に会話することはできません",
|
||||
"WebRTC is not supported or is being blocked in this browser.": "お使いのブラウザでWebRTCがサポートされていないか、またはブロックされています。",
|
||||
"Login to your account": "アカウントにログイン",
|
||||
"Freedom": "自由",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}、通話が終了しました",
|
||||
"Talking…": "話しています…",
|
||||
"Remove": "削除",
|
||||
"No": "いいえ",
|
||||
"This feature is only supported on Firefox.": "この機能はFirefoxでのみサポートされています。",
|
||||
"This call already exists, would you like to join?": "この通話は既に存在します。参加しますか?",
|
||||
"Take me Home": "ホームに戻る",
|
||||
"Press and hold to talk": "押し続けて会話",
|
||||
"{{name}} is presenting": "{{name}}が画面を共有しています",
|
||||
"{{names}}, {{name}}": "{{names}}、{{name}}"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"More menu": "Menu \"więcej\"",
|
||||
"Login": "Zaloguj się",
|
||||
"Go": "Kontynuuj",
|
||||
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "Klikając \"Kontynuuj\", wyrażasz zgodę na nasze <2>Warunki</2>",
|
||||
"{{count}} people connected|other": "{{count}} ludzi połączono",
|
||||
"Go": "Przejdź",
|
||||
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "Klikając \"Kontynuuj\", wyrażasz zgodę na nasze <2>Zasady i warunki</2>",
|
||||
"{{count}} people connected|other": "{{count}} osób połączonych",
|
||||
"Your recent calls": "Twoje ostatnie połączenia",
|
||||
"You can't talk at the same time": "Nie możesz mówić w tym samym czasie",
|
||||
"Yes, join call": "Tak, dołącz do połączenia",
|
||||
@@ -26,7 +26,7 @@
|
||||
"This call already exists, would you like to join?": "Te połączenie już istnieje, czy chcesz do niego dołączyć?",
|
||||
"Thanks! We'll get right on it.": "Dziękujemy! Zaraz się tym zajmiemy.",
|
||||
"Talking…": "Mówienie…",
|
||||
"Take me Home": "Zabierz mnie do ekranu startowego",
|
||||
"Take me Home": "Zabierz mnie do strony głównej",
|
||||
"Submitting feedback…": "Przesyłanie opinii…",
|
||||
"Submit feedback": "Prześlij opinię",
|
||||
"Stop sharing screen": "Zatrzymaj udostępnianie ekranu",
|
||||
@@ -45,10 +45,10 @@
|
||||
"Select an option": "Wybierz opcję",
|
||||
"Saving…": "Zapisywanie…",
|
||||
"Save": "Zapisz",
|
||||
"Return to home screen": "Powróć do ekranu domowego",
|
||||
"Return to home screen": "Powróć do strony głównej",
|
||||
"Remove": "Usuń",
|
||||
"Release to stop": "Puść przycisk, aby przestać",
|
||||
"Release spacebar key to stop": "Puść spację, aby przestać",
|
||||
"Release to stop": "Puść przycisk, aby zatrzymać",
|
||||
"Release spacebar key to stop": "Puść spację, aby zatrzymać",
|
||||
"Registering…": "Rejestrowanie…",
|
||||
"Register": "Zarejestruj",
|
||||
"Recaptcha not loaded": "Recaptcha nie została załadowana",
|
||||
@@ -58,7 +58,7 @@
|
||||
"Press and hold to talk": "Przytrzymaj, aby mówić",
|
||||
"Press and hold spacebar to talk over {{name}}": "Przytrzymaj spację, aby mówić wraz z {{name}}",
|
||||
"Press and hold spacebar to talk": "Przytrzymaj spację, aby mówić",
|
||||
"Passwords must match": "Hasła muszą być identyczne",
|
||||
"Passwords must match": "Hasła muszą pasować",
|
||||
"Password": "Hasło",
|
||||
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Inni użytkownicy próbują dołączyć do tego połączenia przy użyciu niekompatybilnych wersji. Powinni oni upewnić się, że odświeżyli stronę w swoich przeglądarkach:<1>{userLis}</1>",
|
||||
"Not registered yet? <2>Create an account</2>": "Nie masz konta? <2>Utwórz je</2>",
|
||||
@@ -71,9 +71,8 @@
|
||||
"Microphone": "Mikrofon",
|
||||
"Login to your account": "Zaloguj się do swojego konta",
|
||||
"Logging in…": "Logowanie…",
|
||||
"Local volume": "Lokalna głośność",
|
||||
"Local volume": "Głośność lokalna",
|
||||
"Loading…": "Ładowanie…",
|
||||
"Loading room…": "Ładowanie pokoju…",
|
||||
"Leave": "Opuść",
|
||||
"Join existing call?": "Dołączyć do istniejącego połączenia?",
|
||||
"Join call now": "Dołącz do połączenia teraz",
|
||||
@@ -87,42 +86,57 @@
|
||||
"Home": "Strona domowa",
|
||||
"Having trouble? Help us fix it.": "Masz problem? Pomóż nam go naprawić.",
|
||||
"Grid layout menu": "Menu układu siatki",
|
||||
"Full screen": "Pełen ekran",
|
||||
"Full screen": "Pełny ekran",
|
||||
"Freedom": "Wolność",
|
||||
"Fetching group call timed out.": "Przekroczono limit czasu na uzyskanie połączenia grupowego.",
|
||||
"Exit full screen": "Zamknij pełny ekran",
|
||||
"Exit full screen": "Opuść pełny ekran",
|
||||
"Download debug logs": "Pobierz dzienniki debugowania",
|
||||
"Display name": "Wyświetlana nazwa",
|
||||
"Developer": "Deweloper",
|
||||
"Display name": "Nazwa wyświetlana",
|
||||
"Developer": "Programista",
|
||||
"Details": "Szczegóły",
|
||||
"Description (optional)": "Opis (opcjonalny)",
|
||||
"Description (optional)": "Opis (opcjonalne)",
|
||||
"Debug log request": "Prośba o dzienniki debugowania",
|
||||
"Debug log": "Dzienniki debugowania",
|
||||
"Create account": "Utwórz konto",
|
||||
"Copy and share this call link": "Skopiuj i podziel się linkiem do połączenia",
|
||||
"Copy and share this call link": "Skopiuj i udostępnij link do rozmowy",
|
||||
"Copied!": "Skopiowano!",
|
||||
"Connection lost": "Połączenie utracone",
|
||||
"Confirm password": "Potwierdź hasło",
|
||||
"Close": "Zamknij",
|
||||
"Change layout": "Zmień układ",
|
||||
"Camera/microphone permissions needed to join the call.": "Aby dołączyć do tego połączenia, potrzebne są uprawnienia do kamery/mikrofonu.",
|
||||
"Camera/microphone permissions needed to join the call.": "Wymagane są uprawnienia do kamery/mikrofonu, aby dołączyć do rozmowy.",
|
||||
"Camera {{n}}": "Kamera {{n}}",
|
||||
"Camera": "Kamera",
|
||||
"Call type menu": "Menu rodzaju połączenia",
|
||||
"Call type menu": "Menu typu połączenia",
|
||||
"Call link copied": "Skopiowano link do połączenia",
|
||||
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Klikając \"Dołącz do rozmowy\", wyrażasz zgodę na nasze <2>Warunki</2>",
|
||||
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Klikając \"Dołącz do rozmowy\", wyrażasz zgodę na nasze <2>Zasady i warunki</2>",
|
||||
"Avatar": "Awatar",
|
||||
"Audio": "Dźwięk",
|
||||
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Inny użytkownik w tym połączeniu napotkał problem. Aby lepiej zdiagnozować tę usterkę, chcielibyśmy zebrać dzienniki debugowania.",
|
||||
"Accept microphone permissions to join the call.": "Przyznaj uprawnienia do mikrofonu aby dołączyć do połączenia.",
|
||||
"Accept camera/microphone permissions to join the call.": "Przyznaj uprawnienia do kamery/mikrofonu aby dołączyć do połączenia.",
|
||||
"Accept microphone permissions to join the call.": "Akceptuj uprawnienia mikrofonu, aby dołączyć do połączenia.",
|
||||
"Accept camera/microphone permissions to join the call.": "Akceptuj uprawnienia kamery/mikrofonu, aby dołączyć do połączenia.",
|
||||
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Może zechcesz ustawić hasło, aby zachować swoje konto?</0><1>Będziesz w stanie utrzymać swoją nazwę i ustawić awatar do wyświetlania podczas połączeń w przyszłości</1>",
|
||||
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Utwórz konto</0> Albo <2>Dołącz jako gość</2>",
|
||||
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Masz już konto?</0><1><0>Zaloguj się</0> Albo <2>Dołącz jako gość</2></1>",
|
||||
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Utwórz konto</0> lub <2>Dołącz jako gość</2>",
|
||||
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Masz już konto?</0><1><0>Zaloguj się</0> lub <2>Dołącz jako gość</2></1>",
|
||||
"{{roomName}} - Walkie-talkie call": "{{roomName}} - połączenie walkie-talkie",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{name}} is talking…": "{{name}} mówi…",
|
||||
"{{name}} is presenting": "{{name}} prezentuje",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, twoje połączenie zostało zakończone",
|
||||
"{{count}} people connected|one": "{{count}} osoba połączona"
|
||||
"{{count}} people connected|one": "{{count}} osoba połączona",
|
||||
"This feature is only supported on Firefox.": "Ta funkcjonalność jest dostępna tylko w Firefox.",
|
||||
"Copy": "Kopiuj",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Wysłanie dzienników debuggowania pomoże nam ustalić przyczynę problemu.</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Ojej, coś poszło nie tak.</0>",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Dołącz do rozmowy już teraz</0><1>lub</1><2>Skopiuj link do rozmowy i dołącz później</2>",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Oczekiwanie na wideo...)",
|
||||
"{{name}} (Connecting...)": "{{name}} (Łączenie...)",
|
||||
"Expose developer settings in the settings window.": "Wyświetl opcje programisty w oknie ustawień.",
|
||||
"Element Call Home": "Strona główna Element Call",
|
||||
"Developer Settings": "Opcje programisty",
|
||||
"Talk over speaker": "Rozmowa przez głośnik",
|
||||
"Use the upcoming grid system": "Użyj nadchodzącego systemu siatek",
|
||||
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "Ta strona jest chroniona przez ReCAPTCHA, więc obowiązują na niej <2>Polityka prywatności</2> i <6>Warunki świadczenia usług</6> Google.<9></9>Klikając \"Zarejestruj się\", zgadzasz się na nasze <12>Warunki świadczenia usług</12>",
|
||||
"<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.": "<0></0><1></1>Możesz wycofać swoją zgodę poprzez odznaczenie tego pola. Jeśli już jesteś w trakcie rozmowy, opcja zostanie zastosowana po jej zakończeniu.",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Uczestnicząc w tej becie, upoważniasz nas do zbierania anonimowych danych, które wykorzystamy do ulepszenia produktu. Dowiedz się więcej na temat danych, które zbieramy w naszej <2>Polityce prywatności</2> i <5>Polityce ciasteczek</5>."
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@
|
||||
"Login to your account": "Войдите в свой аккаунт",
|
||||
"Login": "Вход",
|
||||
"Loading…": "Загрузка…",
|
||||
"Loading room…": "Загрузка комнаты…",
|
||||
"Leave": "Покинуть",
|
||||
"Join existing call?": "Присоединиться к существующему звонку?",
|
||||
"Join call now": "Присоединиться сейчас",
|
||||
@@ -127,11 +126,17 @@
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, ваш звонок завершён",
|
||||
"{{count}} people connected|other": "{{count}} подключилось",
|
||||
"{{count}} people connected|one": "{{count}} подключился",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "Это даст разрешение на отправку анонимизированных данных (таких, как продолжительность звонка и количество участников) команде Element Call, чтобы помочь нам оптимизировать работу приложения на основании того как оно используется.",
|
||||
"Element Call Home": "Главная Element Call",
|
||||
"Copy": "Копировать",
|
||||
"Allow analytics": "Разрешить аналитику",
|
||||
"Advanced": "Расширенные",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Присоединиться сейчас</0><1>или<1><2>Скопировать ссылку на звонок и присоединиться позже</2>",
|
||||
"{{name}} (Connecting...)": "{{name}} (Соединение...)"
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Присоединиться сейчас к звонку</0><1>или<1><2>Скопировать ссылку на звонок и присоединиться позже</2>",
|
||||
"{{name}} (Connecting...)": "{{name}} (Соединение...)",
|
||||
"This feature is only supported on Firefox.": "Эта возможность доступна только в Firefox.",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Отправка журналов поможет нам найти и устранить проблему.</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Упс, что-то пошло не так.</0>",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Ожидание видео...)",
|
||||
"Use the upcoming grid system": "Использовать сеточный показ",
|
||||
"Expose developer settings in the settings window.": "Раскрыть настройки разработчика в окне настроек.",
|
||||
"Developer Settings": "Настройки Разработчика",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Более подробную информацию о том, какие данные мы отслеживаем, вы можете найти в нашей <2> Политике конфиденциальности</2> и нашей <5> Политике использования файлов cookie</5>.",
|
||||
"<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.": "<0></0><1></1>Вы можете отозвать согласие, сняв этот флажок. Если вы в данный момент находитесь в разговоре, эта настройка вступит в силу по окончании разговора."
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
"Fetching group call timed out.": "Vypršal čas načítania skupinového volania.",
|
||||
"Element Call Home": "Domov Element Call",
|
||||
"You can't talk at the same time": "Nemôžete hovoriť naraz",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Či chcete povoliť jednotlačidlové klávesové skratky, napr. \"m\" na stlmenie/zapnutie mikrofónu.",
|
||||
"Waiting for other participants…": "Čaká sa na ďalších účastníkov…",
|
||||
"Waiting for network": "Čakanie na sieť",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "Týmto spôsobom sa budú posielať anonymizované údaje (napríklad trvanie hovoru a počet účastníkov) tímu Element Call, aby nám pomohli optimalizovať aplikáciu na základe toho, ako sa používa.",
|
||||
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "Zvuk reproduktora tak bude vyzerať, akoby vychádzal z miesta, kde je na obrazovke umiestnená jeho ikona. (Experimentálna funkcia: môže to mať vplyv na stabilitu zvuku.)",
|
||||
"Thanks! We'll get right on it.": "Vďaka! Hneď sa do toho pustíme.",
|
||||
"Talking…": "Rozprávanie…",
|
||||
@@ -17,7 +15,6 @@
|
||||
"Submitting feedback…": "Odosielanie spätnej väzby…",
|
||||
"Submit feedback": "Odoslať spätnú väzbu",
|
||||
"Stop sharing screen": "Zastaviť zdieľanie obrazovky",
|
||||
"Single-key keyboard shortcuts": "Jednotlačidlové klávesové skratky",
|
||||
"Show call inspector": "Zobraziť inšpektora hovorov",
|
||||
"Share screen": "Zdieľať obrazovku",
|
||||
"Sending…": "Odosielanie…",
|
||||
@@ -55,7 +52,6 @@
|
||||
"Login": "Prihlásiť sa",
|
||||
"Logging in…": "Prihlasovanie…",
|
||||
"Loading…": "Načítanie…",
|
||||
"Loading room…": "Načítanie miestnosti…",
|
||||
"Leave": "Opustiť",
|
||||
"Join existing call?": "Pripojiť sa k existujúcemu hovoru?",
|
||||
"Join call now": "Pripojiť sa k hovoru teraz",
|
||||
@@ -120,8 +116,6 @@
|
||||
"Avatar": "Obrázok",
|
||||
"Audio": "Audio",
|
||||
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Ďalší používateľ v tomto hovore má problém. Aby sme mohli lepšie diagnostikovať tieto problémy, chceli by sme získať záznam o ladení.",
|
||||
"Allow analytics": "Povoliť analytiku",
|
||||
"Advanced": "Pokročilé",
|
||||
"Accept camera/microphone permissions to join the call.": "Prijmite povolenia kamery/mikrofónu, aby ste sa mohli pripojiť k hovoru.",
|
||||
"Accept microphone permissions to join the call.": "Prijmite povolenia mikrofónu, aby ste sa mohli pripojiť k hovoru.",
|
||||
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Prečo neskončiť nastavením hesla, aby ste si zachovali svoj účet? </0><1>Budete si môcť ponechať svoje meno a nastaviť obrázok, ktorý sa bude používať pri budúcich hovoroch</1>",
|
||||
@@ -137,5 +131,12 @@
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, váš hovor je teraz ukončený",
|
||||
"{{count}} people connected|other": "{{count}} osôb pripojených",
|
||||
"{{count}} people connected|one": "{{count}} osoba pripojená",
|
||||
"This feature is only supported on Firefox.": "Táto funkcia je podporovaná len v prehliadači Firefox."
|
||||
"This feature is only supported on Firefox.": "Táto funkcia je podporovaná len v prehliadači Firefox.",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Odoslanie záznamov ladenia nám pomôže nájsť problém.</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Hups, niečo sa pokazilo.</0>",
|
||||
"Use the upcoming grid system": "Použiť pripravovaný systém mriežky",
|
||||
"Expose developer settings in the settings window.": "Zobraziť nastavenia pre vývojárov v okne nastavení.",
|
||||
"Developer Settings": "Nastavenia pre vývojárov",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <5>Zásadách používania súborov cookie</5>.",
|
||||
"<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.": "<0></0><1></1>Súhlas môžete odvolať zrušením označenia tohto políčka. Ak práve prebieha hovor, toto nastavenie nadobudne platnosť po skončení hovoru."
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"Join call now": "Aramaya katıl",
|
||||
"Join existing call?": "Mevcut aramaya katıl?",
|
||||
"Leave": "Çık",
|
||||
"Loading room…": "Oda yükleniyor…",
|
||||
"Loading…": "Yükleniyor…",
|
||||
"Local volume": "Yerel ses seviyesi",
|
||||
"Logging in…": "Giriliyor…",
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
"Share screen": "Поділитися екраном",
|
||||
"Settings": "Налаштування",
|
||||
"Sending…": "Надсилання…",
|
||||
"Sending debug logs…": "Надсилання журналу зневадження…",
|
||||
"Send debug logs": "Надіслати журнал зневадження",
|
||||
"Sending debug logs…": "Надсилання журналу налагодження…",
|
||||
"Send debug logs": "Надіслати журнал налагодження",
|
||||
"Select an option": "Вибрати опцію",
|
||||
"Saving…": "Збереження…",
|
||||
"Save": "Зберегти",
|
||||
@@ -72,7 +72,6 @@
|
||||
"Login": "Увійти",
|
||||
"Logging in…": "Вхід…",
|
||||
"Local volume": "Локальна гучність",
|
||||
"Loading room…": "Завантаження кімнати…",
|
||||
"Leave": "Вийти",
|
||||
"Join existing call?": "Приєднатися до наявного виклику?",
|
||||
"Join call now": "Приєднатися до виклику зараз",
|
||||
@@ -82,7 +81,7 @@
|
||||
"Inspector": "Інспектор",
|
||||
"Incompatible versions!": "Несумісні версії!",
|
||||
"Incompatible versions": "Несумісні версії",
|
||||
"Include debug logs": "Долучити журнали зневадження",
|
||||
"Include debug logs": "Долучити журнали налагодження",
|
||||
"Home": "Домівка",
|
||||
"Having trouble? Help us fix it.": "Проблеми? Допоможіть нам це виправити.",
|
||||
"Grid layout menu": "Меню у вигляді сітки",
|
||||
@@ -91,13 +90,13 @@
|
||||
"Freedom": "Свобода",
|
||||
"Fetching group call timed out.": "Вичерпано час очікування групового виклику.",
|
||||
"Exit full screen": "Вийти з повноекранного режиму",
|
||||
"Download debug logs": "Завантажити журнали зневадження",
|
||||
"Download debug logs": "Завантажити журнали налагодження",
|
||||
"Display name": "Показуване ім'я",
|
||||
"Developer": "Розробнику",
|
||||
"Details": "Подробиці",
|
||||
"Description (optional)": "Опис (необов'язково)",
|
||||
"Debug log request": "Запит журналу зневадження",
|
||||
"Debug log": "Журнал зневадження",
|
||||
"Debug log request": "Запит журналу налагодження",
|
||||
"Debug log": "Журнал налагодження",
|
||||
"Create account": "Створити обліковий запис",
|
||||
"Copy and share this call link": "Скопіювати та поділитися цим посиланням на виклик",
|
||||
"Copied!": "Скопійовано!",
|
||||
@@ -114,7 +113,7 @@
|
||||
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "Натиснувши «Далі», ви погодитеся з нашими <2>Умовами та положеннями</2>",
|
||||
"Avatar": "Аватар",
|
||||
"Audio": "Звук",
|
||||
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Інший користувач у цьому виклику має проблему. Щоб краще визначити ці проблеми, ми хотіли б зібрати журнал зневадження.",
|
||||
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Інший користувач у цьому виклику має проблему. Щоб краще визначити ці проблеми, ми хотіли б зібрати журнал налагодження.",
|
||||
"Accept microphone permissions to join the call.": "Надайте дозволи на використання мікрофонів для приєднання до виклику.",
|
||||
"Accept camera/microphone permissions to join the call.": "Надайте дозвіл на використання камери/мікрофона для приєднання до виклику.",
|
||||
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Чому б не завершити, налаштувавши пароль для збереження свого облікового запису?</0><1>Ви зможете зберегти своє ім'я та встановити аватарку для подальшого користування під час майбутніх викликів</1>",
|
||||
@@ -129,13 +128,15 @@
|
||||
"{{count}} people connected|one": "{{count}} під'єднується",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Приєднатися до виклику зараз</0><1>Or</1><2>Скопіювати посилання на виклик і приєднатися пізніше</2>",
|
||||
"{{name}} (Connecting...)": "{{name}} (З'єднання...)",
|
||||
"Allow analytics": "Дозволити аналітику",
|
||||
"Advanced": "Розширені",
|
||||
"Element Call Home": "Домівка Element Call",
|
||||
"Copy": "Копіювати",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "Це дозволить надсилати анонімні дані (такі як тривалість виклику та кількість учасників) команді Element Call, щоб допомогти нам оптимізувати роботу застосунку на основі того, як він використовується.",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Чи вмикати/вимикати мікрофон однією клавішею, наприклад, «m» для ввімкнення/вимкнення мікрофона.",
|
||||
"Single-key keyboard shortcuts": "Одноклавішні комбінації клавіш",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Очікування на відео...)",
|
||||
"This feature is only supported on Firefox.": "Ця функція підтримується лише в браузері Firefox."
|
||||
"This feature is only supported on Firefox.": "Ця функція підтримується лише в браузері Firefox.",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Надсилання журналів налагодження допоможе нам виявити проблему.</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>Йой, щось пішло не за планом.</0>",
|
||||
"Use the upcoming grid system": "Використовувати майбутню сіткову систему",
|
||||
"Expose developer settings in the settings window.": "Відкрийте налаштування розробника у вікні налаштувань.",
|
||||
"Developer Settings": "Налаштування розробника",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <5>Політиці про куки</5>.",
|
||||
"<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.": "<0></0><1></1>Ви можете відкликати згоду, прибравши цей прапорець. Якщо ви зараз розмовляєте, це налаштування застосується після завершення виклику."
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"Your recent calls": "最近通话",
|
||||
"You can't talk at the same time": "你不能在同一时间发言",
|
||||
"Yes, join call": "是,加入通话",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "是否启用单键键盘快捷键,例如,'m'可使麦克风静音/取消静音。",
|
||||
"WebRTC is not supported or is being blocked in this browser.": "此浏览器不支持WebRTC或WebRTC被浏览器阻止。",
|
||||
"Walkie-talkie call name": "对讲机通话名称",
|
||||
"Walkie-talkie call": "对讲机通话",
|
||||
@@ -18,7 +17,6 @@
|
||||
"Unmute microphone": "取消麦克风静音",
|
||||
"Turn on camera": "开启摄像头",
|
||||
"Turn off camera": "关闭摄像头",
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "这将向Element Call团队发送匿名数据(如通话的持续时间和参与者的数量),以帮助我们根据使用方式优化应用程序。",
|
||||
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "这将使发言人的音频看起来像是来自他们在屏幕上的位置。(实验性功能:这可能影响音频的稳定性)",
|
||||
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "本网站受reCaptcha保护,并适用Google<2>隐私政策</2>和<6>服务条款</6>。<9></9>点击\"注册\"则表明您同意我们的<12>条款和条件</12>",
|
||||
"This call already exists, would you like to join?": "该通话已存在,你想加入吗?",
|
||||
@@ -33,13 +31,10 @@
|
||||
"Speaker {{n}}": "发言人 {{n}}",
|
||||
"Speaker": "发言人",
|
||||
"Spatial audio": "空间音频",
|
||||
"Single-key keyboard shortcuts": "单键键盘快捷方式",
|
||||
"Sign out": "注销登录",
|
||||
"Sign in": "登录",
|
||||
"Audio": "音频",
|
||||
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "这个通话中的另一个用户出现了问题。为了更好地诊断这些问题,我们想收集调试日志。",
|
||||
"Allow analytics": "允许进行分析",
|
||||
"Advanced": "偏好",
|
||||
"Accept microphone permissions to join the call.": "授予麦克风权限以加入通话。",
|
||||
"Accept camera/microphone permissions to join the call.": "授予摄像头/麦克风权限以加入通话。",
|
||||
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>为什么不设置一个密码来保留你的账户?</0><1>你将可以保留你的名字并设置一个头像,以便在未来的通话中使用。</1>",
|
||||
@@ -95,7 +90,6 @@
|
||||
"Logging in…": "登录中……",
|
||||
"Local volume": "本地音量",
|
||||
"Loading…": "加载中……",
|
||||
"Loading room…": "加载房间中……",
|
||||
"Leave": "离开",
|
||||
"Join existing call?": "加入现有的通话?",
|
||||
"Join call now": "现在加入通话",
|
||||
|
||||
@@ -1,14 +1,142 @@
|
||||
{
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>加入通話</0><1>或</1><2>複製通話連結並稍候加入</2>",
|
||||
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>建立帳號</0> 或 <2>訪客模式</2>",
|
||||
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>已經有帳號了?</0><1><0>登入</0> Or <2>訪客模式登入</2></1>",
|
||||
"{{roomName}} - Walkie-talkie call": "{{roomName}} - 無線電通話",
|
||||
"{{names}}, {{name}}": "{{names}},{{name}}",
|
||||
"{{name}} is talking…": "{{name}} 正在交談中…",
|
||||
"{{name}} is presenting": "{{name}} 正在報告",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (等待視訊畫面 ...)",
|
||||
"{{name}} (Connecting...)": "{{name}} (連線中 ...)",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}},您的通話現在已結束",
|
||||
"{{count}} people connected|other": "{{count}} 人已連線",
|
||||
"{{count}} people connected|one": "{{count}} 人已連線"
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>現在加入通話</0><1>或</1><2>複製通話連結,稍後再加入</2>",
|
||||
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>建立帳號</0> 或<2>以訪客身份登入</2>",
|
||||
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>已經有帳號?</0><1><0>登入</0> 或<2>以訪客身份登入</2></1>",
|
||||
"{{roomName}} - Walkie-talkie call": "{{roomName}} - 對講機式通話",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{name}} is talking…": "{{name}} 正在發言…",
|
||||
"{{name}} is presenting": "{{name}} 已上線",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (等候視訊中...)",
|
||||
"{{name}} (Connecting...)": "{{name}} (連結中...)",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}},您的通話已結束",
|
||||
"{{count}} people connected|other": "{{count}} 人已連結",
|
||||
"{{count}} people connected|one": "{{count}} 人已連結",
|
||||
"Use the upcoming grid system": "使用即將推出的網格系統",
|
||||
"Expose developer settings in the settings window.": "在設定視窗中顯示開發者設定。",
|
||||
"Developer Settings": "開發者設定",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>送出除錯紀錄,可幫助我們修正問題。</0>",
|
||||
"<0>Oops, something's gone wrong.</0>": "<0>喔喔,有些地方怪怪的。</0>",
|
||||
"Your recent calls": "您最近的通話",
|
||||
"You can't talk at the same time": "您無法在同一時間發言",
|
||||
"Yes, join call": "是,加入對話",
|
||||
"WebRTC is not supported or is being blocked in this browser.": "此瀏覽器未支援 WebRTC 或 WebRTC 被瀏覽器封鎖。",
|
||||
"Walkie-talkie call name": "對講機式通話名稱",
|
||||
"Walkie-talkie call": "即時通話",
|
||||
"Waiting for other participants…": "等待其他參加者…",
|
||||
"Waiting for network": "等待網路連線",
|
||||
"Video call name": "視訊通話姓名",
|
||||
"Video call": "視訊通話",
|
||||
"Video": "視訊",
|
||||
"Version: {{version}}": "版本: {{version}}",
|
||||
"Username": "使用者名稱",
|
||||
"User menu": "使用者選單",
|
||||
"User ID": "使用者 ID",
|
||||
"Unmute microphone": "取消麥克風靜音",
|
||||
"Turn on camera": "開啟相機",
|
||||
"Turn off camera": "關閉相機",
|
||||
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "這會使得發言者的聲音聽起來,像從他們在畫面中的位置傳來(實驗性功能:這可能會影響語音的穩定性。)",
|
||||
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "此網站使用Google 驗證碼技術保護,適用<2>隱私條款</2> 與<6>條款與細則</6> 。<9></9>按下「註冊」,表示您同意我們的<12>條款與細則</12>",
|
||||
"This feature is only supported on Firefox.": "只有 Firefox 支援此功能。",
|
||||
"This call already exists, would you like to join?": "通話已經開始,請問您要加入嗎?",
|
||||
"Thanks! We'll get right on it.": "謝謝您!我們會盡快處理。",
|
||||
"Talking…": "對話中…",
|
||||
"Talk over speaker": "以擴音對話",
|
||||
"Take me Home": "帶我回主畫面",
|
||||
"Submitting feedback…": "遞交回饋…",
|
||||
"Submit feedback": "遞交回覆",
|
||||
"Stop sharing screen": "停止分享螢幕畫面",
|
||||
"Spotlight": "聚焦",
|
||||
"Speaker {{n}}": "發言者{{n}}",
|
||||
"Speaker": "發言者",
|
||||
"Spatial audio": "空間音效",
|
||||
"Sign out": "登出",
|
||||
"Sign in": "登入",
|
||||
"Show call inspector": "顯示通話稽查員",
|
||||
"Share screen": "分享畫面",
|
||||
"Settings": "設定",
|
||||
"Sending…": "傳送中…",
|
||||
"Sending debug logs…": "傳送除錯記錄檔中…",
|
||||
"Send debug logs": "傳送除錯紀錄",
|
||||
"Select an option": "選擇一個選項",
|
||||
"Saving…": "儲存中…",
|
||||
"Save": "儲存",
|
||||
"Return to home screen": "回到首頁",
|
||||
"Remove": "移除",
|
||||
"Release to stop": "放開以停止",
|
||||
"Release spacebar key to stop": "放開空白鍵以停止",
|
||||
"Registering…": "註冊中…",
|
||||
"Register": "註冊",
|
||||
"Recaptcha not loaded": "驗證碼未載入",
|
||||
"Recaptcha dismissed": "略過驗證碼",
|
||||
"Profile": "個人檔案",
|
||||
"Press and hold to talk over {{name}}": "與{{name}}對話時,請按住按鍵",
|
||||
"Press and hold to talk": "請按住按鍵來發言",
|
||||
"Press and hold spacebar to talk over {{name}}": "與{{name}}對話時,請按住空白鍵",
|
||||
"Press and hold spacebar to talk": "說話時請按住空白鍵",
|
||||
"Passwords must match": "密碼必須相符",
|
||||
"Password": "密碼",
|
||||
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "有使用者試著加入通話,但他們的軟體版本不相容。這些使用者需要確認已將瀏覽器更新到最新版本:<1>{userLis}</1>",
|
||||
"Not registered yet? <2>Create an account</2>": "還沒註冊嗎?<2>建立帳號</2>",
|
||||
"Not now, return to home screen": "現在不行,回到首頁",
|
||||
"No": "否",
|
||||
"Mute microphone": "麥克風靜音",
|
||||
"More menu": "更多選單",
|
||||
"More": "更多",
|
||||
"Microphone permissions needed to join the call.": "加入通話前需要取得麥克風的權限。",
|
||||
"Microphone {{n}}": "麥克風 {{n}}",
|
||||
"Microphone": "麥克風",
|
||||
"Login to your account": "登入您的帳號",
|
||||
"Login": "登入",
|
||||
"Logging in…": "登入中…",
|
||||
"Local volume": "您的音量",
|
||||
"Loading…": "載入中…",
|
||||
"Leave": "離開",
|
||||
"Join existing call?": "加入已開始的通話嗎?",
|
||||
"Join call now": "現在加入通話",
|
||||
"Join call": "加入通話",
|
||||
"Invite people": "邀請夥伴",
|
||||
"Invite": "邀請",
|
||||
"Inspector": "稽查員",
|
||||
"Incompatible versions!": "不相容版本!",
|
||||
"Incompatible versions": "不相容版本",
|
||||
"Include debug logs": "包含除錯紀錄",
|
||||
"Home": "首頁",
|
||||
"Having trouble? Help us fix it.": "遇到問題嗎?請讓我們協助您。",
|
||||
"Grid layout menu": "格框式清單",
|
||||
"Go": "前往",
|
||||
"Full screen": "全螢幕",
|
||||
"Freedom": "自由",
|
||||
"Fetching group call timed out.": "加入群組對話已逾時。",
|
||||
"Exit full screen": "退出全螢幕",
|
||||
"Element Call Home": "Element Call 首頁",
|
||||
"Download debug logs": "下載偵錯報告",
|
||||
"Display name": "顯示名稱",
|
||||
"Developer": "開發者",
|
||||
"Details": "詳細說明",
|
||||
"Description (optional)": "描述(選擇性)",
|
||||
"Debug log request": "請求偵錯報告",
|
||||
"Debug log": "除錯紀錄",
|
||||
"Create account": "建立帳號",
|
||||
"Copy and share this call link": "複製並分享通話連結",
|
||||
"Copy": "複製",
|
||||
"Copied!": "已複製!",
|
||||
"Connection lost": "連線中斷",
|
||||
"Confirm password": "確認密碼",
|
||||
"Close": "關閉",
|
||||
"Change layout": "變更排列",
|
||||
"Camera/microphone permissions needed to join the call.": "加入通話需要取得相機/麥克風的權限。",
|
||||
"Camera {{n}}": "相機 {{n}}",
|
||||
"Camera": "相機",
|
||||
"Call type menu": "通話類型選單",
|
||||
"Call link copied": "已複製通話連結",
|
||||
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "當您按下「加入通話」,您也同時同意了我們的條款與細則",
|
||||
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "當您按下「前往」,你也同意了我們的條款與細則",
|
||||
"Avatar": "大頭照",
|
||||
"Audio": "語音",
|
||||
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "這通對話中的另一位使用者遇到了某些問題。為了診斷問題,我們將會建立除錯紀錄。",
|
||||
"Accept microphone permissions to join the call.": "請授權使用您的麥克風以加入通話。",
|
||||
"Accept camera/microphone permissions to join the call.": "請授權使用您的相機/麥克風以加入對話。",
|
||||
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>何不設定密碼以保留此帳號?</0><1>您可以保留暱稱並設定頭像,以便未來通話時使用</1>",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "參與此測試版即表示您同意蒐集匿名資料,我們使用這些資料來改進產品。您可以在我們的<2>隱私政策</2>與我們的 <5>Cookie 政策</5> 中找到關於我們追蹤哪些資料的更多資訊。",
|
||||
"<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.": "<0></0><1></1>您可以透過取消核取此方塊來撤回同意。若您目前正在通話中,此設定將在通話結束時生效。"
|
||||
}
|
||||
|
||||
15
scripts/reformat-release-notes.py
Executable file
15
scripts/reformat-release-notes.py
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This script can be used to reformat the release notes generated by
|
||||
# GitHub releases into a format slightly more appropriate for our
|
||||
# project (ie. we don't really need to mention the author of every PR)
|
||||
#
|
||||
# eg. in: * OpenTelemetry by @dbkr in https://github.com/vector-im/element-call/pull/961
|
||||
# out: * OpenTelemetry (https://github.com/vector-im/element-call/pull/961)
|
||||
|
||||
import sys
|
||||
import re
|
||||
|
||||
for line in sys.stdin:
|
||||
matches = re.match(r'^\* (.*) by (\S+) in (\S+)$', line.strip())
|
||||
print("* %s (%s)" % (matches[1], matches[3]))
|
||||
@@ -36,7 +36,10 @@ import {
|
||||
fallbackICEServerAllowed,
|
||||
} from "./matrix-utils";
|
||||
import { widget } from "./widget";
|
||||
import { PosthogAnalytics, RegistrationType } from "./PosthogAnalytics";
|
||||
import {
|
||||
PosthogAnalytics,
|
||||
RegistrationType,
|
||||
} from "./analytics/PosthogAnalytics";
|
||||
import { translatedError } from "./TranslatedError";
|
||||
import { useEventTarget } from "./useEvents";
|
||||
import { Config } from "./config/Config";
|
||||
@@ -339,6 +342,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
window.matrixclient = client;
|
||||
window.isPasswordlessUser = isPasswordlessUser;
|
||||
|
||||
if (PosthogAnalytics.hasInstance())
|
||||
PosthogAnalytics.instance.onLoginStatusChanged();
|
||||
}, [client, isPasswordlessUser]);
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -134,7 +134,9 @@ export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) {
|
||||
/>
|
||||
<VideoIcon width={16} height={16} />
|
||||
</div>
|
||||
<Subtitle fontWeight="semiBold">{roomName}</Subtitle>
|
||||
<Subtitle data-testid="roomHeader_roomName" fontWeight="semiBold">
|
||||
{roomName}
|
||||
</Subtitle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function Modal({
|
||||
{...closeButtonProps}
|
||||
ref={closeButtonRef}
|
||||
className={styles.closeButton}
|
||||
data-testid="modal_close"
|
||||
title={t("Close")}
|
||||
>
|
||||
<CloseIcon />
|
||||
|
||||
@@ -58,6 +58,7 @@ export function UserMenu({
|
||||
key: "user",
|
||||
icon: UserIcon,
|
||||
label: displayName,
|
||||
dataTestid: "usermenu_user",
|
||||
});
|
||||
|
||||
if (isPasswordlessUser && !preventNavigation) {
|
||||
@@ -65,6 +66,7 @@ export function UserMenu({
|
||||
key: "login",
|
||||
label: t("Sign in"),
|
||||
icon: LoginIcon,
|
||||
dataTestid: "usermenu_login",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,6 +75,7 @@ export function UserMenu({
|
||||
key: "logout",
|
||||
label: t("Sign out"),
|
||||
icon: LogoutIcon,
|
||||
dataTestid: "usermenu_logout",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -93,7 +96,11 @@ export function UserMenu({
|
||||
return (
|
||||
<PopoverMenuTrigger placement="bottom right">
|
||||
<TooltipTrigger tooltip={tooltip} placement="bottom left">
|
||||
<Button variant="icon" className={styles.userButton}>
|
||||
<Button
|
||||
variant="icon"
|
||||
className={styles.userButton}
|
||||
data-testid="usermenu_open"
|
||||
>
|
||||
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
|
||||
<Avatar
|
||||
size={Size.SM}
|
||||
@@ -108,9 +115,14 @@ export function UserMenu({
|
||||
</TooltipTrigger>
|
||||
{(props) => (
|
||||
<Menu {...props} label={t("User menu")} onAction={onAction}>
|
||||
{items.map(({ key, icon: Icon, label }) => (
|
||||
{items.map(({ key, icon: Icon, label, dataTestid }) => (
|
||||
<Item key={key} textValue={label}>
|
||||
<Icon width={24} height={24} className={styles.menuIcon} />
|
||||
<Icon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.menuIcon}
|
||||
data-testid={dataTestid}
|
||||
/>
|
||||
<Body overflowEllipsis>{label}</Body>
|
||||
</Item>
|
||||
))}
|
||||
|
||||
14
src/analytics/AnalyticsNotice.tsx
Normal file
14
src/analytics/AnalyticsNotice.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React, { FC } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
import { Link } from "../typography/Typography";
|
||||
|
||||
export const AnalyticsNotice: FC = () => (
|
||||
<Trans>
|
||||
By participating in this beta, you consent to the collection of anonymous
|
||||
data, which we use to improve the product. You can find more information
|
||||
about which data we track in our{" "}
|
||||
<Link href="https://element.io/privacy">Privacy Policy</Link> and our{" "}
|
||||
<Link href="https://element.io/cookie-policy">Cookie Policy</Link>.
|
||||
</Trans>
|
||||
);
|
||||
@@ -19,8 +19,8 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixClient } from "matrix-js-sdk";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
import { widget } from "./widget";
|
||||
import { getSetting, setSetting, settingsBus } from "./settings/useSetting";
|
||||
import { widget } from "../widget";
|
||||
import { getSetting, setSetting, settingsBus } from "../settings/useSetting";
|
||||
import {
|
||||
CallEndedTracker,
|
||||
CallStartedTracker,
|
||||
@@ -28,9 +28,10 @@ import {
|
||||
SignupTracker,
|
||||
MuteCameraTracker,
|
||||
MuteMicrophoneTracker,
|
||||
UndecryptableToDeviceEventTracker,
|
||||
} from "./PosthogEvents";
|
||||
import { Config } from "./config/Config";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
|
||||
/* Posthog analytics tracking.
|
||||
*
|
||||
@@ -93,7 +94,7 @@ export class PosthogAnalytics {
|
||||
private static ANALYTICS_EVENT_TYPE = "im.vector.analytics";
|
||||
|
||||
// set true during the constructor if posthog config is present, otherwise false
|
||||
private static internalInstance = null;
|
||||
private static internalInstance: PosthogAnalytics | null = null;
|
||||
|
||||
private identificationPromise: Promise<void>;
|
||||
private readonly enabled: boolean = false;
|
||||
@@ -101,6 +102,10 @@ export class PosthogAnalytics {
|
||||
private platformSuperProperties = {};
|
||||
private registrationType: RegistrationType = RegistrationType.Guest;
|
||||
|
||||
public static hasInstance(): boolean {
|
||||
return Boolean(this.internalInstance);
|
||||
}
|
||||
|
||||
public static get instance(): PosthogAnalytics {
|
||||
if (!this.internalInstance) {
|
||||
this.internalInstance = new PosthogAnalytics(posthog);
|
||||
@@ -136,6 +141,9 @@ export class PosthogAnalytics {
|
||||
});
|
||||
this.enabled = true;
|
||||
} else {
|
||||
logger.info(
|
||||
"Posthog is not enabled because there is no api key or no host given in the config"
|
||||
);
|
||||
this.enabled = false;
|
||||
}
|
||||
this.startListeningToSettingsChanges();
|
||||
@@ -223,10 +231,8 @@ export class PosthogAnalytics {
|
||||
.join("");
|
||||
}
|
||||
|
||||
public async identifyUser(analyticsIdGenerator: () => string) {
|
||||
// There might be a better way to get the client here.
|
||||
|
||||
if (this.anonymity == Anonymity.Pseudonymous) {
|
||||
private async identifyUser(analyticsIdGenerator: () => string) {
|
||||
if (this.anonymity == Anonymity.Pseudonymous && this.enabled) {
|
||||
// Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows
|
||||
// different devices to send the same ID.
|
||||
let analyticsID = await this.getAnalyticsId();
|
||||
@@ -317,7 +323,12 @@ export class PosthogAnalytics {
|
||||
this.setAnonymity(Anonymity.Disabled);
|
||||
}
|
||||
|
||||
public updateSuperProperties() {
|
||||
public onLoginStatusChanged(): void {
|
||||
const optInAnalytics = getSetting("opt-in-analytics", false);
|
||||
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
||||
}
|
||||
|
||||
private updateSuperProperties() {
|
||||
// Update super properties in posthog with our platform (app version, platform).
|
||||
// These properties will be subsequently passed in every event.
|
||||
//
|
||||
@@ -337,7 +348,7 @@ export class PosthogAnalytics {
|
||||
return this.eventSignup.getSignupEndTime() > new Date(0);
|
||||
}
|
||||
|
||||
public async updateAnonymityAndIdentifyUser(
|
||||
private async updateAnonymityAndIdentifyUser(
|
||||
pseudonymousOptIn: boolean
|
||||
): Promise<void> {
|
||||
// Update this.anonymity based on the user's analytics opt-in settings
|
||||
@@ -346,6 +357,10 @@ export class PosthogAnalytics {
|
||||
: Anonymity.Disabled;
|
||||
this.setAnonymity(anonymity);
|
||||
|
||||
// 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.
|
||||
if (!window.matrixclient) return;
|
||||
|
||||
if (anonymity === Anonymity.Pseudonymous) {
|
||||
this.setRegistrationType(
|
||||
window.matrixclient.isGuest() || window.isPasswordlessUser
|
||||
@@ -383,7 +398,7 @@ export class PosthogAnalytics {
|
||||
this.capture(eventName, properties, options);
|
||||
}
|
||||
|
||||
public startListeningToSettingsChanges(): void {
|
||||
private startListeningToSettingsChanges(): void {
|
||||
// Listen to account data changes from sync so we can observe changes to relevant flags and update.
|
||||
// This is called -
|
||||
// * On page load, when the account data is first received by sync
|
||||
@@ -415,4 +430,5 @@ export class PosthogAnalytics {
|
||||
public eventLogin = new LoginTracker();
|
||||
public eventMuteMicrophone = new MuteMicrophoneTracker();
|
||||
public eventMuteCamera = new MuteCameraTracker();
|
||||
public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker();
|
||||
}
|
||||
@@ -149,3 +149,17 @@ export class MuteCameraTracker {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface UndecryptableToDeviceEvent {
|
||||
eventName: "UndecryptableToDeviceEvent";
|
||||
callId: string;
|
||||
}
|
||||
|
||||
export class UndecryptableToDeviceEventTracker {
|
||||
track(callId: string) {
|
||||
PosthogAnalytics.instance.trackEvent<UndecryptableToDeviceEvent>({
|
||||
eventName: "UndecryptableToDeviceEvent",
|
||||
callId,
|
||||
});
|
||||
}
|
||||
}
|
||||
154
src/analytics/PosthogSpanProcessor.ts
Normal file
154
src/analytics/PosthogSpanProcessor.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
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 {
|
||||
SpanProcessor,
|
||||
ReadableSpan,
|
||||
Span,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
import { hrTimeToMilliseconds } from "@opentelemetry/core";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
|
||||
interface PrevCall {
|
||||
callId: string;
|
||||
hangupTs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum time between hanging up and joining the same call that we would
|
||||
* consider a 'rejoin' on the user's part.
|
||||
*/
|
||||
const maxRejoinMs = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
/**
|
||||
* Span processor that extracts certain metrics from spans to send to PostHog
|
||||
*/
|
||||
export class PosthogSpanProcessor implements SpanProcessor {
|
||||
async forceFlush(): Promise<void> {}
|
||||
|
||||
onStart(span: Span): void {
|
||||
// Hack: Yield to allow attributes to be set before processing
|
||||
Promise.resolve().then(() => {
|
||||
switch (span.name) {
|
||||
case "matrix.groupCallMembership":
|
||||
this.onGroupCallMembershipStart(span);
|
||||
return;
|
||||
case "matrix.groupCallMembership.summaryReport":
|
||||
this.onSummaryReportStart(span);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onEnd(span: ReadableSpan): void {
|
||||
switch (span.name) {
|
||||
case "matrix.groupCallMembership":
|
||||
this.onGroupCallMembershipEnd(span);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private get prevCall(): PrevCall | null {
|
||||
// This is stored in localStorage so we can remember the previous call
|
||||
// across app restarts
|
||||
const data = localStorage.getItem("matrix-prev-call");
|
||||
if (data === null) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
logger.warn("Invalid prev call data", data);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private set prevCall(data: PrevCall | null) {
|
||||
localStorage.setItem("matrix-prev-call", JSON.stringify(data));
|
||||
}
|
||||
|
||||
private onGroupCallMembershipStart(span: ReadableSpan): void {
|
||||
const prevCall = this.prevCall;
|
||||
const newCallId = span.attributes["matrix.confId"] as string;
|
||||
|
||||
// If the user joined the same call within a short time frame, log this as a
|
||||
// rejoin. This is interesting as a call quality metric, since rejoins may
|
||||
// indicate that users had to intervene to make the product work.
|
||||
if (prevCall !== null && newCallId === prevCall.callId) {
|
||||
const duration = hrTimeToMilliseconds(span.startTime) - prevCall.hangupTs;
|
||||
if (duration <= maxRejoinMs) {
|
||||
PosthogAnalytics.instance.trackEvent({
|
||||
eventName: "Rejoin",
|
||||
callId: prevCall.callId,
|
||||
rejoinDuration: duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onGroupCallMembershipEnd(span: ReadableSpan): void {
|
||||
this.prevCall = {
|
||||
callId: span.attributes["matrix.confId"] as string,
|
||||
hangupTs: hrTimeToMilliseconds(span.endTime),
|
||||
};
|
||||
}
|
||||
|
||||
private onSummaryReportStart(span: ReadableSpan): void {
|
||||
// Searching for an event like this:
|
||||
// matrix.stats.summary
|
||||
// matrix.stats.summary.percentageReceivedAudioMedia: 0.75
|
||||
// matrix.stats.summary.percentageReceivedMedia: 1
|
||||
// matrix.stats.summary.percentageReceivedVideoMedia: 0.75
|
||||
// matrix.stats.summary.maxJitter: 100
|
||||
// matrix.stats.summary.maxPacketLoss: 20
|
||||
const event = span.events.find((e) => e.name === "matrix.stats.summary");
|
||||
if (event !== undefined) {
|
||||
const attributes = event.attributes;
|
||||
if (attributes) {
|
||||
const mediaReceived = `${attributes["matrix.stats.summary.percentageReceivedMedia"]}`;
|
||||
const videoReceived = `${attributes["matrix.stats.summary.percentageReceivedVideoMedia"]}`;
|
||||
const audioReceived = `${attributes["matrix.stats.summary.percentageReceivedAudioMedia"]}`;
|
||||
const maxJitter = `${attributes["matrix.stats.summary.maxJitter"]}`;
|
||||
const maxPacketLoss = `${attributes["matrix.stats.summary.maxPacketLoss"]}`;
|
||||
const peerConnections = `${attributes["matrix.stats.summary.peerConnections"]}`;
|
||||
const percentageConcealedAudio = `${attributes["matrix.stats.summary.percentageConcealedAudio"]}`;
|
||||
PosthogAnalytics.instance.trackEvent(
|
||||
{
|
||||
eventName: "MediaReceived",
|
||||
callId: span.attributes["matrix.confId"] as string,
|
||||
mediaReceived: mediaReceived,
|
||||
audioReceived: audioReceived,
|
||||
videoReceived: videoReceived,
|
||||
maxJitter: maxJitter,
|
||||
maxPacketLoss: maxPacketLoss,
|
||||
peerConnections: peerConnections,
|
||||
percentageConcealedAudio: percentageConcealedAudio,
|
||||
},
|
||||
// Send instantly because the window might be closing
|
||||
{ send_instantly: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the processor.
|
||||
*/
|
||||
shutdown(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
114
src/analytics/RageshakeSpanProcessor.ts
Normal file
114
src/analytics/RageshakeSpanProcessor.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Attributes } from "@opentelemetry/api";
|
||||
import { hrTimeToMicroseconds } from "@opentelemetry/core";
|
||||
import {
|
||||
SpanProcessor,
|
||||
ReadableSpan,
|
||||
Span,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
|
||||
const dumpAttributes = (attr: Attributes) =>
|
||||
Object.entries(attr).map(([key, value]) => ({
|
||||
key,
|
||||
type: typeof value,
|
||||
value,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Exports spans on demand to the Jaeger JSON format, which can be attached to
|
||||
* rageshakes and loaded into analysis tools like Jaeger and Stalk.
|
||||
*/
|
||||
export class RageshakeSpanProcessor implements SpanProcessor {
|
||||
private readonly spans: ReadableSpan[] = [];
|
||||
|
||||
async forceFlush(): Promise<void> {}
|
||||
|
||||
onStart(span: Span): void {
|
||||
this.spans.push(span);
|
||||
}
|
||||
|
||||
onEnd(): void {}
|
||||
|
||||
/**
|
||||
* Dumps the spans collected so far as Jaeger-compatible JSON.
|
||||
*/
|
||||
public dump(): string {
|
||||
const now = Date.now() * 1000; // Jaeger works in microseconds
|
||||
const traces = new Map<string, ReadableSpan[]>();
|
||||
|
||||
// Organize spans by their trace IDs
|
||||
for (const span of this.spans) {
|
||||
const traceId = span.spanContext().traceId;
|
||||
let trace = traces.get(traceId);
|
||||
|
||||
if (trace === undefined) {
|
||||
trace = [];
|
||||
traces.set(traceId, trace);
|
||||
}
|
||||
|
||||
trace.push(span);
|
||||
}
|
||||
|
||||
const processId = "p1";
|
||||
const processes = {
|
||||
[processId]: {
|
||||
serviceName: "element-call",
|
||||
tags: [],
|
||||
},
|
||||
warnings: null,
|
||||
};
|
||||
|
||||
return JSON.stringify({
|
||||
// Honestly not sure what some of these fields mean, I just know that
|
||||
// they're present in Jaeger JSON exports
|
||||
total: 0,
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
errors: null,
|
||||
data: [...traces.entries()].map(([traceId, spans]) => ({
|
||||
traceID: traceId,
|
||||
warnings: null,
|
||||
processes,
|
||||
spans: spans.map((span) => {
|
||||
const ctx = span.spanContext();
|
||||
const startTime = hrTimeToMicroseconds(span.startTime);
|
||||
// If the span has not yet ended, pretend that it ends now
|
||||
const duration =
|
||||
span.duration[0] === -1
|
||||
? now - startTime
|
||||
: hrTimeToMicroseconds(span.duration);
|
||||
|
||||
return {
|
||||
traceID: traceId,
|
||||
spanID: ctx.spanId,
|
||||
operationName: span.name,
|
||||
processID: processId,
|
||||
warnings: null,
|
||||
startTime,
|
||||
duration,
|
||||
references:
|
||||
span.parentSpanId === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
refType: "CHILD_OF",
|
||||
traceID: traceId,
|
||||
spanID: span.parentSpanId,
|
||||
},
|
||||
],
|
||||
tags: dumpAttributes(span.attributes),
|
||||
logs: span.events.map((event) => ({
|
||||
timestamp: hrTimeToMicroseconds(event.time),
|
||||
// The name of the event is in the "event" field, aparently.
|
||||
fields: [
|
||||
...dumpAttributes(event.attributes ?? {}),
|
||||
{ key: "event", type: "string", value: event.name },
|
||||
],
|
||||
})),
|
||||
};
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import { Button } from "../button";
|
||||
import styles from "./LoginPage.module.css";
|
||||
import { useInteractiveLogin } from "./useInteractiveLogin";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { PosthogAnalytics } from "../PosthogAnalytics";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
export const LoginPage: FC = () => {
|
||||
@@ -88,6 +88,7 @@ export const LoginPage: FC = () => {
|
||||
autoCapitalize="none"
|
||||
prefix="@"
|
||||
suffix={`:${Config.defaultServerName()}`}
|
||||
data-testid="login_username"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
@@ -96,6 +97,7 @@ export const LoginPage: FC = () => {
|
||||
ref={passwordRef}
|
||||
placeholder={t("Password")}
|
||||
label={t("Password")}
|
||||
data-testid="login_password"
|
||||
/>
|
||||
</FieldRow>
|
||||
{error && (
|
||||
@@ -104,7 +106,11 @@ export const LoginPage: FC = () => {
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow>
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
data-testid="login_login"
|
||||
>
|
||||
{loading ? t("Logging in…") : t("Login")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
|
||||
@@ -38,7 +38,7 @@ import { LoadingView } from "../FullScreenView";
|
||||
import { useRecaptcha } from "./useRecaptcha";
|
||||
import { Caption, Link } from "../typography/Typography";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { PosthogAnalytics } from "../PosthogAnalytics";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
export const RegisterPage: FC = () => {
|
||||
@@ -166,6 +166,7 @@ export const RegisterPage: FC = () => {
|
||||
autoCapitalize="none"
|
||||
prefix="@"
|
||||
suffix={`:${Config.defaultServerName()}`}
|
||||
data-testid="register_username"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
@@ -179,6 +180,7 @@ export const RegisterPage: FC = () => {
|
||||
value={password}
|
||||
placeholder={t("Password")}
|
||||
label={t("Password")}
|
||||
data-testid="register_password"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
@@ -193,6 +195,7 @@ export const RegisterPage: FC = () => {
|
||||
placeholder={t("Confirm password")}
|
||||
label={t("Confirm password")}
|
||||
ref={confirmPasswordRef}
|
||||
data-testid="register_confirm_password"
|
||||
/>
|
||||
</FieldRow>
|
||||
<Caption>
|
||||
@@ -217,7 +220,11 @@ export const RegisterPage: FC = () => {
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow>
|
||||
<Button type="submit" disabled={registering}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={registering}
|
||||
data-testid="register_register"
|
||||
>
|
||||
{registering ? t("Registering…") : t("Register")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface ConfigOptions {
|
||||
submit_url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the URL to send opentelemetry data to. If unset, opentelemetry will
|
||||
* be disabled.
|
||||
*/
|
||||
opentelemetry?: {
|
||||
collector_url: string;
|
||||
};
|
||||
|
||||
// Describes the default homeserver to use. The same format as Element Web
|
||||
// (without identity servers as we don't use them).
|
||||
default_server_config?: {
|
||||
@@ -44,6 +52,14 @@ export interface ConfigOptions {
|
||||
server_name: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Allow to join a group calls without audio and video.
|
||||
* TEMPORARY: Is a feature that's not proved and experimental
|
||||
*/
|
||||
features?: {
|
||||
feature_group_calls_without_video_and_audio: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Overrides members from ConfigOptions that are always provided by the
|
||||
|
||||
@@ -15,14 +15,14 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { FormEventHandler, forwardRef } from "react";
|
||||
import React, { FormEventHandler, forwardRef, ReactNode } from "react";
|
||||
|
||||
import styles from "./Form.module.css";
|
||||
|
||||
interface FormProps {
|
||||
className: string;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
children: JSX.Element[];
|
||||
children: ReactNode[];
|
||||
}
|
||||
|
||||
export const Form = forwardRef<HTMLFormElement, FormProps>(
|
||||
|
||||
@@ -43,7 +43,9 @@ export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) {
|
||||
<p>{t("This call already exists, would you like to join?")}</p>
|
||||
<FieldRow rightAlign className={styles.buttons}>
|
||||
<Button onPress={onClose}>{t("No")}</Button>
|
||||
<Button onPress={onJoin}>{t("Yes, join call")}</Button>
|
||||
<Button onPress={onJoin} data-testid="home_joinExistingRoom">
|
||||
{t("Yes, join call")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -37,3 +37,7 @@ limitations under the License.
|
||||
.recentCallsTitle {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: var(--secondary-content);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,11 @@ import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
|
||||
import {
|
||||
createRoom,
|
||||
roomAliasLocalpartFromRoomName,
|
||||
sanitiseRoomNameInput,
|
||||
} from "../matrix-utils";
|
||||
import { useGroupCallRooms } from "./useGroupCallRooms";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import commonStyles from "./common.module.css";
|
||||
@@ -35,9 +39,11 @@ import { CallList } from "./CallList";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { Title } from "../typography/Typography";
|
||||
import { Caption, Title } from "../typography/Typography";
|
||||
import { Form } from "../form/Form";
|
||||
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
|
||||
import { useOptInAnalytics } from "../settings/useSetting";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -48,6 +54,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
const [callType, setCallType] = useState(CallType.Video);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [optInAnalytics] = useOptInAnalytics();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const { modalState, modalProps } = useModalTriggerState();
|
||||
@@ -57,7 +64,10 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const roomNameData = data.get("callName");
|
||||
const roomName = typeof roomNameData === "string" ? roomNameData : "";
|
||||
const roomName =
|
||||
typeof roomNameData === "string"
|
||||
? sanitiseRoomNameInput(roomNameData)
|
||||
: "";
|
||||
const ptt = callType === CallType.Radio;
|
||||
|
||||
async function submit() {
|
||||
@@ -123,6 +133,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
data-testid="home_callName"
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -130,10 +141,16 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
size="lg"
|
||||
className={styles.button}
|
||||
disabled={loading}
|
||||
data-testid="home_go"
|
||||
>
|
||||
{loading ? t("Loading…") : t("Go")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
{optInAnalytics === null && (
|
||||
<Caption className={styles.notice}>
|
||||
<AnalyticsNotice />
|
||||
</Caption>
|
||||
)}
|
||||
{error && (
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
@@ -45,3 +45,7 @@ limitations under the License.
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: var(--secondary-content);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,11 @@ import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { Button } from "../button";
|
||||
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
|
||||
import {
|
||||
createRoom,
|
||||
roomAliasLocalpartFromRoomName,
|
||||
sanitiseRoomNameInput,
|
||||
} from "../matrix-utils";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
@@ -35,12 +39,15 @@ import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
|
||||
import styles from "./UnauthenticatedView.module.css";
|
||||
import commonStyles from "./common.module.css";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
import { useOptInAnalytics } from "../settings/useSetting";
|
||||
|
||||
export const UnauthenticatedView: FC = () => {
|
||||
const { setClient } = useClient();
|
||||
const [callType, setCallType] = useState(CallType.Video);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [optInAnalytics] = useOptInAnalytics();
|
||||
const [privacyPolicyUrl, recaptchaKey, register] =
|
||||
useInteractiveRegistration();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
@@ -54,7 +61,7 @@ export const UnauthenticatedView: FC = () => {
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const roomName = data.get("callName") as string;
|
||||
const roomName = sanitiseRoomNameInput(data.get("callName") as string);
|
||||
const displayName = data.get("displayName") as string;
|
||||
const ptt = callType === CallType.Radio;
|
||||
|
||||
@@ -135,6 +142,7 @@ export const UnauthenticatedView: FC = () => {
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
data-testid="home_callName"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
@@ -145,10 +153,16 @@ export const UnauthenticatedView: FC = () => {
|
||||
placeholder={t("Display name")}
|
||||
type="text"
|
||||
required
|
||||
data-testid="home_displayName"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldRow>
|
||||
<Caption>
|
||||
{optInAnalytics === null && (
|
||||
<Caption className={styles.notice}>
|
||||
<AnalyticsNotice />
|
||||
</Caption>
|
||||
)}
|
||||
<Caption className={styles.notice}>
|
||||
<Trans>
|
||||
By clicking "Go", you agree to our{" "}
|
||||
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
||||
@@ -159,7 +173,12 @@ export const UnauthenticatedView: FC = () => {
|
||||
<ErrorMessage error={error} />
|
||||
</FieldRow>
|
||||
)}
|
||||
<Button type="submit" size="lg" disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
data-testid="home_go"
|
||||
>
|
||||
{loading ? t("Loading…") : t("Go")}
|
||||
</Button>
|
||||
<div id={recaptchaId} />
|
||||
@@ -167,14 +186,14 @@ export const UnauthenticatedView: FC = () => {
|
||||
</main>
|
||||
<footer className={styles.footer}>
|
||||
<Body className={styles.mobileLoginLink}>
|
||||
<Link color="primary" to="/login">
|
||||
<Link color="primary" to="/login" data-testid="home_login">
|
||||
{t("Login to your account")}
|
||||
</Link>
|
||||
</Body>
|
||||
<Body>
|
||||
<Trans>
|
||||
Not registered yet?{" "}
|
||||
<Link color="primary" to="/register">
|
||||
<Link color="primary" to="/register" data-testid="home_register">
|
||||
Create an account
|
||||
</Link>
|
||||
</Trans>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg data-testid="videoTile_muted" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.20333 0.963373C0.474437 0.690007 0.913989 0.690007 1.1851 0.963373L11.5983 11.4633C11.8694 11.7367 11.8694 12.1799 11.5983 12.4533C11.3272 12.7267 10.8876 12.7267 10.6165 12.4533L0.20333 1.95332C-0.0677768 1.67995 -0.0677768 1.23674 0.20333 0.963373Z" fill="white"/>
|
||||
<path d="M0.418261 3.63429C0.226267 3.95219 0.115674 4.32557 0.115674 4.725V9.85832C0.115674 11.0181 1.0481 11.9583 2.19831 11.9583H8.65411L0.447396 3.66596C0.437225 3.65568 0.427513 3.64511 0.418261 3.63429Z" fill="white"/>
|
||||
<path d="M9.95036 4.725V8.33212L4.30219 2.625H7.86772C9.01793 2.625 9.95036 3.5652 9.95036 4.725Z" fill="white"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 892 B After Width: | Height: | Size: 922 B |
@@ -23,6 +23,7 @@ import * as Sentry from "@sentry/react";
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "./config/Config";
|
||||
import { ElementCallOpenTelemetry } from "./otel/otel";
|
||||
|
||||
enum LoadState {
|
||||
None,
|
||||
@@ -35,6 +36,7 @@ class DependencyLoadStates {
|
||||
// olm: LoadState = LoadState.None;
|
||||
config: LoadState = LoadState.None;
|
||||
sentry: LoadState = LoadState.None;
|
||||
openTelemetry: LoadState = LoadState.None;
|
||||
|
||||
allDepsAreLoaded() {
|
||||
return !Object.values(this).some((s) => s !== LoadState.Loaded);
|
||||
@@ -209,10 +211,19 @@ export class Initializer {
|
||||
this.loadStates.sentry = LoadState.Loaded;
|
||||
}
|
||||
|
||||
// OpenTelemetry (also only after config loaded)
|
||||
if (
|
||||
this.loadStates.openTelemetry === LoadState.None &&
|
||||
this.loadStates.config === LoadState.Loaded
|
||||
) {
|
||||
ElementCallOpenTelemetry.globalInit();
|
||||
this.loadStates.openTelemetry = LoadState.Loaded;
|
||||
}
|
||||
|
||||
if (this.loadStates.allDepsAreLoaded()) {
|
||||
// resolve if there is no dependency that is not loaded
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
private initPromise: Promise<void>;
|
||||
private initPromise: Promise<void> | null;
|
||||
}
|
||||
|
||||
@@ -209,3 +209,7 @@ limitations under the License.
|
||||
margin-left: 26px;
|
||||
width: 100%; /* Ensure that it breaks onto the next row */
|
||||
}
|
||||
|
||||
.description.noLabel {
|
||||
margin-top: -20px; /* Ensures that there is no weired spacing if the checkbox doesn't have a label */
|
||||
}
|
||||
|
||||
@@ -55,14 +55,14 @@ export function Field({ children, className }: FieldProps): JSX.Element {
|
||||
}
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string;
|
||||
label?: string;
|
||||
type: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
id?: string;
|
||||
checked?: boolean;
|
||||
className?: string;
|
||||
description?: string;
|
||||
description?: string | ReactNode;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
// this is a hack. Those variables should be part of `HTMLAttributes<HTMLInputElement> | HTMLAttributes<HTMLTextAreaElement>`
|
||||
@@ -140,7 +140,14 @@ export const InputField = forwardRef<
|
||||
</label>
|
||||
{suffix && <span>{suffix}</span>}
|
||||
{description && (
|
||||
<p id={descriptionId} className={styles.description}>
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={
|
||||
label
|
||||
? styles.description
|
||||
: classNames(styles.description, styles.noLabel)
|
||||
}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -92,17 +92,26 @@ export async function initClient(
|
||||
indexedDB = window.indexedDB;
|
||||
} catch (e) {}
|
||||
|
||||
const storeOpts = {} as ICreateClientOpts;
|
||||
const baseOpts = {
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
isVoipWithNoMediaAllowed:
|
||||
Config.get().features?.feature_group_calls_without_video_and_audio,
|
||||
} as ICreateClientOpts;
|
||||
|
||||
if (indexedDB && localStorage && !import.meta.env.DEV) {
|
||||
storeOpts.store = new IndexedDBStore({
|
||||
if (indexedDB && localStorage) {
|
||||
baseOpts.store = new IndexedDBStore({
|
||||
indexedDB: window.indexedDB,
|
||||
localStorage,
|
||||
dbName: SYNC_STORE_NAME,
|
||||
workerFactory: () => new IndexedDBWorker(),
|
||||
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
|
||||
// in dev mode: it expects them to use native modules. Ours don't, and even then only
|
||||
// Chrome supports it. (It bundles them fine in production mode.)
|
||||
workerFactory: import.meta.env.DEV
|
||||
? undefined
|
||||
: () => new IndexedDBWorker(),
|
||||
});
|
||||
} else if (localStorage) {
|
||||
storeOpts.store = new MemoryStore({ localStorage });
|
||||
baseOpts.store = new MemoryStore({ localStorage });
|
||||
}
|
||||
|
||||
// Check whether we have crypto data store. If we are restoring a session
|
||||
@@ -134,14 +143,14 @@ export async function initClient(
|
||||
}
|
||||
|
||||
if (indexedDB) {
|
||||
storeOpts.cryptoStore = new IndexedDBCryptoStore(
|
||||
baseOpts.cryptoStore = new IndexedDBCryptoStore(
|
||||
indexedDB,
|
||||
CRYPTO_STORE_NAME
|
||||
);
|
||||
} else if (localStorage) {
|
||||
storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
|
||||
baseOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
|
||||
} else {
|
||||
storeOpts.cryptoStore = new MemoryCryptoStore();
|
||||
baseOpts.cryptoStore = new MemoryCryptoStore();
|
||||
}
|
||||
|
||||
// XXX: we read from the URL params in RoomPage too:
|
||||
@@ -155,7 +164,7 @@ export async function initClient(
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
...storeOpts,
|
||||
...baseOpts,
|
||||
...clientOptions,
|
||||
useAuthorizationHeader: true,
|
||||
// Use a relatively low timeout for API calls: this is a realtime app
|
||||
@@ -206,6 +215,37 @@ export function fullAliasFromRoomName(
|
||||
return `#${roomAliasLocalpartFromRoomName(roomName)}:${client.getDomain()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies some basic sanitisation to a room name that the user
|
||||
* has given us
|
||||
* @param input The room name from the user
|
||||
* @param client A matrix client object
|
||||
*/
|
||||
export function sanitiseRoomNameInput(input: string): string {
|
||||
// check to see if the user has enetered a fully qualified room
|
||||
// alias. If so, turn it into just the localpart because that's what
|
||||
// we use
|
||||
const parts = input.split(":", 2);
|
||||
if (parts.length === 2 && parts[0][0] === "#") {
|
||||
// looks like a room alias
|
||||
if (parts[1] === Config.defaultServerName()) {
|
||||
// it's local to our own homeserver
|
||||
return parts[0];
|
||||
} else {
|
||||
throw new Error("Unsupported remote room alias");
|
||||
}
|
||||
}
|
||||
|
||||
// that's all we do here right now
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* XXX: What is this trying to do? It looks like it's getting the localpart from
|
||||
* a room alias, but why is it splitting on hyphens and then putting spaces in??
|
||||
* @param roomId
|
||||
* @returns
|
||||
*/
|
||||
export function roomNameFromRoomId(roomId: string): string {
|
||||
return roomId
|
||||
.match(/([^:]+):.*$/)[1]
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function findDeviceByName(
|
||||
*
|
||||
* @return The available media devices
|
||||
*/
|
||||
export async function getDevices(): Promise<MediaDeviceInfo[]> {
|
||||
export async function getNamedDevices(): Promise<MediaDeviceInfo[]> {
|
||||
// First get the devices without their labels, to learn what kinds of streams
|
||||
// we can request
|
||||
let devices: MediaDeviceInfo[];
|
||||
|
||||
119
src/otel/OTelCall.ts
Normal file
119
src/otel/OTelCall.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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 { Span } from "@opentelemetry/api";
|
||||
import { MatrixCall } from "matrix-js-sdk";
|
||||
import { CallEvent } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import { ObjectFlattener } from "./ObjectFlattener";
|
||||
|
||||
/**
|
||||
* Tracks an individual call within a group call, either to a full-mesh peer or a focus
|
||||
*/
|
||||
export class OTelCall {
|
||||
constructor(
|
||||
public userId: string,
|
||||
public deviceId: string,
|
||||
public call: MatrixCall,
|
||||
public span: Span
|
||||
) {
|
||||
if (call.peerConn) {
|
||||
this.addCallPeerConnListeners();
|
||||
} else {
|
||||
this.call.once(
|
||||
CallEvent.PeerConnectionCreated,
|
||||
this.addCallPeerConnListeners
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.call.peerConn.removeEventListener(
|
||||
"connectionstatechange",
|
||||
this.onCallConnectionStateChanged
|
||||
);
|
||||
this.call.peerConn.removeEventListener(
|
||||
"signalingstatechange",
|
||||
this.onCallSignalingStateChanged
|
||||
);
|
||||
this.call.peerConn.removeEventListener(
|
||||
"iceconnectionstatechange",
|
||||
this.onIceConnectionStateChanged
|
||||
);
|
||||
this.call.peerConn.removeEventListener(
|
||||
"icegatheringstatechange",
|
||||
this.onIceGatheringStateChanged
|
||||
);
|
||||
this.call.peerConn.removeEventListener(
|
||||
"icecandidateerror",
|
||||
this.onIceCandidateError
|
||||
);
|
||||
}
|
||||
|
||||
private addCallPeerConnListeners = (): void => {
|
||||
this.call.peerConn.addEventListener(
|
||||
"connectionstatechange",
|
||||
this.onCallConnectionStateChanged
|
||||
);
|
||||
this.call.peerConn.addEventListener(
|
||||
"signalingstatechange",
|
||||
this.onCallSignalingStateChanged
|
||||
);
|
||||
this.call.peerConn.addEventListener(
|
||||
"iceconnectionstatechange",
|
||||
this.onIceConnectionStateChanged
|
||||
);
|
||||
this.call.peerConn.addEventListener(
|
||||
"icegatheringstatechange",
|
||||
this.onIceGatheringStateChanged
|
||||
);
|
||||
this.call.peerConn.addEventListener(
|
||||
"icecandidateerror",
|
||||
this.onIceCandidateError
|
||||
);
|
||||
};
|
||||
|
||||
public onCallConnectionStateChanged = (): void => {
|
||||
this.span.addEvent("matrix.call.callConnectionStateChange", {
|
||||
callConnectionState: this.call.peerConn.connectionState,
|
||||
});
|
||||
};
|
||||
|
||||
public onCallSignalingStateChanged = (): void => {
|
||||
this.span.addEvent("matrix.call.callSignalingStateChange", {
|
||||
callSignalingState: this.call.peerConn.signalingState,
|
||||
});
|
||||
};
|
||||
|
||||
public onIceConnectionStateChanged = (): void => {
|
||||
this.span.addEvent("matrix.call.iceConnectionStateChange", {
|
||||
iceConnectionState: this.call.peerConn.iceConnectionState,
|
||||
});
|
||||
};
|
||||
|
||||
public onIceGatheringStateChanged = (): void => {
|
||||
this.span.addEvent("matrix.call.iceGatheringStateChange", {
|
||||
iceGatheringState: this.call.peerConn.iceGatheringState,
|
||||
});
|
||||
};
|
||||
|
||||
public onIceCandidateError = (ev: Event): void => {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(ev, flatObject, "error.", 0);
|
||||
|
||||
this.span.addEvent("matrix.call.iceCandidateError", flatObject);
|
||||
};
|
||||
}
|
||||
434
src/otel/OTelGroupCallMembership.ts
Normal file
434
src/otel/OTelGroupCallMembership.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
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 opentelemetry, { Span, Attributes, Context } from "@opentelemetry/api";
|
||||
import {
|
||||
GroupCall,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
RoomMember,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
CallError,
|
||||
CallState,
|
||||
MatrixCall,
|
||||
VoipEvent,
|
||||
} from "matrix-js-sdk/src/webrtc/call";
|
||||
import {
|
||||
CallsByUserAndDevice,
|
||||
GroupCallError,
|
||||
GroupCallEvent,
|
||||
GroupCallStatsReport,
|
||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import {
|
||||
ConnectionStatsReport,
|
||||
ByteSentStatsReport,
|
||||
SummaryStatsReport,
|
||||
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
|
||||
import { setSpan } from "@opentelemetry/api/build/esm/trace/context-utils";
|
||||
|
||||
import { ElementCallOpenTelemetry } from "./otel";
|
||||
import { ObjectFlattener } from "./ObjectFlattener";
|
||||
import { OTelCall } from "./OTelCall";
|
||||
|
||||
/**
|
||||
* Represent the span of time which we intend to be joined to a group call
|
||||
*/
|
||||
export class OTelGroupCallMembership {
|
||||
private callMembershipSpan?: Span;
|
||||
private groupCallContext?: Context;
|
||||
private myUserId = "unknown";
|
||||
private myDeviceId: string;
|
||||
private myMember?: RoomMember;
|
||||
private callsByCallId = new Map<string, OTelCall>();
|
||||
private statsReportSpan: {
|
||||
span: Span | undefined;
|
||||
stats: OTelStatsReportEvent[];
|
||||
};
|
||||
private readonly speakingSpans = new Map<RoomMember, Map<string, Span>>();
|
||||
|
||||
constructor(private groupCall: GroupCall, client: MatrixClient) {
|
||||
const clientId = client.getUserId();
|
||||
if (clientId) {
|
||||
this.myUserId = clientId;
|
||||
const myMember = groupCall.room.getMember(clientId);
|
||||
if (myMember) {
|
||||
this.myMember = myMember;
|
||||
}
|
||||
}
|
||||
this.myDeviceId = client.getDeviceId() || "unknown";
|
||||
this.statsReportSpan = { span: undefined, stats: [] };
|
||||
this.groupCall.on(GroupCallEvent.CallsChanged, this.onCallsChanged);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.groupCall.removeListener(
|
||||
GroupCallEvent.CallsChanged,
|
||||
this.onCallsChanged
|
||||
);
|
||||
}
|
||||
|
||||
public onJoinCall() {
|
||||
if (!ElementCallOpenTelemetry.instance) return;
|
||||
if (this.callMembershipSpan !== undefined) {
|
||||
logger.warn("Call membership span is already started");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the main span that tracks the time we intend to be in the call
|
||||
this.callMembershipSpan =
|
||||
ElementCallOpenTelemetry.instance.tracer.startSpan(
|
||||
"matrix.groupCallMembership"
|
||||
);
|
||||
this.callMembershipSpan.setAttribute(
|
||||
"matrix.confId",
|
||||
this.groupCall.groupCallId
|
||||
);
|
||||
this.callMembershipSpan.setAttribute("matrix.userId", this.myUserId);
|
||||
this.callMembershipSpan.setAttribute("matrix.deviceId", this.myDeviceId);
|
||||
this.callMembershipSpan.setAttribute(
|
||||
"matrix.displayName",
|
||||
this.myMember ? this.myMember.name : "unknown-name"
|
||||
);
|
||||
|
||||
this.groupCallContext = opentelemetry.trace.setSpan(
|
||||
opentelemetry.context.active(),
|
||||
this.callMembershipSpan
|
||||
);
|
||||
|
||||
this.callMembershipSpan?.addEvent("matrix.joinCall");
|
||||
}
|
||||
|
||||
public onLeaveCall() {
|
||||
if (this.callMembershipSpan === undefined) {
|
||||
logger.warn("Call membership span is already ended");
|
||||
return;
|
||||
}
|
||||
|
||||
this.callMembershipSpan.addEvent("matrix.leaveCall");
|
||||
// and end the span to indicate we've left
|
||||
this.callMembershipSpan.end();
|
||||
this.callMembershipSpan = undefined;
|
||||
this.groupCallContext = undefined;
|
||||
}
|
||||
|
||||
public onUpdateRoomState(event: MatrixEvent) {
|
||||
if (
|
||||
!event ||
|
||||
(!event.getType().startsWith("m.call") &&
|
||||
!event.getType().startsWith("org.matrix.msc3401.call"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.callMembershipSpan?.addEvent(
|
||||
`matrix.roomStateEvent_${event.getType()}`,
|
||||
ObjectFlattener.flattenVoipEvent(event.getContent())
|
||||
);
|
||||
}
|
||||
|
||||
public onCallsChanged = (calls: CallsByUserAndDevice) => {
|
||||
for (const [userId, userCalls] of calls.entries()) {
|
||||
for (const [deviceId, call] of userCalls.entries()) {
|
||||
if (!this.callsByCallId.has(call.callId)) {
|
||||
if (ElementCallOpenTelemetry.instance) {
|
||||
const span = ElementCallOpenTelemetry.instance.tracer.startSpan(
|
||||
`matrix.call`,
|
||||
undefined,
|
||||
this.groupCallContext
|
||||
);
|
||||
// XXX: anonymity
|
||||
span.setAttribute("matrix.call.target.userId", userId);
|
||||
span.setAttribute("matrix.call.target.deviceId", deviceId);
|
||||
const displayName =
|
||||
this.groupCall.room.getMember(userId)?.name ?? "unknown";
|
||||
span.setAttribute("matrix.call.target.displayName", displayName);
|
||||
this.callsByCallId.set(
|
||||
call.callId,
|
||||
new OTelCall(userId, deviceId, call, span)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const callTrackingInfo of this.callsByCallId.values()) {
|
||||
const userCalls = calls.get(callTrackingInfo.userId);
|
||||
if (
|
||||
!userCalls ||
|
||||
!userCalls.has(callTrackingInfo.deviceId) ||
|
||||
userCalls.get(callTrackingInfo.deviceId).callId !==
|
||||
callTrackingInfo.call.callId
|
||||
) {
|
||||
callTrackingInfo.span.end();
|
||||
this.callsByCallId.delete(callTrackingInfo.call.callId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onCallStateChange(call: MatrixCall, newState: CallState) {
|
||||
const callTrackingInfo = this.callsByCallId.get(call.callId);
|
||||
if (!callTrackingInfo) {
|
||||
logger.error(`Got call state change for unknown call ID ${call.callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
callTrackingInfo.span.addEvent("matrix.call.stateChange", {
|
||||
state: newState,
|
||||
});
|
||||
}
|
||||
|
||||
public onSendEvent(call: MatrixCall, event: VoipEvent) {
|
||||
const eventType = event.eventType as string;
|
||||
if (
|
||||
!eventType.startsWith("m.call") &&
|
||||
!eventType.startsWith("org.matrix.call")
|
||||
)
|
||||
return;
|
||||
|
||||
const callTrackingInfo = this.callsByCallId.get(call.callId);
|
||||
if (!callTrackingInfo) {
|
||||
logger.error(`Got call send event for unknown call ID ${call.callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "toDevice") {
|
||||
callTrackingInfo.span.addEvent(
|
||||
`matrix.sendToDeviceEvent_${event.eventType}`,
|
||||
ObjectFlattener.flattenVoipEvent(event)
|
||||
);
|
||||
} else if (event.type === "sendEvent") {
|
||||
callTrackingInfo.span.addEvent(
|
||||
`matrix.sendToRoomEvent_${event.eventType}`,
|
||||
ObjectFlattener.flattenVoipEvent(event)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public onReceivedVoipEvent(event: MatrixEvent) {
|
||||
// These come straight from CallEventHandler so don't have
|
||||
// a call already associated (in principle we could receive
|
||||
// events for calls we don't know about).
|
||||
const callId = event.getContent().call_id;
|
||||
if (!callId) {
|
||||
this.callMembershipSpan?.addEvent("matrix.receive_voip_event_no_callid", {
|
||||
"sender.userId": event.getSender(),
|
||||
});
|
||||
logger.error("Received call event with no call ID!");
|
||||
return;
|
||||
}
|
||||
|
||||
const call = this.callsByCallId.get(callId);
|
||||
if (!call) {
|
||||
this.callMembershipSpan?.addEvent(
|
||||
"matrix.receive_voip_event_unknown_callid",
|
||||
{
|
||||
"sender.userId": event.getSender(),
|
||||
}
|
||||
);
|
||||
logger.error("Received call event for unknown call ID " + callId);
|
||||
return;
|
||||
}
|
||||
|
||||
call.span.addEvent("matrix.receive_voip_event", {
|
||||
"sender.userId": event.getSender(),
|
||||
...ObjectFlattener.flattenVoipEvent(event.getContent()),
|
||||
});
|
||||
}
|
||||
|
||||
public onToggleMicrophoneMuted(newValue: boolean) {
|
||||
this.callMembershipSpan?.addEvent("matrix.toggleMicMuted", {
|
||||
"matrix.microphone.muted": newValue,
|
||||
});
|
||||
}
|
||||
|
||||
public onSetMicrophoneMuted(setMuted: boolean) {
|
||||
this.callMembershipSpan?.addEvent("matrix.setMicMuted", {
|
||||
"matrix.microphone.muted": setMuted,
|
||||
});
|
||||
}
|
||||
|
||||
public onToggleLocalVideoMuted(newValue: boolean) {
|
||||
this.callMembershipSpan?.addEvent("matrix.toggleVidMuted", {
|
||||
"matrix.video.muted": newValue,
|
||||
});
|
||||
}
|
||||
|
||||
public onSetLocalVideoMuted(setMuted: boolean) {
|
||||
this.callMembershipSpan?.addEvent("matrix.setVidMuted", {
|
||||
"matrix.video.muted": setMuted,
|
||||
});
|
||||
}
|
||||
|
||||
public onToggleScreensharing(newValue: boolean) {
|
||||
this.callMembershipSpan?.addEvent("matrix.setVidMuted", {
|
||||
"matrix.screensharing.enabled": newValue,
|
||||
});
|
||||
}
|
||||
|
||||
public onSpeaking(member: RoomMember, deviceId: string, speaking: boolean) {
|
||||
if (speaking) {
|
||||
// Ensure that there's an audio activity span for this speaker
|
||||
let deviceMap = this.speakingSpans.get(member);
|
||||
if (deviceMap === undefined) {
|
||||
deviceMap = new Map();
|
||||
this.speakingSpans.set(member, deviceMap);
|
||||
}
|
||||
|
||||
if (!deviceMap.has(deviceId)) {
|
||||
const span = ElementCallOpenTelemetry.instance.tracer.startSpan(
|
||||
"matrix.audioActivity",
|
||||
undefined,
|
||||
this.groupCallContext
|
||||
);
|
||||
span.setAttribute("matrix.userId", member.userId);
|
||||
span.setAttribute("matrix.displayName", member.rawDisplayName);
|
||||
|
||||
deviceMap.set(deviceId, span);
|
||||
}
|
||||
} else {
|
||||
// End the audio activity span for this speaker, if any
|
||||
const deviceMap = this.speakingSpans.get(member);
|
||||
deviceMap?.get(deviceId)?.end();
|
||||
deviceMap?.delete(deviceId);
|
||||
|
||||
if (deviceMap?.size === 0) this.speakingSpans.delete(member);
|
||||
}
|
||||
}
|
||||
|
||||
public onCallError(error: CallError, call: MatrixCall) {
|
||||
const callTrackingInfo = this.callsByCallId.get(call.callId);
|
||||
if (!callTrackingInfo) {
|
||||
logger.error(`Got error for unknown call ID ${call.callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
callTrackingInfo.span.recordException(error);
|
||||
}
|
||||
|
||||
public onGroupCallError(error: GroupCallError) {
|
||||
this.callMembershipSpan?.recordException(error);
|
||||
}
|
||||
|
||||
public onUndecryptableToDevice(event: MatrixEvent) {
|
||||
this.callMembershipSpan?.addEvent("matrix.toDevice.undecryptable", {
|
||||
"sender.userId": event.getSender(),
|
||||
});
|
||||
}
|
||||
|
||||
public onConnectionStatsReport(
|
||||
statsReport: GroupCallStatsReport<ConnectionStatsReport>
|
||||
) {
|
||||
if (!ElementCallOpenTelemetry.instance) return;
|
||||
|
||||
const type = OTelStatsReportType.ConnectionReport;
|
||||
const data =
|
||||
ObjectFlattener.flattenConnectionStatsReportObject(statsReport);
|
||||
this.buildStatsEventSpan({ type, data });
|
||||
}
|
||||
|
||||
public onByteSentStatsReport(
|
||||
statsReport: GroupCallStatsReport<ByteSentStatsReport>
|
||||
) {
|
||||
if (!ElementCallOpenTelemetry.instance) return;
|
||||
|
||||
const type = OTelStatsReportType.ByteSentReport;
|
||||
const data = ObjectFlattener.flattenByteSentStatsReportObject(statsReport);
|
||||
this.buildStatsEventSpan({ type, data });
|
||||
}
|
||||
|
||||
public onSummaryStatsReport(
|
||||
statsReport: GroupCallStatsReport<SummaryStatsReport>
|
||||
) {
|
||||
if (!ElementCallOpenTelemetry.instance) return;
|
||||
|
||||
const type = OTelStatsReportType.SummaryReport;
|
||||
const data = ObjectFlattener.flattenSummaryStatsReportObject(statsReport);
|
||||
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
|
||||
const ctx = setSpan(
|
||||
opentelemetry.context.active(),
|
||||
this.callMembershipSpan
|
||||
);
|
||||
const span = ElementCallOpenTelemetry.instance?.tracer.startSpan(
|
||||
"matrix.groupCallMembership.summaryReport",
|
||||
undefined,
|
||||
ctx
|
||||
);
|
||||
if (span === undefined) {
|
||||
return;
|
||||
}
|
||||
span.setAttribute("matrix.confId", this.groupCall.groupCallId);
|
||||
span.setAttribute("matrix.userId", this.myUserId);
|
||||
span.setAttribute(
|
||||
"matrix.displayName",
|
||||
this.myMember ? this.myMember.name : "unknown-name"
|
||||
);
|
||||
span.addEvent(type, data);
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
private buildStatsEventSpan(event: OTelStatsReportEvent): void {
|
||||
// @ TODO: fix this - Because on multiple calls we receive multiple stats report spans.
|
||||
// This could be break if stats arrived in same time from different call objects.
|
||||
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
|
||||
const ctx = setSpan(
|
||||
opentelemetry.context.active(),
|
||||
this.callMembershipSpan
|
||||
);
|
||||
this.statsReportSpan.span =
|
||||
ElementCallOpenTelemetry.instance?.tracer.startSpan(
|
||||
"matrix.groupCallMembership.statsReport",
|
||||
undefined,
|
||||
ctx
|
||||
);
|
||||
if (this.statsReportSpan.span === undefined) {
|
||||
return;
|
||||
}
|
||||
this.statsReportSpan.span.setAttribute(
|
||||
"matrix.confId",
|
||||
this.groupCall.groupCallId
|
||||
);
|
||||
this.statsReportSpan.span.setAttribute("matrix.userId", this.myUserId);
|
||||
this.statsReportSpan.span.setAttribute(
|
||||
"matrix.displayName",
|
||||
this.myMember ? this.myMember.name : "unknown-name"
|
||||
);
|
||||
|
||||
this.statsReportSpan.span.addEvent(event.type, event.data);
|
||||
this.statsReportSpan.stats.push(event);
|
||||
} else if (
|
||||
this.statsReportSpan.span !== undefined &&
|
||||
this.callMembershipSpan
|
||||
) {
|
||||
this.statsReportSpan.span.addEvent(event.type, event.data);
|
||||
this.statsReportSpan.span.end();
|
||||
this.statsReportSpan = { span: undefined, stats: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface OTelStatsReportEvent {
|
||||
type: OTelStatsReportType;
|
||||
data: Attributes;
|
||||
}
|
||||
|
||||
enum OTelStatsReportType {
|
||||
ConnectionReport = "matrix.stats.connection",
|
||||
ByteSentReport = "matrix.stats.byteSent",
|
||||
SummaryReport = "matrix.stats.summary",
|
||||
}
|
||||
113
src/otel/ObjectFlattener.ts
Normal file
113
src/otel/ObjectFlattener.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
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 { Attributes } from "@opentelemetry/api";
|
||||
import { VoipEvent } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { GroupCallStatsReport } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import {
|
||||
ByteSentStatsReport,
|
||||
ConnectionStatsReport,
|
||||
SummaryStatsReport,
|
||||
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
|
||||
|
||||
export class ObjectFlattener {
|
||||
public static flattenConnectionStatsReportObject(
|
||||
statsReport: GroupCallStatsReport<ConnectionStatsReport>
|
||||
): Attributes {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
statsReport.report,
|
||||
flatObject,
|
||||
"matrix.stats.conn.",
|
||||
0
|
||||
);
|
||||
return flatObject;
|
||||
}
|
||||
|
||||
public static flattenByteSentStatsReportObject(
|
||||
statsReport: GroupCallStatsReport<ByteSentStatsReport>
|
||||
): Attributes {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
statsReport.report,
|
||||
flatObject,
|
||||
"matrix.stats.bytesSent.",
|
||||
0
|
||||
);
|
||||
return flatObject;
|
||||
}
|
||||
|
||||
static flattenSummaryStatsReportObject(
|
||||
statsReport: GroupCallStatsReport<SummaryStatsReport>
|
||||
) {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
statsReport.report,
|
||||
flatObject,
|
||||
"matrix.stats.summary.",
|
||||
0
|
||||
);
|
||||
return flatObject;
|
||||
}
|
||||
|
||||
/* Flattens out an object into a single layer with components
|
||||
* of the key separated by dots
|
||||
*/
|
||||
public static flattenVoipEvent(event: VoipEvent): Attributes {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
event as unknown as Record<string, unknown>, // XXX Types
|
||||
flatObject,
|
||||
"matrix.event.",
|
||||
0
|
||||
);
|
||||
|
||||
return flatObject;
|
||||
}
|
||||
|
||||
public static flattenObjectRecursive(
|
||||
obj: Object,
|
||||
flatObject: Attributes,
|
||||
prefix: string,
|
||||
depth: number
|
||||
): void {
|
||||
if (depth > 10)
|
||||
throw new Error(
|
||||
"Depth limit exceeded: aborting VoipEvent recursion. Prefix is " +
|
||||
prefix
|
||||
);
|
||||
let entries;
|
||||
if (obj instanceof Map) {
|
||||
entries = obj.entries();
|
||||
} else {
|
||||
entries = Object.entries(obj);
|
||||
}
|
||||
for (const [k, v] of entries) {
|
||||
if (["string", "number", "boolean"].includes(typeof v) || v === null) {
|
||||
let value;
|
||||
value = v === null ? "null" : v;
|
||||
value = typeof v === "number" && Number.isNaN(v) ? "NaN" : value;
|
||||
flatObject[prefix + k] = value;
|
||||
} else if (typeof v === "object") {
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
v,
|
||||
flatObject,
|
||||
prefix + k + ".",
|
||||
depth + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/otel/otel.ts
Normal file
122
src/otel/otel.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
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 { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
|
||||
import opentelemetry, { Tracer } from "@opentelemetry/api";
|
||||
import { Resource } from "@opentelemetry/resources";
|
||||
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { PosthogSpanProcessor } from "../analytics/PosthogSpanProcessor";
|
||||
import { Anonymity } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
import { RageshakeSpanProcessor } from "../analytics/RageshakeSpanProcessor";
|
||||
|
||||
const SERVICE_NAME = "element-call";
|
||||
|
||||
let sharedInstance: ElementCallOpenTelemetry;
|
||||
|
||||
export class ElementCallOpenTelemetry {
|
||||
private _provider: WebTracerProvider;
|
||||
private _tracer: Tracer;
|
||||
private _anonymity: Anonymity;
|
||||
private otlpExporter: OTLPTraceExporter;
|
||||
public readonly rageshakeProcessor?: RageshakeSpanProcessor;
|
||||
|
||||
static globalInit(): void {
|
||||
const config = Config.get();
|
||||
// we always enable opentelemetry in general. We only enable the OTLP
|
||||
// collector if a URL is defined (and in future if another setting is defined)
|
||||
// Posthog reporting is enabled or disabled
|
||||
// within the posthog code.
|
||||
const shouldEnableOtlp = Boolean(config.opentelemetry?.collector_url);
|
||||
|
||||
if (!sharedInstance || sharedInstance.isOtlpEnabled !== shouldEnableOtlp) {
|
||||
logger.info("(Re)starting OpenTelemetry debug reporting");
|
||||
sharedInstance?.dispose();
|
||||
|
||||
sharedInstance = new ElementCallOpenTelemetry(
|
||||
config.opentelemetry?.collector_url,
|
||||
config.rageshake?.submit_url
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static get instance(): ElementCallOpenTelemetry {
|
||||
return sharedInstance;
|
||||
}
|
||||
|
||||
constructor(
|
||||
collectorUrl: string | undefined,
|
||||
rageshakeUrl: string | undefined
|
||||
) {
|
||||
// This is how we can make Jaeger show a reasonable service in the dropdown on the left.
|
||||
const providerConfig = {
|
||||
resource: new Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME,
|
||||
}),
|
||||
};
|
||||
this._provider = new WebTracerProvider(providerConfig);
|
||||
|
||||
if (collectorUrl) {
|
||||
logger.info("Enabling OTLP collector with URL " + collectorUrl);
|
||||
this.otlpExporter = new OTLPTraceExporter({
|
||||
url: collectorUrl,
|
||||
});
|
||||
this._provider.addSpanProcessor(
|
||||
new SimpleSpanProcessor(this.otlpExporter)
|
||||
);
|
||||
} else {
|
||||
logger.info("OTLP collector disabled");
|
||||
}
|
||||
|
||||
if (rageshakeUrl) {
|
||||
this.rageshakeProcessor = new RageshakeSpanProcessor();
|
||||
this._provider.addSpanProcessor(this.rageshakeProcessor);
|
||||
}
|
||||
|
||||
this._provider.addSpanProcessor(new PosthogSpanProcessor());
|
||||
opentelemetry.trace.setGlobalTracerProvider(this._provider);
|
||||
|
||||
this._tracer = opentelemetry.trace.getTracer(
|
||||
// This is not the serviceName shown in jaeger
|
||||
"my-element-call-otl-tracer"
|
||||
);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
opentelemetry.trace.setGlobalTracerProvider(null);
|
||||
this._provider?.shutdown();
|
||||
}
|
||||
|
||||
public get isOtlpEnabled(): boolean {
|
||||
return Boolean(this.otlpExporter);
|
||||
}
|
||||
|
||||
public get tracer(): Tracer {
|
||||
return this._tracer;
|
||||
}
|
||||
|
||||
public get provider(): WebTracerProvider {
|
||||
return this._provider;
|
||||
}
|
||||
|
||||
public get anonymity(): Anonymity {
|
||||
return this._anonymity;
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ export function ProfileModal({ client, ...rest }: Props) {
|
||||
placeholder={t("Display name")}
|
||||
value={displayName}
|
||||
onChange={onChangeDisplayName}
|
||||
data-testid="profile_displayname"
|
||||
/>
|
||||
</FieldRow>
|
||||
{error && (
|
||||
@@ -130,7 +131,11 @@ export function ProfileModal({ client, ...rest }: Props) {
|
||||
<Button type="button" variant="secondary" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
data-testid="profile_submit"
|
||||
>
|
||||
{loading ? t("Saving…") : t("Save")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Resizable } from "re-resizable";
|
||||
import React, {
|
||||
useEffect,
|
||||
@@ -27,13 +28,25 @@ import ReactJson, { CollapsedFieldProps } from "react-json-view";
|
||||
import mermaid from "mermaid";
|
||||
import { Item } from "@react-stately/collections";
|
||||
import { MatrixEvent, IContent } from "matrix-js-sdk/src/models/event";
|
||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import {
|
||||
GroupCall,
|
||||
GroupCallError,
|
||||
GroupCallEvent,
|
||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
||||
import { CallEvent } from "matrix-js-sdk/src/webrtc/call";
|
||||
import {
|
||||
CallEvent,
|
||||
CallState,
|
||||
CallError,
|
||||
MatrixCall,
|
||||
VoipEvent,
|
||||
} from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import styles from "./GroupCallInspector.module.css";
|
||||
import { SelectInput } from "../input/SelectInput";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
|
||||
interface InspectorContextState {
|
||||
eventsByUserId?: { [userId: string]: SequenceDiagramMatrixEvent[] };
|
||||
@@ -108,6 +121,19 @@ function formatTimestamp(timestamp: number | Date) {
|
||||
return dateFormatter.format(timestamp);
|
||||
}
|
||||
|
||||
function formatType(event: SequenceDiagramMatrixEvent): string {
|
||||
if (event.content.msgtype === "m.bad.encrypted") return "Undecryptable";
|
||||
return event.type;
|
||||
}
|
||||
|
||||
function lineForEvent(event: SequenceDiagramMatrixEvent): string {
|
||||
return `${getUserName(event.from)} ${
|
||||
event.ignored ? "-x" : "->>"
|
||||
} ${getUserName(event.to)}: ${formatTimestamp(event.timestamp)} ${formatType(
|
||||
event
|
||||
)} ${formatContent(event.type, event.content)}`;
|
||||
}
|
||||
|
||||
export const InspectorContext =
|
||||
createContext<
|
||||
[
|
||||
@@ -187,21 +213,7 @@ export function SequenceDiagramViewer({
|
||||
participant ${getUserName(localUserId)}
|
||||
participant Room
|
||||
participant ${selectedUserId ? getUserName(selectedUserId) : "unknown"}
|
||||
${
|
||||
events
|
||||
? events
|
||||
.map(
|
||||
({ to, from, timestamp, type, content, ignored }) =>
|
||||
`${getUserName(from)} ${ignored ? "-x" : "->>"} ${getUserName(
|
||||
to
|
||||
)}: ${formatTimestamp(timestamp)} ${type} ${formatContent(
|
||||
type,
|
||||
content
|
||||
)}`
|
||||
)
|
||||
.join("\n ")
|
||||
: ""
|
||||
}
|
||||
${events ? events.map(lineForEvent).join("\n ") : ""}
|
||||
`;
|
||||
|
||||
mermaid.mermaidAPI.render("mermaid", graphDefinition, (svgCode: string) => {
|
||||
@@ -234,7 +246,7 @@ function reducer(
|
||||
action: {
|
||||
type?: CallEvent | ClientEvent | RoomStateEvent;
|
||||
event?: MatrixEvent;
|
||||
rawEvent?: Record<string, unknown>;
|
||||
rawEvent?: VoipEvent;
|
||||
callStateEvent?: MatrixEvent;
|
||||
memberStateEvents?: MatrixEvent[];
|
||||
}
|
||||
@@ -352,7 +364,7 @@ function reducer(
|
||||
function useGroupCallState(
|
||||
client: MatrixClient,
|
||||
groupCall: GroupCall,
|
||||
showPollCallStats: boolean
|
||||
otelGroupCallMembership: OTelGroupCallMembership
|
||||
): InspectorContextState {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
localUserId: client.getUserId(),
|
||||
@@ -380,33 +392,77 @@ function useGroupCallState(
|
||||
callStateEvent,
|
||||
memberStateEvents,
|
||||
});
|
||||
|
||||
otelGroupCallMembership?.onUpdateRoomState(event);
|
||||
}
|
||||
|
||||
function onReceivedVoipEvent(event: MatrixEvent) {
|
||||
dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
|
||||
|
||||
otelGroupCallMembership?.onReceivedVoipEvent(event);
|
||||
}
|
||||
|
||||
function onSendVoipEvent(event: Record<string, unknown>) {
|
||||
function onSendVoipEvent(event: VoipEvent, call: MatrixCall) {
|
||||
dispatch({ type: CallEvent.SendVoipEvent, rawEvent: event });
|
||||
|
||||
otelGroupCallMembership?.onSendEvent(call, event);
|
||||
}
|
||||
|
||||
function onCallStateChange(
|
||||
newState: CallState,
|
||||
_: CallState,
|
||||
call: MatrixCall
|
||||
) {
|
||||
otelGroupCallMembership?.onCallStateChange(call, newState);
|
||||
}
|
||||
|
||||
function onCallError(error: CallError, call: MatrixCall) {
|
||||
otelGroupCallMembership.onCallError(error, call);
|
||||
}
|
||||
|
||||
function onGroupCallError(error: GroupCallError) {
|
||||
otelGroupCallMembership.onGroupCallError(error);
|
||||
}
|
||||
|
||||
function onUndecryptableToDevice(event: MatrixEvent) {
|
||||
dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
|
||||
|
||||
Sentry.captureMessage("Undecryptable to-device Event");
|
||||
// probably unnecessary if it's now captured via otel?
|
||||
PosthogAnalytics.instance.eventUndecryptableToDevice.track(
|
||||
groupCall.groupCallId
|
||||
);
|
||||
|
||||
otelGroupCallMembership.onUndecryptableToDevice(event);
|
||||
}
|
||||
|
||||
client.on(RoomStateEvent.Events, onUpdateRoomState);
|
||||
//groupCall.on("calls_changed", onCallsChanged);
|
||||
groupCall.on(CallEvent.SendVoipEvent, onSendVoipEvent);
|
||||
groupCall.on(CallEvent.State, onCallStateChange);
|
||||
groupCall.on(CallEvent.Error, onCallError);
|
||||
groupCall.on(GroupCallEvent.Error, onGroupCallError);
|
||||
//client.on("state", onCallsChanged);
|
||||
//client.on("hangup", onCallHangup);
|
||||
client.on(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
|
||||
client.on(ClientEvent.UndecryptableToDeviceEvent, onUndecryptableToDevice);
|
||||
|
||||
onUpdateRoomState();
|
||||
|
||||
return () => {
|
||||
client.removeListener(RoomStateEvent.Events, onUpdateRoomState);
|
||||
//groupCall.removeListener("calls_changed", onCallsChanged);
|
||||
groupCall.removeListener(CallEvent.SendVoipEvent, onSendVoipEvent);
|
||||
groupCall.removeListener(CallEvent.State, onCallStateChange);
|
||||
groupCall.removeListener(CallEvent.Error, onCallError);
|
||||
groupCall.removeListener(GroupCallEvent.Error, onGroupCallError);
|
||||
//client.removeListener("state", onCallsChanged);
|
||||
//client.removeListener("hangup", onCallHangup);
|
||||
client.removeListener(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
|
||||
client.removeListener(
|
||||
ClientEvent.UndecryptableToDeviceEvent,
|
||||
onUndecryptableToDevice
|
||||
);
|
||||
};
|
||||
}, [client, groupCall]);
|
||||
}, [client, groupCall, otelGroupCallMembership]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -414,17 +470,19 @@ function useGroupCallState(
|
||||
interface GroupCallInspectorProps {
|
||||
client: MatrixClient;
|
||||
groupCall: GroupCall;
|
||||
otelGroupCallMembership: OTelGroupCallMembership;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function GroupCallInspector({
|
||||
client,
|
||||
groupCall,
|
||||
otelGroupCallMembership,
|
||||
show,
|
||||
}: GroupCallInspectorProps) {
|
||||
const [currentTab, setCurrentTab] = useState("sequence-diagrams");
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>();
|
||||
const state = useGroupCallState(client, groupCall, show);
|
||||
const state = useGroupCallState(client, groupCall, otelGroupCallMembership);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, setState] = useContext(InspectorContext);
|
||||
|
||||
@@ -51,7 +51,7 @@ export function GroupCallLoader({
|
||||
if (loading) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("Loading room…")}</h1>
|
||||
<h1>{t("Loading…")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ import { CallEndedView } from "./CallEndedView";
|
||||
import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
|
||||
import { useLocationNavigation } from "../useLocationNavigation";
|
||||
import { PosthogAnalytics } from "../PosthogAnalytics";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { useMediaHandler } from "../settings/useMediaHandler";
|
||||
import { findDeviceByName, getDevices } from "../media-utils";
|
||||
import { findDeviceByName, getNamedDevices } from "../media-utils";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -75,12 +75,14 @@ export function GroupCallView({
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
toggleScreensharing,
|
||||
setMicrophoneMuted,
|
||||
requestingScreenshare,
|
||||
isScreensharing,
|
||||
screenshareFeeds,
|
||||
participants,
|
||||
unencryptedEventsFromUsers,
|
||||
} = useGroupCall(groupCall);
|
||||
otelGroupCallMembership,
|
||||
} = useGroupCall(groupCall, client);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { setAudioInput, setVideoInput } = useMediaHandler();
|
||||
@@ -100,7 +102,7 @@ export function GroupCallView({
|
||||
// Get the available devices so we can match the selected device
|
||||
// to its ID. This involves getting a media stream (see docs on
|
||||
// the function) so we only do it once and re-use the result.
|
||||
const devices = await getDevices();
|
||||
const devices = await getNamedDevices();
|
||||
|
||||
const { audioInput, videoInput } = ev.detail
|
||||
.data as unknown as JoinCallData;
|
||||
@@ -141,8 +143,7 @@ export function GroupCallView({
|
||||
groupCall.setLocalVideoMuted(videoInput === null),
|
||||
]);
|
||||
|
||||
await groupCall.enter();
|
||||
|
||||
await enter();
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId);
|
||||
|
||||
@@ -157,17 +158,17 @@ export function GroupCallView({
|
||||
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
}
|
||||
}, [groupCall, preload, setAudioInput, setVideoInput]);
|
||||
}, [groupCall, preload, setAudioInput, setVideoInput, enter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmbedded && !preload) {
|
||||
// In embedded mode, bypass the lobby and just enter the call straight away
|
||||
groupCall.enter();
|
||||
enter();
|
||||
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId);
|
||||
}
|
||||
}, [groupCall, isEmbedded, preload]);
|
||||
}, [groupCall, isEmbedded, preload, enter]);
|
||||
|
||||
useSentryGroupCallHandler(groupCall);
|
||||
|
||||
@@ -237,6 +238,7 @@ export function GroupCallView({
|
||||
onLeave={onLeave}
|
||||
isEmbedded={isEmbedded}
|
||||
hideHeader={hideHeader}
|
||||
otelGroupCallMembership={otelGroupCallMembership}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -251,6 +253,7 @@ export function GroupCallView({
|
||||
localVideoMuted={localVideoMuted}
|
||||
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
||||
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
||||
setMicrophoneMuted={setMicrophoneMuted}
|
||||
userMediaFeeds={userMediaFeeds}
|
||||
activeSpeaker={activeSpeaker}
|
||||
onLeave={onLeave}
|
||||
@@ -260,6 +263,7 @@ export function GroupCallView({
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
|
||||
hideHeader={hideHeader}
|
||||
otelGroupCallMembership={otelGroupCallMembership}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -277,7 +281,7 @@ export function GroupCallView({
|
||||
} else if (isEmbedded) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("Loading room…")}</h1>
|
||||
<h1>{t("Loading…")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -15,14 +15,20 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
.inRoom {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
--footerPadding: 8px;
|
||||
--footerHeight: calc(50px + 2 * var(--footerPadding));
|
||||
}
|
||||
|
||||
.controlsOverlay {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.centerMessage {
|
||||
@@ -39,11 +45,27 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: calc(50px + 2 * 8px);
|
||||
padding: var(--footerPadding) 0;
|
||||
/* TODO: Un-hardcode these colors */
|
||||
background: linear-gradient(
|
||||
360deg,
|
||||
#15191e 0%,
|
||||
rgba(21, 25, 30, 0.9) 37%,
|
||||
rgba(21, 25, 30, 0.8) 49.68%,
|
||||
rgba(21, 25, 30, 0.7) 56.68%,
|
||||
rgba(21, 25, 30, 0.427397) 72.92%,
|
||||
rgba(21, 25, 30, 0.257534) 81.06%,
|
||||
rgba(21, 25, 30, 0.136986) 87.29%,
|
||||
rgba(21, 25, 30, 0.0658079) 92.4%,
|
||||
rgba(21, 25, 30, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.footer > * {
|
||||
@@ -65,16 +87,22 @@ limitations under the License.
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
/* To make avatars scale smoothly with their tiles during animations, we
|
||||
override the styles set on the element */
|
||||
--avatarSize: calc(min(var(--tileWidth), var(--tileHeight)) / 2);
|
||||
width: var(--avatarSize) !important;
|
||||
height: var(--avatarSize) !important;
|
||||
border-radius: 10000px !important;
|
||||
}
|
||||
|
||||
@media (min-height: 300px) {
|
||||
.footer {
|
||||
height: calc(50px + 2 * 24px);
|
||||
.inRoom {
|
||||
--footerPadding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.footer {
|
||||
height: calc(50px + 2 * 32px);
|
||||
.inRoom {
|
||||
--footerPadding: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ import {
|
||||
RoomHeaderInfo,
|
||||
VersionMismatchWarning,
|
||||
} from "../Header";
|
||||
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
|
||||
import {
|
||||
VideoGrid,
|
||||
useVideoGridLayout,
|
||||
ChildrenProperties,
|
||||
} from "../video-grid/VideoGrid";
|
||||
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
|
||||
import { GroupCallInspector } from "./GroupCallInspector";
|
||||
import { OverflowMenu } from "./OverflowMenu";
|
||||
@@ -51,11 +55,15 @@ import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||
import { useMediaHandler } from "../settings/useMediaHandler";
|
||||
import { useShowInspector, useSpatialAudio } from "../settings/useSetting";
|
||||
import {
|
||||
useNewGrid,
|
||||
useShowInspector,
|
||||
useSpatialAudio,
|
||||
} from "../settings/useSetting";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { useAudioContext } from "../video-grid/useMediaStream";
|
||||
import { useFullscreen } from "../video-grid/useFullscreen";
|
||||
import { PosthogAnalytics } from "../PosthogAnalytics";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { widget, ElementWidgetActions } from "../widget";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
@@ -63,6 +71,9 @@ import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||
import { ParticipantInfo } from "./useGroupCall";
|
||||
import { TileDescriptor } from "../video-grid/TileDescriptor";
|
||||
import { AudioSink } from "../video-grid/AudioSink";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
|
||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
||||
@@ -81,6 +92,7 @@ interface Props {
|
||||
toggleLocalVideoMuted: () => void;
|
||||
toggleMicrophoneMuted: () => void;
|
||||
toggleScreensharing: () => void;
|
||||
setMicrophoneMuted: (muted: boolean) => void;
|
||||
userMediaFeeds: CallFeed[];
|
||||
activeSpeaker: CallFeed | null;
|
||||
onLeave: () => void;
|
||||
@@ -89,6 +101,7 @@ interface Props {
|
||||
roomIdOrAlias: string;
|
||||
unencryptedEventsFromUsers: Set<string>;
|
||||
hideHeader: boolean;
|
||||
otelGroupCallMembership: OTelGroupCallMembership;
|
||||
}
|
||||
|
||||
export function InCallView({
|
||||
@@ -101,6 +114,7 @@ export function InCallView({
|
||||
localVideoMuted,
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
setMicrophoneMuted,
|
||||
userMediaFeeds,
|
||||
activeSpeaker,
|
||||
onLeave,
|
||||
@@ -110,6 +124,7 @@ export function InCallView({
|
||||
roomIdOrAlias,
|
||||
unencryptedEventsFromUsers,
|
||||
hideHeader,
|
||||
otelGroupCallMembership,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
usePreventScroll();
|
||||
@@ -141,6 +156,13 @@ export function InCallView({
|
||||
|
||||
const { hideScreensharing } = useUrlParams();
|
||||
|
||||
useCallViewKeyboardShortcuts(
|
||||
containerRef1,
|
||||
toggleMicrophoneMuted,
|
||||
toggleLocalVideoMuted,
|
||||
setMicrophoneMuted
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport.send(
|
||||
layout === "freedom"
|
||||
@@ -266,6 +288,8 @@ export function InCallView({
|
||||
[]
|
||||
);
|
||||
|
||||
const [newGrid] = useNewGrid();
|
||||
const Grid = newGrid ? NewVideoGrid : VideoGrid;
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
@@ -279,8 +303,8 @@ export function InCallView({
|
||||
if (maximisedParticipant) {
|
||||
return (
|
||||
<VideoTileContainer
|
||||
height={bounds.height}
|
||||
width={bounds.width}
|
||||
targetHeight={bounds.height}
|
||||
targetWidth={bounds.width}
|
||||
key={maximisedParticipant.id}
|
||||
item={maximisedParticipant}
|
||||
getAvatar={renderAvatar}
|
||||
@@ -295,20 +319,13 @@ export function InCallView({
|
||||
}
|
||||
|
||||
return (
|
||||
<VideoGrid
|
||||
<Grid
|
||||
items={items}
|
||||
layout={layout}
|
||||
disableAnimations={prefersReducedMotion || isSafari}
|
||||
>
|
||||
{({
|
||||
item,
|
||||
...rest
|
||||
}: {
|
||||
item: TileDescriptor;
|
||||
[x: string]: unknown;
|
||||
}) => (
|
||||
{({ item, ...rest }: ChildrenProperties) => (
|
||||
<VideoTileContainer
|
||||
key={item.id}
|
||||
item={item}
|
||||
getAvatar={renderAvatar}
|
||||
audioContext={audioContext}
|
||||
@@ -320,7 +337,7 @@ export function InCallView({
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</VideoGrid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -343,11 +360,11 @@ export function InCallView({
|
||||
const audioElements: JSX.Element[] = [];
|
||||
if (!spatialAudio || maximisedParticipant) {
|
||||
for (const item of items) {
|
||||
if (item.isLocal) continue; // We don't want to render own audio
|
||||
audioElements.push(
|
||||
<AudioSink
|
||||
tileDescriptor={item}
|
||||
audioOutput={audioOutput}
|
||||
otelGroupCallMembership={otelGroupCallMembership}
|
||||
key={item.id}
|
||||
/>
|
||||
);
|
||||
@@ -358,27 +375,39 @@ export function InCallView({
|
||||
|
||||
if (noControls) {
|
||||
footer = null;
|
||||
} else if (reducedControls) {
|
||||
footer = (
|
||||
<div className={styles.footer}>
|
||||
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
|
||||
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
|
||||
<HangupButton onPress={onLeave} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
footer = (
|
||||
<div className={styles.footer}>
|
||||
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
|
||||
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
|
||||
{canScreenshare && !hideScreensharing && !isSafari && (
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
buttons.push(
|
||||
<MicButton
|
||||
key="1"
|
||||
muted={microphoneMuted}
|
||||
onPress={toggleMicrophoneMuted}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
key="2"
|
||||
muted={localVideoMuted}
|
||||
onPress={toggleLocalVideoMuted}
|
||||
data-testid="incall_videomute"
|
||||
/>
|
||||
);
|
||||
|
||||
if (!reducedControls) {
|
||||
if (canScreenshare && !hideScreensharing && !isSafari) {
|
||||
buttons.push(
|
||||
<ScreenshareButton
|
||||
key="3"
|
||||
enabled={isScreensharing}
|
||||
onPress={toggleScreensharing}
|
||||
data-testid="incall_screenshare"
|
||||
/>
|
||||
)}
|
||||
{!maximisedParticipant && (
|
||||
);
|
||||
}
|
||||
if (!maximisedParticipant) {
|
||||
buttons.push(
|
||||
<OverflowMenu
|
||||
key="4"
|
||||
inCall
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
groupCall={groupCall}
|
||||
@@ -386,10 +415,14 @@ export function InCallView({
|
||||
feedbackModalState={feedbackModalState}
|
||||
feedbackModalProps={feedbackModalProps}
|
||||
/>
|
||||
)}
|
||||
<HangupButton onPress={onLeave} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<HangupButton key="6" onPress={onLeave} data-testid="incall_leave" />
|
||||
);
|
||||
footer = <div className={styles.footer}>{buttons}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -410,14 +443,17 @@ export function InCallView({
|
||||
</RightNav>
|
||||
</Header>
|
||||
)}
|
||||
{renderContent()}
|
||||
{footer}
|
||||
<div className={styles.controlsOverlay}>
|
||||
{renderContent()}
|
||||
{footer}
|
||||
</div>
|
||||
<GroupCallInspector
|
||||
client={client}
|
||||
groupCall={groupCall}
|
||||
otelGroupCallMembership={otelGroupCallMembership}
|
||||
show={showInspector}
|
||||
/>
|
||||
{rageshakeRequestModalState.isOpen && (
|
||||
{rageshakeRequestModalState.isOpen && !noControls && (
|
||||
<RageshakeRequestModal
|
||||
{...rageshakeRequestModalProps}
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
|
||||
@@ -41,6 +41,7 @@ export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
|
||||
<CopyButton
|
||||
className={styles.copyButton}
|
||||
value={getRoomUrl(roomIdOrAlias)}
|
||||
data-testid="modal_inviteLink"
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -137,6 +137,7 @@ export function LobbyView({
|
||||
size="lg"
|
||||
disabled={state !== GroupCallState.LocalCallFeedInitialized}
|
||||
onPress={onEnter}
|
||||
data-testid="lobby_joinCall"
|
||||
>
|
||||
Join call now
|
||||
</Button>
|
||||
@@ -146,6 +147,7 @@ export function LobbyView({
|
||||
value={getRoomUrl(roomIdOrAlias)}
|
||||
className={styles.copyButton}
|
||||
copiedMessage={t("Call link copied")}
|
||||
data-testid="lobby_inviteLink"
|
||||
>
|
||||
Copy call link and join later
|
||||
</CopyButton>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function OverflowMenu({
|
||||
<>
|
||||
<PopoverMenuTrigger disableOnState>
|
||||
<TooltipTrigger tooltip={tooltip} placement="top">
|
||||
<Button variant="toolbar">
|
||||
<Button variant="toolbar" data-testid="call_more">
|
||||
<OverflowIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -111,7 +111,7 @@ export function OverflowMenu({
|
||||
{showInvite && (
|
||||
<Item key="invite" textValue={t("Invite people")}>
|
||||
<AddUserIcon />
|
||||
<span>{t("Invite people")}</span>
|
||||
<span data-testid="call_moreInvite">{t("Invite people")}</span>
|
||||
</Item>
|
||||
)}
|
||||
<Item key="settings" textValue={t("Settings")}>
|
||||
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
import React, { useCallback, useState, useRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { useSpring, animated } from "@react-spring/web";
|
||||
import { logger } from "@sentry/utils";
|
||||
|
||||
import styles from "./PTTButton.module.css";
|
||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||
@@ -68,11 +69,23 @@ export const PTTButton: React.FC<Props> = ({
|
||||
enqueueNetworkWaiting(true, 100);
|
||||
startTalking();
|
||||
}, [enqueueNetworkWaiting, startTalking, buttonHeld]);
|
||||
|
||||
const unhold = useCallback(() => {
|
||||
if (!buttonHeld) return;
|
||||
setButtonHeld(false);
|
||||
setNetworkWaiting(false);
|
||||
stopTalking();
|
||||
}, [setNetworkWaiting, stopTalking]);
|
||||
}, [setNetworkWaiting, stopTalking, buttonHeld]);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
logger.info("Mouse up event: unholding PTT button");
|
||||
unhold();
|
||||
}, [unhold]);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
logger.info("Blur event: unholding PTT button");
|
||||
unhold();
|
||||
}, [unhold]);
|
||||
|
||||
const onButtonMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -85,7 +98,7 @@ export const PTTButton: React.FC<Props> = ({
|
||||
// These listeners go on the window so even if the user's cursor / finger
|
||||
// leaves the button while holding it, the button stays pushed until
|
||||
// they stop clicking / tapping.
|
||||
useEventTarget(window, "mouseup", unhold);
|
||||
useEventTarget(window, "mouseup", onMouseUp);
|
||||
useEventTarget(
|
||||
window,
|
||||
"touchend",
|
||||
@@ -103,6 +116,8 @@ export const PTTButton: React.FC<Props> = ({
|
||||
}
|
||||
if (!touchFound) return;
|
||||
|
||||
logger.info("Touch event ended: unholding PTT button");
|
||||
|
||||
e.preventDefault();
|
||||
unhold();
|
||||
setActiveTouchId(null);
|
||||
@@ -163,6 +178,8 @@ export const PTTButton: React.FC<Props> = ({
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
logger.info("Keyup event for spacebar: unholding PTT button");
|
||||
|
||||
unhold();
|
||||
}
|
||||
},
|
||||
@@ -171,7 +188,7 @@ export const PTTButton: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
// TODO: We will need to disable this for a global PTT hotkey to work
|
||||
useEventTarget(window, "blur", unhold);
|
||||
useEventTarget(window, "blur", onBlur);
|
||||
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const { shadow } = useSpring({
|
||||
|
||||
@@ -44,6 +44,7 @@ import { GroupCallInspector } from "./GroupCallInspector";
|
||||
import { OverflowMenu } from "./OverflowMenu";
|
||||
import { Size } from "../Avatar";
|
||||
import { ParticipantInfo } from "./useGroupCall";
|
||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
|
||||
function getPromptText(
|
||||
networkWaiting: boolean,
|
||||
@@ -106,6 +107,7 @@ interface Props {
|
||||
onLeave: () => void;
|
||||
isEmbedded: boolean;
|
||||
hideHeader: boolean;
|
||||
otelGroupCallMembership: OTelGroupCallMembership;
|
||||
}
|
||||
|
||||
export const PTTCallView: React.FC<Props> = ({
|
||||
@@ -119,6 +121,7 @@ export const PTTCallView: React.FC<Props> = ({
|
||||
onLeave,
|
||||
isEmbedded,
|
||||
hideHeader,
|
||||
otelGroupCallMembership,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { modalState: inviteModalState, modalProps: inviteModalProps } =
|
||||
@@ -192,6 +195,7 @@ export const PTTCallView: React.FC<Props> = ({
|
||||
<GroupCallInspector
|
||||
client={client}
|
||||
groupCall={groupCall}
|
||||
otelGroupCallMembership={otelGroupCallMembership}
|
||||
// Never shown in PTT mode, but must be present to collect call state
|
||||
// https://github.com/vector-im/element-call/issues/328
|
||||
show={false}
|
||||
@@ -210,36 +214,36 @@ export const PTTCallView: React.FC<Props> = ({
|
||||
</Header>
|
||||
)}
|
||||
<div className={styles.center}>
|
||||
{showControls && (
|
||||
<>
|
||||
<div className={styles.participants}>
|
||||
<p>
|
||||
{t("{{count}} people connected", {
|
||||
count: participatingMembers.length,
|
||||
})}
|
||||
</p>
|
||||
<Facepile
|
||||
size={facepileSize}
|
||||
max={8}
|
||||
className={styles.facepile}
|
||||
client={client}
|
||||
members={participatingMembers}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<OverflowMenu
|
||||
inCall
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
groupCall={groupCall}
|
||||
showInvite={false}
|
||||
feedbackModalState={feedbackModalState}
|
||||
feedbackModalProps={feedbackModalProps}
|
||||
/>
|
||||
{!isEmbedded && <HangupButton onPress={onLeave} />}
|
||||
<InviteButton onPress={() => inviteModalState.open()} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Always render this because the window will become shorter when the on-screen
|
||||
keyboard appears, so if we don't render it, the dialog will unmount. */}
|
||||
<div style={{ display: showControls ? "block" : "none" }}>
|
||||
<div className={styles.participants}>
|
||||
<p>
|
||||
{t("{{count}} people connected", {
|
||||
count: participatingMembers.length,
|
||||
})}
|
||||
</p>
|
||||
<Facepile
|
||||
size={facepileSize}
|
||||
max={8}
|
||||
className={styles.facepile}
|
||||
client={client}
|
||||
members={participatingMembers}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<OverflowMenu
|
||||
inCall
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
groupCall={groupCall}
|
||||
showInvite={false}
|
||||
feedbackModalState={feedbackModalState}
|
||||
feedbackModalProps={feedbackModalProps}
|
||||
/>
|
||||
{!isEmbedded && <HangupButton onPress={onLeave} />}
|
||||
<InviteButton onPress={() => inviteModalState.open()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.pttButtonContainer}>
|
||||
{showControls &&
|
||||
|
||||
@@ -74,6 +74,7 @@ export function RoomAuthView() {
|
||||
name="displayName"
|
||||
label={t("Display name")}
|
||||
placeholder={t("Display name")}
|
||||
data-testid="joincall_displayName"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
@@ -90,7 +91,12 @@ export function RoomAuthView() {
|
||||
<ErrorMessage error={error} />
|
||||
</FieldRow>
|
||||
)}
|
||||
<Button type="submit" size="lg" disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
data-testid="joincall_joincall"
|
||||
>
|
||||
{loading ? t("Loading…") : t("Join call now")}
|
||||
</Button>
|
||||
<div id={recaptchaId} />
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useUrlParams } from "../UrlParams";
|
||||
import { MediaHandlerProvider } from "../settings/useMediaHandler";
|
||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||
import { translatedError } from "../TranslatedError";
|
||||
import { useOptInAnalytics } from "../settings/useSetting";
|
||||
|
||||
export const RoomPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -46,13 +47,19 @@ export const RoomPage: FC = () => {
|
||||
const roomIdOrAlias = roomId ?? roomAlias;
|
||||
if (!roomIdOrAlias) throw translatedError("No room specified", t);
|
||||
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If we're not already authed and we've been given a display name as
|
||||
// During the beta, opt into analytics by default
|
||||
if (optInAnalytics === null) setOptInAnalytics(true);
|
||||
}, [optInAnalytics, setOptInAnalytics]);
|
||||
|
||||
useEffect(() => {
|
||||
// If we've finished loading, are not already authed and we've been given a display name as
|
||||
// a URL param, automatically register a passwordless user
|
||||
if (!isAuthenticated && displayName) {
|
||||
if (!loading && !isAuthenticated && displayName) {
|
||||
setIsRegistering(true);
|
||||
registerPasswordlessUser(displayName).finally(() => {
|
||||
setIsRegistering(false);
|
||||
@@ -63,6 +70,7 @@ export const RoomPage: FC = () => {
|
||||
displayName,
|
||||
setIsRegistering,
|
||||
registerPasswordlessUser,
|
||||
loading,
|
||||
]);
|
||||
|
||||
const groupCallView = useCallback(
|
||||
|
||||
@@ -64,7 +64,13 @@ export function VideoPreview({
|
||||
|
||||
return (
|
||||
<div className={styles.preview} ref={previewRef}>
|
||||
<video ref={videoRef} muted playsInline disablePictureInPicture />
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
data-testid="preview_video"
|
||||
/>
|
||||
{state === GroupCallState.LocalCallFeedUninitialized && (
|
||||
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
|
||||
{t("Camera/microphone permissions needed to join the call.")}
|
||||
|
||||
65
src/room/checkForParallelCalls.ts
Normal file
65
src/room/checkForParallelCalls.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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 { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
|
||||
function isObject(x: unknown): x is Record<string, unknown> {
|
||||
return typeof x === "object" && x !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the state of a room for multiple calls happening in parallel, sending
|
||||
* the details to PostHog if that is indeed what's happening. (This is unwanted
|
||||
* as it indicates a split-brain scenario.)
|
||||
*/
|
||||
export function checkForParallelCalls(state: RoomState): void {
|
||||
const now = Date.now();
|
||||
const participantsPerCall = new Map<string, number>();
|
||||
|
||||
// For each participant in each call, increment the participant count
|
||||
for (const e of state.getStateEvents(EventType.GroupCallMemberPrefix)) {
|
||||
const content = e.getContent<Record<string, unknown>>();
|
||||
const calls: unknown[] = Array.isArray(content["m.calls"])
|
||||
? content["m.calls"]
|
||||
: [];
|
||||
|
||||
for (const call of calls) {
|
||||
if (isObject(call) && typeof call["m.call_id"] === "string") {
|
||||
const devices: unknown[] = Array.isArray(call["m.devices"])
|
||||
? call["m.devices"]
|
||||
: [];
|
||||
|
||||
for (const device of devices) {
|
||||
if (isObject(device) && (device["expires_ts"] as number) > now) {
|
||||
const participantCount =
|
||||
participantsPerCall.get(call["m.call_id"]) ?? 0;
|
||||
participantsPerCall.set(call["m.call_id"], participantCount + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (participantsPerCall.size > 1) {
|
||||
PosthogAnalytics.instance.trackEvent({
|
||||
eventName: "ParallelCalls",
|
||||
participantsPerCall: Object.fromEntries(participantsPerCall),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,18 +22,27 @@ import {
|
||||
GroupCallErrorCode,
|
||||
GroupCallUnknownDeviceError,
|
||||
GroupCallError,
|
||||
GroupCallStatsReportEvent,
|
||||
GroupCallStatsReport,
|
||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { MatrixClient, RoomStateEvent } from "matrix-js-sdk";
|
||||
import {
|
||||
ByteSentStatsReport,
|
||||
ConnectionStatsReport,
|
||||
SummaryStatsReport,
|
||||
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
|
||||
|
||||
import { usePageUnload } from "./usePageUnload";
|
||||
import { PosthogAnalytics } from "../PosthogAnalytics";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { TranslatedError, translatedError } from "../TranslatedError";
|
||||
import { ElementWidgetActions, ScreenshareStartData, widget } from "../widget";
|
||||
import { getSetting } from "../settings/useSetting";
|
||||
import { useEventTarget } from "../useEvents";
|
||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
import { ElementCallOpenTelemetry } from "../otel/otel";
|
||||
import { checkForParallelCalls } from "./checkForParallelCalls";
|
||||
|
||||
export enum ConnectionState {
|
||||
EstablishingCall = "establishing call", // call hasn't been established yet
|
||||
@@ -60,6 +69,7 @@ export interface UseGroupCallReturnType {
|
||||
toggleLocalVideoMuted: () => void;
|
||||
toggleMicrophoneMuted: () => void;
|
||||
toggleScreensharing: () => void;
|
||||
setMicrophoneMuted: (muted: boolean) => void;
|
||||
requestingScreenshare: boolean;
|
||||
isScreensharing: boolean;
|
||||
screenshareFeeds: CallFeed[];
|
||||
@@ -67,6 +77,7 @@ export interface UseGroupCallReturnType {
|
||||
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
|
||||
hasLocalParticipant: boolean;
|
||||
unencryptedEventsFromUsers: Set<string>;
|
||||
otelGroupCallMembership: OTelGroupCallMembership;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -85,6 +96,13 @@ interface State {
|
||||
hasLocalParticipant: boolean;
|
||||
}
|
||||
|
||||
// This is a bit of a hack, but we keep the opentelemetry tracker object at the file
|
||||
// level so that it doesn't pop in & out of existence as react mounts & unmounts
|
||||
// components. The right solution is probably for this to live in the js-sdk and have
|
||||
// the same lifetime as groupcalls themselves.
|
||||
let groupCallOTelMembership: OTelGroupCallMembership;
|
||||
let groupCallOTelMembershipGroupCallId: string;
|
||||
|
||||
function getParticipants(
|
||||
groupCall: GroupCall
|
||||
): Map<RoomMember, Map<string, ParticipantInfo>> {
|
||||
@@ -99,12 +117,24 @@ function getParticipants(
|
||||
(f) => f.userId === member.userId && f.deviceId === deviceId
|
||||
);
|
||||
|
||||
participantInfoMap.set(deviceId, {
|
||||
connectionState: feed
|
||||
let connectionState: ConnectionState;
|
||||
// If we allow calls without media, we have no feeds and cannot read the connection status from them.
|
||||
// @TODO: The connection state should generally not be determined by the feed.
|
||||
if (
|
||||
groupCall.allowCallWithoutVideoAndAudio &&
|
||||
!feed &&
|
||||
!participant.screensharing
|
||||
) {
|
||||
connectionState = ConnectionState.Connected;
|
||||
} else {
|
||||
connectionState = feed
|
||||
? feed.connected
|
||||
? ConnectionState.Connected
|
||||
: ConnectionState.WaitMedia
|
||||
: ConnectionState.EstablishingCall,
|
||||
: ConnectionState.EstablishingCall;
|
||||
}
|
||||
participantInfoMap.set(deviceId, {
|
||||
connectionState,
|
||||
presenter: participant.screensharing,
|
||||
});
|
||||
}
|
||||
@@ -113,7 +143,10 @@ function getParticipants(
|
||||
return participants;
|
||||
}
|
||||
|
||||
export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
export function useGroupCall(
|
||||
groupCall: GroupCall,
|
||||
client: MatrixClient
|
||||
): UseGroupCallReturnType {
|
||||
const [
|
||||
{
|
||||
state,
|
||||
@@ -147,6 +180,19 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
hasLocalParticipant: false,
|
||||
});
|
||||
|
||||
if (groupCallOTelMembershipGroupCallId !== groupCall.groupCallId) {
|
||||
if (groupCallOTelMembership) groupCallOTelMembership.dispose();
|
||||
|
||||
// If the user disables analytics, this will stay around until they leave the call
|
||||
// so analytics will be disabled once they leave.
|
||||
if (ElementCallOpenTelemetry.instance) {
|
||||
groupCallOTelMembership = new OTelGroupCallMembership(groupCall, client);
|
||||
groupCallOTelMembershipGroupCallId = groupCall.groupCallId;
|
||||
} else {
|
||||
groupCallOTelMembership = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const [unencryptedEventsFromUsers, addUnencryptedEventUser] = useReducer(
|
||||
(state: Set<string>, newVal: string) => {
|
||||
return new Set(state).add(newVal);
|
||||
@@ -159,6 +205,43 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
[setState]
|
||||
);
|
||||
|
||||
const doNothingMediaActionCallback = useCallback(
|
||||
(details: MediaSessionActionDetails) => {},
|
||||
[]
|
||||
);
|
||||
|
||||
const leaveCall = useCallback(() => {
|
||||
groupCallOTelMembership?.onLeaveCall();
|
||||
groupCall.leave();
|
||||
}, [groupCall]);
|
||||
|
||||
useEffect(() => {
|
||||
// disable the media action keys, otherwise audio elements get paused when
|
||||
// the user presses media keys or unplugs headphones, etc.
|
||||
// Note there are actions for muting / unmuting a microphone & hanging up
|
||||
// which we could wire up.
|
||||
const mediaActions: MediaSessionAction[] = [
|
||||
"play",
|
||||
"pause",
|
||||
"stop",
|
||||
"nexttrack",
|
||||
"previoustrack",
|
||||
];
|
||||
|
||||
for (const mediaAction of mediaActions) {
|
||||
navigator.mediaSession?.setActionHandler(
|
||||
mediaAction,
|
||||
doNothingMediaActionCallback
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const mediaAction of mediaActions) {
|
||||
navigator.mediaSession?.setActionHandler(mediaAction, null);
|
||||
}
|
||||
};
|
||||
}, [doNothingMediaActionCallback]);
|
||||
|
||||
useEffect(() => {
|
||||
function onGroupCallStateChanged() {
|
||||
updateState({
|
||||
@@ -262,6 +345,24 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
}
|
||||
}
|
||||
|
||||
function onConnectionStatsReport(
|
||||
report: GroupCallStatsReport<ConnectionStatsReport>
|
||||
): void {
|
||||
groupCallOTelMembership?.onConnectionStatsReport(report);
|
||||
}
|
||||
|
||||
function onByteSentStatsReport(
|
||||
report: GroupCallStatsReport<ByteSentStatsReport>
|
||||
): void {
|
||||
groupCallOTelMembership?.onByteSentStatsReport(report);
|
||||
}
|
||||
|
||||
function onSummaryStatsReport(
|
||||
report: GroupCallStatsReport<SummaryStatsReport>
|
||||
): void {
|
||||
groupCallOTelMembership?.onSummaryStatsReport(report);
|
||||
}
|
||||
|
||||
groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged);
|
||||
groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged);
|
||||
groupCall.on(
|
||||
@@ -277,6 +378,19 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
groupCall.on(GroupCallEvent.CallsChanged, onCallsChanged);
|
||||
groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged);
|
||||
groupCall.on(GroupCallEvent.Error, onError);
|
||||
groupCall.on(
|
||||
GroupCallStatsReportEvent.ConnectionStats,
|
||||
onConnectionStatsReport
|
||||
);
|
||||
groupCall.on(
|
||||
GroupCallStatsReportEvent.ByteSentStats,
|
||||
onByteSentStatsReport
|
||||
);
|
||||
groupCall.on(GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport);
|
||||
groupCall.room.currentState.on(
|
||||
RoomStateEvent.Update,
|
||||
checkForParallelCalls
|
||||
);
|
||||
|
||||
updateState({
|
||||
error: null,
|
||||
@@ -324,12 +438,28 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
onParticipantsChanged
|
||||
);
|
||||
groupCall.removeListener(GroupCallEvent.Error, onError);
|
||||
groupCall.leave();
|
||||
groupCall.removeListener(
|
||||
GroupCallStatsReportEvent.ConnectionStats,
|
||||
onConnectionStatsReport
|
||||
);
|
||||
groupCall.removeListener(
|
||||
GroupCallStatsReportEvent.ByteSentStats,
|
||||
onByteSentStatsReport
|
||||
);
|
||||
groupCall.removeListener(
|
||||
GroupCallStatsReportEvent.SummaryStats,
|
||||
onSummaryStatsReport
|
||||
);
|
||||
groupCall.room.currentState.off(
|
||||
RoomStateEvent.Update,
|
||||
checkForParallelCalls
|
||||
);
|
||||
leaveCall();
|
||||
};
|
||||
}, [groupCall, updateState]);
|
||||
}, [groupCall, updateState, leaveCall]);
|
||||
|
||||
usePageUnload(() => {
|
||||
groupCall.leave();
|
||||
leaveCall();
|
||||
});
|
||||
|
||||
const initLocalCallFeed = useCallback(
|
||||
@@ -348,17 +478,21 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||
PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId);
|
||||
|
||||
// This must be called before we start trying to join the call, as we need to
|
||||
// have started tracking by the time calls start getting created.
|
||||
groupCallOTelMembership?.onJoinCall();
|
||||
|
||||
groupCall.enter().catch((error) => {
|
||||
console.error(error);
|
||||
updateState({ error });
|
||||
});
|
||||
}, [groupCall, updateState]);
|
||||
|
||||
const leave = useCallback(() => groupCall.leave(), [groupCall]);
|
||||
|
||||
const toggleLocalVideoMuted = useCallback(() => {
|
||||
const toggleToMute = !groupCall.isLocalVideoMuted();
|
||||
groupCall.setLocalVideoMuted(toggleToMute);
|
||||
groupCallOTelMembership?.onToggleLocalVideoMuted(toggleToMute);
|
||||
// TODO: These explict posthog calls should be unnecessary now with the posthog otel exporter?
|
||||
PosthogAnalytics.instance.eventMuteCamera.track(
|
||||
toggleToMute,
|
||||
groupCall.groupCallId
|
||||
@@ -368,6 +502,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
const setMicrophoneMuted = useCallback(
|
||||
(setMuted) => {
|
||||
groupCall.setMicrophoneMuted(setMuted);
|
||||
groupCallOTelMembership?.onSetMicrophoneMuted(setMuted);
|
||||
PosthogAnalytics.instance.eventMuteMicrophone.track(
|
||||
setMuted,
|
||||
groupCall.groupCallId
|
||||
@@ -378,10 +513,13 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
|
||||
const toggleMicrophoneMuted = useCallback(() => {
|
||||
const toggleToMute = !groupCall.isMicrophoneMuted();
|
||||
groupCallOTelMembership?.onToggleMicrophoneMuted(toggleToMute);
|
||||
setMicrophoneMuted(toggleToMute);
|
||||
}, [groupCall, setMicrophoneMuted]);
|
||||
|
||||
const toggleScreensharing = useCallback(async () => {
|
||||
groupCallOTelMembership?.onToggleScreensharing(!groupCall.isScreensharing);
|
||||
|
||||
if (!groupCall.isScreensharing()) {
|
||||
// toggling on
|
||||
updateState({ requestingScreenshare: true });
|
||||
@@ -472,68 +610,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
}
|
||||
}, [t, updateState]);
|
||||
|
||||
const [spacebarHeld, setSpacebarHeld] = useState(false);
|
||||
|
||||
useEventTarget(
|
||||
window,
|
||||
"keydown",
|
||||
useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Check if keyboard shortcuts are enabled
|
||||
const keyboardShortcuts = getSetting("keyboard-shortcuts", true);
|
||||
if (!keyboardShortcuts) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "m") {
|
||||
toggleMicrophoneMuted();
|
||||
} else if (event.key == "v") {
|
||||
toggleLocalVideoMuted();
|
||||
} else if (event.key === " ") {
|
||||
setSpacebarHeld(true);
|
||||
setMicrophoneMuted(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
setMicrophoneMuted,
|
||||
setSpacebarHeld,
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
useEventTarget(
|
||||
window,
|
||||
"keyup",
|
||||
useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Check if keyboard shortcuts are enabled
|
||||
const keyboardShortcuts = getSetting("keyboard-shortcuts", true);
|
||||
if (!keyboardShortcuts) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === " ") {
|
||||
setSpacebarHeld(false);
|
||||
setMicrophoneMuted(true);
|
||||
}
|
||||
},
|
||||
[setMicrophoneMuted, setSpacebarHeld]
|
||||
)
|
||||
);
|
||||
|
||||
useEventTarget(
|
||||
window,
|
||||
"blur",
|
||||
useCallback(() => {
|
||||
if (spacebarHeld) {
|
||||
setSpacebarHeld(false);
|
||||
setMicrophoneMuted(true);
|
||||
}
|
||||
}, [setMicrophoneMuted, setSpacebarHeld, spacebarHeld])
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
localCallFeed,
|
||||
@@ -544,10 +620,11 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
error,
|
||||
initLocalCallFeed,
|
||||
enter,
|
||||
leave,
|
||||
leave: leaveCall,
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
toggleScreensharing,
|
||||
setMicrophoneMuted,
|
||||
requestingScreenshare,
|
||||
isScreensharing,
|
||||
screenshareFeeds,
|
||||
@@ -555,5 +632,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
participants,
|
||||
hasLocalParticipant,
|
||||
unencryptedEventsFromUsers,
|
||||
otelGroupCallMembership: groupCallOTelMembership,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
|
||||
import { translatedError } from "../TranslatedError";
|
||||
import { widget } from "../widget";
|
||||
|
||||
const STATS_COLLECT_INTERVAL_TIME_MS = 10000;
|
||||
|
||||
export interface GroupCallLoadState {
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
@@ -52,12 +54,22 @@ export const useLoadGroupCall = (
|
||||
|
||||
const fetchOrCreateRoom = async (): Promise<Room> => {
|
||||
try {
|
||||
const room = await client.joinRoom(roomIdOrAlias, { viaServers });
|
||||
// We lowercase the localpart when we create the room, so we must lowercase
|
||||
// it here too (we just do the whole alias). We can't do the same to room IDs
|
||||
// though.
|
||||
const sanitisedIdOrAlias =
|
||||
roomIdOrAlias[0] === "#"
|
||||
? roomIdOrAlias.toLowerCase()
|
||||
: roomIdOrAlias;
|
||||
|
||||
const room = await client.joinRoom(sanitisedIdOrAlias, {
|
||||
viaServers,
|
||||
});
|
||||
logger.info(
|
||||
`Joined ${roomIdOrAlias}, waiting room to be ready for group calls`
|
||||
`Joined ${sanitisedIdOrAlias}, waiting room to be ready for group calls`
|
||||
);
|
||||
await client.waitUntilRoomReadyForGroupCalls(room.roomId);
|
||||
logger.info(`${roomIdOrAlias}, is ready for group calls`);
|
||||
logger.info(`${sanitisedIdOrAlias}, is ready for group calls`);
|
||||
return room;
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -84,10 +96,13 @@ export const useLoadGroupCall = (
|
||||
const fetchOrCreateGroupCall = async (): Promise<GroupCall> => {
|
||||
const room = await fetchOrCreateRoom();
|
||||
logger.debug(`Fetched / joined room ${roomIdOrAlias}`);
|
||||
const groupCall = client.getGroupCallForRoom(room.roomId);
|
||||
let groupCall = client.getGroupCallForRoom(room.roomId);
|
||||
logger.debug("Got group call", groupCall?.groupCallId);
|
||||
|
||||
if (groupCall) return groupCall;
|
||||
if (groupCall) {
|
||||
groupCall.setGroupCallStatsInterval(STATS_COLLECT_INTERVAL_TIME_MS);
|
||||
return groupCall;
|
||||
}
|
||||
|
||||
if (
|
||||
!widget &&
|
||||
@@ -102,12 +117,14 @@ export const useLoadGroupCall = (
|
||||
createPtt ? "PTT" : "video"
|
||||
} call`
|
||||
);
|
||||
return await client.createGroupCall(
|
||||
groupCall = await client.createGroupCall(
|
||||
room.roomId,
|
||||
createPtt ? GroupCallType.Voice : GroupCallType.Video,
|
||||
createPtt,
|
||||
GroupCallIntent.Room
|
||||
);
|
||||
groupCall.setGroupCallStatsInterval(STATS_COLLECT_INTERVAL_TIME_MS);
|
||||
return groupCall;
|
||||
}
|
||||
|
||||
// We don't have permission to create the call, so all we can do is wait
|
||||
@@ -116,6 +133,7 @@ export const useLoadGroupCall = (
|
||||
const onGroupCallIncoming = (groupCall: GroupCall) => {
|
||||
if (groupCall?.room.roomId === room.roomId) {
|
||||
clearTimeout(timeout);
|
||||
groupCall.setGroupCallStatsInterval(STATS_COLLECT_INTERVAL_TIME_MS);
|
||||
client.off(
|
||||
GroupCallEventHandlerEvent.Incoming,
|
||||
onGroupCallIncoming
|
||||
|
||||
@@ -113,12 +113,14 @@ export const usePTT = (
|
||||
},
|
||||
setState,
|
||||
] = useState(() => {
|
||||
// slightly concerningly, this can end up null as we seem to sometimes get
|
||||
// here before the room state contains our own member event
|
||||
const roomMember = groupCall.room.getMember(client.getUserId());
|
||||
|
||||
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
|
||||
|
||||
return {
|
||||
isAdmin: roomMember.powerLevel >= 100,
|
||||
isAdmin: roomMember ? roomMember.powerLevel >= 100 : false,
|
||||
talkOverEnabled: false,
|
||||
pttButtonHeld: false,
|
||||
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
|
||||
|
||||
@@ -20,9 +20,18 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.tabContainer {
|
||||
margin: 27px 16px;
|
||||
padding: 27px 20px;
|
||||
}
|
||||
|
||||
.fieldRowText {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
This style guarantees a fixed width of the tab bar in the settings window.
|
||||
The "Developer" item in the tab bar can be toggled.
|
||||
Without a defined width activating the developer tab makes the tab container jump to the right.
|
||||
*/
|
||||
.tabLabel {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import { Item } from "@react-stately/collections";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import styles from "./SettingsModal.module.css";
|
||||
@@ -28,16 +28,17 @@ import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
|
||||
import { SelectInput } from "../input/SelectInput";
|
||||
import { useMediaHandler } from "./useMediaHandler";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useSpatialAudio,
|
||||
useShowInspector,
|
||||
useOptInAnalytics,
|
||||
canEnableSpatialAudio,
|
||||
useNewGrid,
|
||||
useDeveloperSettingsTab,
|
||||
} from "./useSetting";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { Button } from "../button";
|
||||
import { useDownloadDebugLog } from "./submit-rageshake";
|
||||
import { Body } from "../typography/Typography";
|
||||
import { Body, Caption } from "../typography/Typography";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -56,15 +57,30 @@ export const SettingsModal = (props: Props) => {
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
useDeviceNames,
|
||||
} = useMediaHandler();
|
||||
useDeviceNames();
|
||||
|
||||
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
|
||||
const [showInspector, setShowInspector] = useShowInspector();
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
const [keyboardShortcuts, setKeyboardShortcuts] = useKeyboardShortcuts();
|
||||
const [developerSettingsTab, setDeveloperSettingsTab] =
|
||||
useDeveloperSettingsTab();
|
||||
const [newGrid, setNewGrid] = useNewGrid();
|
||||
|
||||
const downloadDebugLog = useDownloadDebugLog();
|
||||
|
||||
const optInDescription = (
|
||||
<Caption>
|
||||
<Trans>
|
||||
<AnalyticsNotice />
|
||||
<br />
|
||||
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.
|
||||
</Trans>
|
||||
</Caption>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("Settings")}
|
||||
@@ -78,7 +94,7 @@ export const SettingsModal = (props: Props) => {
|
||||
title={
|
||||
<>
|
||||
<AudioIcon width={16} height={16} />
|
||||
<span>{t("Audio")}</span>
|
||||
<span className={styles.tabLabel}>{t("Audio")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -116,16 +132,16 @@ export const SettingsModal = (props: Props) => {
|
||||
label={t("Spatial audio")}
|
||||
type="checkbox"
|
||||
checked={spatialAudio}
|
||||
disabled={!canEnableSpatialAudio()}
|
||||
disabled={setSpatialAudio === null}
|
||||
description={
|
||||
canEnableSpatialAudio()
|
||||
? t(
|
||||
setSpatialAudio === null
|
||||
? t("This feature is only supported on Firefox.")
|
||||
: t(
|
||||
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
|
||||
)
|
||||
: t("This feature is only supported on Firefox.")
|
||||
}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSpatialAudio(event.target.checked)
|
||||
setSpatialAudio!(event.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
@@ -156,19 +172,17 @@ export const SettingsModal = (props: Props) => {
|
||||
title={
|
||||
<>
|
||||
<OverflowIcon width={16} height={16} />
|
||||
<span>{t("Advanced")}</span>
|
||||
<span>{t("More")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<h4>Analytics</h4>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="optInAnalytics"
|
||||
label={t("Allow analytics")}
|
||||
type="checkbox"
|
||||
checked={optInAnalytics}
|
||||
description={t(
|
||||
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used."
|
||||
)}
|
||||
description={optInDescription}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setOptInAnalytics(event.target.checked)
|
||||
}
|
||||
@@ -176,52 +190,65 @@ export const SettingsModal = (props: Props) => {
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="keyboardShortcuts"
|
||||
label={t("Single-key keyboard shortcuts")}
|
||||
id="developerSettingsTab"
|
||||
type="checkbox"
|
||||
checked={keyboardShortcuts}
|
||||
checked={developerSettingsTab}
|
||||
label={t("Developer Settings")}
|
||||
description={t(
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic."
|
||||
"Expose developer settings in the settings window."
|
||||
)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setKeyboardShortcuts(event.target.checked)
|
||||
setDeveloperSettingsTab(event.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
</TabItem>
|
||||
<TabItem
|
||||
title={
|
||||
<>
|
||||
<DeveloperIcon width={16} height={16} />
|
||||
<span>{t("Developer")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FieldRow>
|
||||
<Body className={styles.fieldRowText}>
|
||||
{t("Version: {{version}}", {
|
||||
version: import.meta.env.VITE_APP_VERSION || "dev",
|
||||
})}
|
||||
</Body>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="showInspector"
|
||||
name="inspector"
|
||||
label={t("Show call inspector")}
|
||||
type="checkbox"
|
||||
checked={showInspector}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setShowInspector(e.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Button onPress={downloadDebugLog}>
|
||||
{t("Download debug logs")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
</TabItem>
|
||||
{developerSettingsTab && (
|
||||
<TabItem
|
||||
title={
|
||||
<>
|
||||
<DeveloperIcon width={16} height={16} />
|
||||
<span>{t("Developer")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FieldRow>
|
||||
<Body className={styles.fieldRowText}>
|
||||
{t("Version: {{version}}", {
|
||||
version: import.meta.env.VITE_APP_VERSION || "dev",
|
||||
})}
|
||||
</Body>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="showInspector"
|
||||
name="inspector"
|
||||
label={t("Show call inspector")}
|
||||
type="checkbox"
|
||||
checked={showInspector}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setShowInspector(e.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="newGrid"
|
||||
label={t("Use the upcoming grid system")}
|
||||
type="checkbox"
|
||||
checked={newGrid}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNewGrid(e.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Button onPress={downloadDebugLog}>
|
||||
{t("Download debug logs")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
</TabItem>
|
||||
)}
|
||||
</TabContainer>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
/*
|
||||
Copyright 2017 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2019 The New Vector Ltd
|
||||
@@ -54,15 +37,23 @@ limitations under the License.
|
||||
// actually timestamps. We then purge the remaining logs. We also do this
|
||||
// purge on startup to prevent logs from accumulating.
|
||||
|
||||
import EventEmitter from "events";
|
||||
import { throttle } from "lodash";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
// the frequency with which we flush to indexeddb
|
||||
const FLUSH_RATE_MS = 30 * 1000;
|
||||
|
||||
// the length of log data we keep in indexeddb (and include in the reports)
|
||||
const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB
|
||||
|
||||
// Shortest amount of time between flushes. We are just appending to an
|
||||
// IndexedDB table so we don't expect flushing to be that expensive, but
|
||||
// we can batch the writes a little.
|
||||
const MAX_FLUSH_INTERVAL_MS = 2 * 1000;
|
||||
|
||||
enum ConsoleLoggerEvent {
|
||||
Log = "log",
|
||||
}
|
||||
|
||||
type LogFunction = (
|
||||
...args: (Error | DOMException | object | string)[]
|
||||
) => void;
|
||||
@@ -76,7 +67,7 @@ interface LogEntry {
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export class ConsoleLogger {
|
||||
export class ConsoleLogger extends EventEmitter {
|
||||
private logs = "";
|
||||
private originalFunctions: { [key in LogFunctionName]?: LogFunction } = {};
|
||||
|
||||
@@ -99,13 +90,6 @@ export class ConsoleLogger {
|
||||
});
|
||||
}
|
||||
|
||||
public bypassRageshake(
|
||||
fnName: LogFunctionName,
|
||||
...args: (Error | DOMException | object | string)[]
|
||||
): void {
|
||||
this.originalFunctions[fnName](...args);
|
||||
}
|
||||
|
||||
public log(
|
||||
level: string,
|
||||
...args: (Error | DOMException | object | string)[]
|
||||
@@ -137,23 +121,27 @@ export class ConsoleLogger {
|
||||
// Using + really is the quickest way in JS
|
||||
// http://jsperf.com/concat-vs-plus-vs-join
|
||||
this.logs += line;
|
||||
|
||||
this.emit(ConsoleLoggerEvent.Log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve log lines to flush to disk.
|
||||
* @param {boolean} keepLogs True to not delete logs after flushing.
|
||||
* @return {string} \n delimited log lines to flush.
|
||||
* Returns the log lines to flush to disk and empties the internal log buffer
|
||||
* @return {string} \n delimited log lines
|
||||
*/
|
||||
public flush(keepLogs?: boolean): string {
|
||||
// The ConsoleLogger doesn't care how these end up on disk, it just
|
||||
// flushes them to the caller.
|
||||
if (keepLogs) {
|
||||
return this.logs;
|
||||
}
|
||||
public popLogs(): string {
|
||||
const logsToFlush = this.logs;
|
||||
this.logs = "";
|
||||
return logsToFlush;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns lines currently in the log buffer without removing them
|
||||
* @return {string} \n delimited log lines
|
||||
*/
|
||||
public peekLogs(): string {
|
||||
return this.logs;
|
||||
}
|
||||
}
|
||||
|
||||
// A class which stores log lines in an IndexedDB instance.
|
||||
@@ -164,8 +152,14 @@ export class IndexedDBLogStore {
|
||||
private flushAgainPromise: Promise<void> = null;
|
||||
private id: string;
|
||||
|
||||
constructor(private indexedDB: IDBFactory, private logger: ConsoleLogger) {
|
||||
constructor(
|
||||
private indexedDB: IDBFactory,
|
||||
private loggerInstance: ConsoleLogger
|
||||
) {
|
||||
this.id = "instance-" + randomString(16);
|
||||
|
||||
loggerInstance.on(ConsoleLoggerEvent.Log, this.onLoggerLog);
|
||||
window.addEventListener("beforeunload", this.flush);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,30 +168,31 @@ export class IndexedDBLogStore {
|
||||
public connect(): Promise<void> {
|
||||
const req = this.indexedDB.open("logs");
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = (event: Event) => {
|
||||
// @ts-ignore
|
||||
this.db = event.target.result;
|
||||
// Periodically flush logs to local storage / indexeddb
|
||||
setInterval(this.flush.bind(this), FLUSH_RATE_MS);
|
||||
req.onsuccess = () => {
|
||||
this.db = req.result;
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
req.onerror = (event) => {
|
||||
const err =
|
||||
// @ts-ignore
|
||||
"Failed to open log database: " + event.target.error.name;
|
||||
req.onerror = () => {
|
||||
const err = "Failed to open log database: " + req.error.name;
|
||||
logger.error(err);
|
||||
reject(new Error(err));
|
||||
};
|
||||
|
||||
// First time: Setup the object store
|
||||
req.onupgradeneeded = (event) => {
|
||||
// @ts-ignore
|
||||
const db = event.target.result;
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
// This is the log entries themselves. Each entry is a chunk of
|
||||
// logs (ie multiple lines). 'id' is the instance ID (so logs with
|
||||
// the same instance ID are all from the same session) and 'index'
|
||||
// is a sequence number for the chunk. The log lines live in the
|
||||
// 'lines' key, which is a chunk of many newline-separated log lines.
|
||||
const logObjStore = db.createObjectStore("logs", {
|
||||
keyPath: ["id", "index"],
|
||||
});
|
||||
// Keys in the database look like: [ "instance-148938490", 0 ]
|
||||
// (The instance ID plus the ID of each log chunk).
|
||||
// Later on we need to query everything based on an instance id.
|
||||
// In order to do this, we need to set up indexes "id".
|
||||
logObjStore.createIndex("id", "id", { unique: false });
|
||||
@@ -206,6 +201,9 @@ export class IndexedDBLogStore {
|
||||
this.generateLogEntry(new Date() + " ::: Log database was created.")
|
||||
);
|
||||
|
||||
// This records the last time each instance ID generated a log message, such
|
||||
// that the logs from each session can be collated in the order they last logged
|
||||
// something.
|
||||
const lastModifiedStore = db.createObjectStore("logslastmod", {
|
||||
keyPath: "id",
|
||||
});
|
||||
@@ -214,6 +212,26 @@ export class IndexedDBLogStore {
|
||||
});
|
||||
}
|
||||
|
||||
private onLoggerLog = () => {
|
||||
if (!this.db) return;
|
||||
|
||||
this.throttledFlush();
|
||||
};
|
||||
|
||||
// Throttled function to flush logs. We use throttle rather
|
||||
// than debounce as we want logs to be written regularly, otherwise
|
||||
// if there's a constant stream of logging, we'd never write anything.
|
||||
private throttledFlush = throttle(
|
||||
() => {
|
||||
this.flush();
|
||||
},
|
||||
MAX_FLUSH_INTERVAL_MS,
|
||||
{
|
||||
leading: false,
|
||||
trailing: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Flush logs to disk.
|
||||
*
|
||||
@@ -233,7 +251,7 @@ export class IndexedDBLogStore {
|
||||
*
|
||||
* @return {Promise} Resolved when the logs have been flushed.
|
||||
*/
|
||||
public flush(): Promise<void> {
|
||||
public flush = (): Promise<void> => {
|
||||
// check if a flush() operation is ongoing
|
||||
if (this.flushPromise) {
|
||||
if (this.flushAgainPromise) {
|
||||
@@ -258,20 +276,19 @@ export class IndexedDBLogStore {
|
||||
reject(new Error("No connected database"));
|
||||
return;
|
||||
}
|
||||
const lines = this.logger.flush();
|
||||
const lines = this.loggerInstance.popLogs();
|
||||
if (lines.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const txn = this.db.transaction(["logs", "logslastmod"], "readwrite");
|
||||
const objStore = txn.objectStore("logs");
|
||||
txn.oncomplete = (event) => {
|
||||
txn.oncomplete = () => {
|
||||
resolve();
|
||||
};
|
||||
txn.onerror = (event) => {
|
||||
logger.error("Failed to flush logs : ", event);
|
||||
// @ts-ignore
|
||||
reject(new Error("Failed to write logs: " + event.target.errorCode));
|
||||
reject(new Error("Failed to write logs: " + txn.error.message));
|
||||
};
|
||||
objStore.add(this.generateLogEntry(lines));
|
||||
const lastModStore = txn.objectStore("logslastmod");
|
||||
@@ -280,7 +297,7 @@ export class IndexedDBLogStore {
|
||||
this.flushPromise = null;
|
||||
});
|
||||
return this.flushPromise;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Consume the most recent logs and return them. Older logs which are not
|
||||
@@ -307,13 +324,11 @@ export class IndexedDBLogStore {
|
||||
.index("id")
|
||||
.openCursor(IDBKeyRange.only(id), "prev");
|
||||
let lines = "";
|
||||
query.onerror = (event) => {
|
||||
// @ts-ignore
|
||||
reject(new Error("Query failed: " + event.target.errorCode));
|
||||
query.onerror = () => {
|
||||
reject(new Error("Query failed: " + query.error.message));
|
||||
};
|
||||
query.onsuccess = (event) => {
|
||||
// @ts-ignore
|
||||
const cursor = event.target.result;
|
||||
query.onsuccess = () => {
|
||||
const cursor = query.result;
|
||||
if (!cursor) {
|
||||
resolve(lines);
|
||||
return; // end of results
|
||||
@@ -355,9 +370,8 @@ export class IndexedDBLogStore {
|
||||
const o = txn.objectStore("logs");
|
||||
// only load the key path, not the data which may be huge
|
||||
const query = o.index("id").openKeyCursor(IDBKeyRange.only(id));
|
||||
query.onsuccess = (event) => {
|
||||
// @ts-ignore
|
||||
const cursor = event.target.result;
|
||||
query.onsuccess = () => {
|
||||
const cursor = query.result;
|
||||
if (!cursor) {
|
||||
return;
|
||||
}
|
||||
@@ -367,12 +381,10 @@ export class IndexedDBLogStore {
|
||||
txn.oncomplete = () => {
|
||||
resolve();
|
||||
};
|
||||
txn.onerror = (event) => {
|
||||
txn.onerror = () => {
|
||||
reject(
|
||||
new Error(
|
||||
"Failed to delete logs for " +
|
||||
// @ts-ignore
|
||||
`'${id}' : ${event.target.errorCode}`
|
||||
"Failed to delete logs for " + `'${id}' : ${txn.error.message}`
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -456,14 +468,12 @@ function selectQuery<T>(
|
||||
const query = store.openCursor(keyRange);
|
||||
return new Promise((resolve, reject) => {
|
||||
const results = [];
|
||||
query.onerror = (event) => {
|
||||
// @ts-ignore
|
||||
reject(new Error("Query failed: " + event.target.errorCode));
|
||||
query.onerror = () => {
|
||||
reject(new Error("Query failed: " + query.error.message));
|
||||
};
|
||||
// collect results
|
||||
query.onsuccess = (event) => {
|
||||
// @ts-ignore
|
||||
const cursor = event.target.result;
|
||||
query.onsuccess = () => {
|
||||
const cursor = query.result;
|
||||
if (!cursor) {
|
||||
resolve(results);
|
||||
return; // end of results
|
||||
@@ -479,8 +489,6 @@ declare global {
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_logger: ConsoleLogger;
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_initPromise: Promise<void>;
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_initStoragePromise: Promise<void>;
|
||||
}
|
||||
|
||||
@@ -491,19 +499,11 @@ declare global {
|
||||
* be set up immediately for the logs.
|
||||
* @return {Promise} Resolves when set up.
|
||||
*/
|
||||
export function init(setUpPersistence = true): Promise<void> {
|
||||
if (global.mx_rage_initPromise) {
|
||||
return global.mx_rage_initPromise;
|
||||
}
|
||||
export function init(): Promise<void> {
|
||||
global.mx_rage_logger = new ConsoleLogger();
|
||||
global.mx_rage_logger.monkeyPatch(window.console);
|
||||
|
||||
if (setUpPersistence) {
|
||||
return tryInitStorage();
|
||||
}
|
||||
|
||||
global.mx_rage_initPromise = Promise.resolve();
|
||||
return global.mx_rage_initPromise;
|
||||
return tryInitStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,7 +573,7 @@ export async function getLogsForReport(): Promise<LogEntry[]> {
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
lines: global.mx_rage_logger.flush(true),
|
||||
lines: global.mx_rage_logger.peekLogs(),
|
||||
id: "-",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -25,6 +25,14 @@ import { useClient } from "../ClientContext";
|
||||
import { InspectorContext } from "../room/GroupCallInspector";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { Config } from "../config/Config";
|
||||
import { ElementCallOpenTelemetry } from "../otel/otel";
|
||||
|
||||
const gzip = (text: string): Blob => {
|
||||
// encode as UTF-8
|
||||
const buf = new TextEncoder().encode(text);
|
||||
// compress
|
||||
return new Blob([pako.gzip(buf)]);
|
||||
};
|
||||
|
||||
interface RageShakeSubmitOptions {
|
||||
sendLogs: boolean;
|
||||
@@ -235,14 +243,15 @@ export function useSubmitRageshake(): {
|
||||
const logs = await getLogsForReport();
|
||||
|
||||
for (const entry of logs) {
|
||||
// encode as UTF-8
|
||||
let buf = new TextEncoder().encode(entry.lines);
|
||||
// compress
|
||||
buf = pako.gzip(buf);
|
||||
|
||||
body.append("compressed-log", new Blob([buf]), entry.id);
|
||||
body.append("compressed-log", gzip(entry.lines), entry.id);
|
||||
}
|
||||
|
||||
body.append(
|
||||
"file",
|
||||
gzip(ElementCallOpenTelemetry.instance.rageshakeProcessor!.dump()),
|
||||
"traces.json.gz"
|
||||
);
|
||||
|
||||
if (inspectorState) {
|
||||
body.append(
|
||||
"file",
|
||||
|
||||
@@ -32,7 +32,6 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -41,18 +40,26 @@ import React, {
|
||||
useContext,
|
||||
createContext,
|
||||
ReactNode,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import { getNamedDevices } from "../media-utils";
|
||||
|
||||
export interface MediaHandlerContextInterface {
|
||||
audioInput: string;
|
||||
audioInput: string | undefined;
|
||||
audioInputs: MediaDeviceInfo[];
|
||||
setAudioInput: (deviceId: string) => void;
|
||||
videoInput: string;
|
||||
videoInput: string | undefined;
|
||||
videoInputs: MediaDeviceInfo[];
|
||||
setVideoInput: (deviceId: string) => void;
|
||||
audioOutput: string;
|
||||
audioOutput: string | undefined;
|
||||
audioOutputs: MediaDeviceInfo[];
|
||||
setAudioOutput: (deviceId: string) => void;
|
||||
/**
|
||||
* A hook which requests for devices to be named. This requires media
|
||||
* permissions.
|
||||
*/
|
||||
useDeviceNames: () => void;
|
||||
}
|
||||
|
||||
const MediaHandlerContext =
|
||||
@@ -70,10 +77,10 @@ function getMediaPreferences(): MediaPreferences {
|
||||
try {
|
||||
return JSON.parse(mediaPreferences);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,112 +110,98 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
|
||||
audioOutputs,
|
||||
},
|
||||
setState,
|
||||
] = useState(() => {
|
||||
const mediaPreferences = getMediaPreferences();
|
||||
const mediaHandler = client.getMediaHandler();
|
||||
] = useState(() => ({
|
||||
audioInput: undefined as string | undefined,
|
||||
videoInput: undefined as string | undefined,
|
||||
audioOutput: undefined as string | undefined,
|
||||
audioInputs: [] as MediaDeviceInfo[],
|
||||
videoInputs: [] as MediaDeviceInfo[],
|
||||
audioOutputs: [] as MediaDeviceInfo[],
|
||||
}));
|
||||
|
||||
mediaHandler.restoreMediaSettings(
|
||||
mediaPreferences?.audioInput,
|
||||
mediaPreferences?.videoInput
|
||||
);
|
||||
// A ref counting the number of components currently mounted that want
|
||||
// to know device names
|
||||
const numComponentsWantingNames = useRef(0);
|
||||
|
||||
return {
|
||||
// @ts-ignore, ignore that audioInput is a private members of mediaHandler
|
||||
audioInput: mediaHandler.audioInput,
|
||||
// @ts-ignore, ignore that videoInput is a private members of mediaHandler
|
||||
videoInput: mediaHandler.videoInput,
|
||||
audioOutput: undefined,
|
||||
audioInputs: [],
|
||||
videoInputs: [],
|
||||
audioOutputs: [],
|
||||
};
|
||||
});
|
||||
const updateDevices = useCallback(
|
||||
async (initial: boolean) => {
|
||||
// Only request device names if components actually want them, because it
|
||||
// could trigger an extra permission pop-up
|
||||
const devices = await (numComponentsWantingNames.current > 0
|
||||
? getNamedDevices()
|
||||
: navigator.mediaDevices.enumerateDevices());
|
||||
const mediaPreferences = getMediaPreferences();
|
||||
|
||||
const audioInputs = devices.filter((d) => d.kind === "audioinput");
|
||||
const videoInputs = devices.filter((d) => d.kind === "videoinput");
|
||||
const audioOutputs = devices.filter((d) => d.kind === "audiooutput");
|
||||
|
||||
const audioInput = (
|
||||
mediaPreferences.audioInput === undefined
|
||||
? audioInputs.at(0)
|
||||
: audioInputs.find(
|
||||
(d) => d.deviceId === mediaPreferences.audioInput
|
||||
) ?? audioInputs.at(0)
|
||||
)?.deviceId;
|
||||
const videoInput = (
|
||||
mediaPreferences.videoInput === undefined
|
||||
? videoInputs.at(0)
|
||||
: videoInputs.find(
|
||||
(d) => d.deviceId === mediaPreferences.videoInput
|
||||
) ?? videoInputs.at(0)
|
||||
)?.deviceId;
|
||||
const audioOutput =
|
||||
mediaPreferences.audioOutput === undefined
|
||||
? undefined
|
||||
: audioOutputs.find(
|
||||
(d) => d.deviceId === mediaPreferences.audioOutput
|
||||
)?.deviceId;
|
||||
|
||||
updateMediaPreferences({ audioInput, videoInput, audioOutput });
|
||||
setState({
|
||||
audioInput,
|
||||
videoInput,
|
||||
audioOutput,
|
||||
audioInputs,
|
||||
videoInputs,
|
||||
audioOutputs,
|
||||
});
|
||||
|
||||
if (
|
||||
initial ||
|
||||
audioInput !== mediaPreferences.audioInput ||
|
||||
videoInput !== mediaPreferences.videoInput
|
||||
) {
|
||||
client.getMediaHandler().setMediaInputs(audioInput, videoInput);
|
||||
}
|
||||
},
|
||||
[client, setState]
|
||||
);
|
||||
|
||||
const useDeviceNames = useCallback(() => {
|
||||
// This is a little weird from React's perspective as it looks like a
|
||||
// dynamic hook, but it works
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
numComponentsWantingNames.current++;
|
||||
if (numComponentsWantingNames.current === 1) updateDevices(false);
|
||||
return () => void numComponentsWantingNames.current--;
|
||||
}, []);
|
||||
}, [updateDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaHandler = client.getMediaHandler();
|
||||
|
||||
function updateDevices(): void {
|
||||
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
||||
const mediaPreferences = getMediaPreferences();
|
||||
|
||||
const audioInputs = devices.filter(
|
||||
(device) => device.kind === "audioinput"
|
||||
);
|
||||
const audioConnected = audioInputs.some(
|
||||
// @ts-ignore
|
||||
(device) => device.deviceId === mediaHandler.audioInput
|
||||
);
|
||||
// @ts-ignore
|
||||
let audioInput = mediaHandler.audioInput;
|
||||
|
||||
if (!audioConnected && audioInputs.length > 0) {
|
||||
audioInput = audioInputs[0].deviceId;
|
||||
}
|
||||
|
||||
const videoInputs = devices.filter(
|
||||
(device) => device.kind === "videoinput"
|
||||
);
|
||||
const videoConnected = videoInputs.some(
|
||||
// @ts-ignore
|
||||
(device) => device.deviceId === mediaHandler.videoInput
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
let videoInput = mediaHandler.videoInput;
|
||||
|
||||
if (!videoConnected && videoInputs.length > 0) {
|
||||
videoInput = videoInputs[0].deviceId;
|
||||
}
|
||||
|
||||
const audioOutputs = devices.filter(
|
||||
(device) => device.kind === "audiooutput"
|
||||
);
|
||||
let audioOutput = undefined;
|
||||
|
||||
if (
|
||||
mediaPreferences &&
|
||||
audioOutputs.some(
|
||||
(device) => device.deviceId === mediaPreferences.audioOutput
|
||||
)
|
||||
) {
|
||||
audioOutput = mediaPreferences.audioOutput;
|
||||
}
|
||||
|
||||
if (
|
||||
// @ts-ignore
|
||||
(mediaHandler.videoInput && mediaHandler.videoInput !== videoInput) ||
|
||||
// @ts-ignore
|
||||
mediaHandler.audioInput !== audioInput
|
||||
) {
|
||||
mediaHandler.setMediaInputs(audioInput, videoInput);
|
||||
}
|
||||
|
||||
updateMediaPreferences({ audioInput, videoInput, audioOutput });
|
||||
|
||||
setState({
|
||||
audioInput,
|
||||
videoInput,
|
||||
audioOutput,
|
||||
audioInputs,
|
||||
videoInputs,
|
||||
audioOutputs,
|
||||
});
|
||||
});
|
||||
}
|
||||
updateDevices();
|
||||
|
||||
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices);
|
||||
navigator.mediaDevices.addEventListener("devicechange", updateDevices);
|
||||
updateDevices(true);
|
||||
const onDeviceChange = () => updateDevices(false);
|
||||
navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
|
||||
|
||||
return () => {
|
||||
mediaHandler.removeListener(
|
||||
MediaHandlerEvent.LocalStreamsChanged,
|
||||
updateDevices
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
"devicechange",
|
||||
onDeviceChange
|
||||
);
|
||||
navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
|
||||
mediaHandler.stopAllStreams();
|
||||
client.getMediaHandler().stopAllStreams();
|
||||
};
|
||||
}, [client]);
|
||||
}, [client, updateDevices]);
|
||||
|
||||
const setAudioInput: (deviceId: string) => void = useCallback(
|
||||
(deviceId: string) => {
|
||||
@@ -245,6 +238,7 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
useDeviceNames,
|
||||
}),
|
||||
[
|
||||
audioInput,
|
||||
@@ -256,6 +250,7 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
useDeviceNames,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ limitations under the License.
|
||||
import { EventEmitter } from "events";
|
||||
import { useMemo, useState, useEffect, useCallback } from "react";
|
||||
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
|
||||
type Setting<T> = [T, (value: T) => void];
|
||||
type DisableableSetting<T> = [T, ((value: T) => void) | null];
|
||||
|
||||
// Bus to notify other useSetting consumers when a setting is changed
|
||||
export const settingsBus = new EventEmitter();
|
||||
|
||||
@@ -24,10 +29,7 @@ const getSettingKey = (name: string): string => {
|
||||
return `matrix-setting-${name}`;
|
||||
};
|
||||
// Like useState, but reads from and persists the value to localStorage
|
||||
const useSetting = <T>(
|
||||
name: string,
|
||||
defaultValue: T
|
||||
): [T, (value: T) => void] => {
|
||||
const useSetting = <T>(name: string, defaultValue: T): Setting<T> => {
|
||||
const key = useMemo(() => getSettingKey(name), [name]);
|
||||
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
@@ -65,7 +67,7 @@ export const setSetting = <T>(name: string, newValue: T) => {
|
||||
settingsBus.emit(name, newValue);
|
||||
};
|
||||
|
||||
export const canEnableSpatialAudio = () => {
|
||||
const canEnableSpatialAudio = () => {
|
||||
const { userAgent } = navigator;
|
||||
// Spatial audio means routing audio through audio contexts. On Chrome,
|
||||
// this bypasses the AEC processor and so breaks echo cancellation.
|
||||
@@ -79,14 +81,24 @@ export const canEnableSpatialAudio = () => {
|
||||
return userAgent.includes("Firefox");
|
||||
};
|
||||
|
||||
export const useSpatialAudio = (): [boolean, (val: boolean) => void] => {
|
||||
export const useSpatialAudio = (): DisableableSetting<boolean> => {
|
||||
const settingVal = useSetting("spatial-audio", false);
|
||||
if (canEnableSpatialAudio()) return settingVal;
|
||||
|
||||
return [false, (_: boolean) => {}];
|
||||
return [false, null];
|
||||
};
|
||||
|
||||
export const useShowInspector = () => useSetting("show-inspector", false);
|
||||
export const useOptInAnalytics = () => useSetting("opt-in-analytics", false);
|
||||
export const useKeyboardShortcuts = () =>
|
||||
useSetting("keyboard-shortcuts", true);
|
||||
|
||||
// null = undecided
|
||||
export const useOptInAnalytics = (): DisableableSetting<boolean | null> => {
|
||||
const settingVal = useSetting<boolean | null>("opt-in-analytics", null);
|
||||
if (PosthogAnalytics.instance.isEnabled()) return settingVal;
|
||||
|
||||
return [false, null];
|
||||
};
|
||||
|
||||
export const useNewGrid = () => useSetting("new-grid", false);
|
||||
|
||||
export const useDeveloperSettingsTab = () =>
|
||||
useSetting("developer-settings-tab", false);
|
||||
|
||||
@@ -88,7 +88,9 @@ limitations under the License.
|
||||
.tabContainer {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
margin: 27px 16px;
|
||||
padding: 27px 20px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabList {
|
||||
|
||||
97
src/useCallViewKeyboardShortcuts.ts
Normal file
97
src/useCallViewKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Copyright 2022-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 { RefObject, useCallback, useRef } from "react";
|
||||
|
||||
import { useEventTarget } from "./useEvents";
|
||||
|
||||
/**
|
||||
* Determines whether focus is in the same part of the tree as the given
|
||||
* element (specifically, if an ancestor or descendant of it is focused).
|
||||
*/
|
||||
const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
|
||||
const focusedElement = document.activeElement;
|
||||
return (
|
||||
focusedElement !== null &&
|
||||
(focusedElement.contains(e) || e.contains(focusedElement))
|
||||
);
|
||||
};
|
||||
|
||||
export function useCallViewKeyboardShortcuts(
|
||||
focusElement: RefObject<HTMLElement | null>,
|
||||
toggleMicrophoneMuted: () => void,
|
||||
toggleLocalVideoMuted: () => void,
|
||||
setMicrophoneMuted: (muted: boolean) => void
|
||||
) {
|
||||
const spacebarHeld = useRef(false);
|
||||
|
||||
// These event handlers are set on the window because we want users to be able
|
||||
// to trigger them without going to the trouble of focusing something
|
||||
|
||||
useEventTarget(
|
||||
window,
|
||||
"keydown",
|
||||
useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (focusElement.current === null) return;
|
||||
if (!mayReceiveKeyEvents(focusElement.current)) return;
|
||||
|
||||
if (event.key === "m") {
|
||||
toggleMicrophoneMuted();
|
||||
} else if (event.key == "v") {
|
||||
toggleLocalVideoMuted();
|
||||
} else if (event.key === " " && !spacebarHeld.current) {
|
||||
spacebarHeld.current = true;
|
||||
setMicrophoneMuted(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
focusElement,
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
setMicrophoneMuted,
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
useEventTarget(
|
||||
window,
|
||||
"keyup",
|
||||
useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (focusElement.current === null) return;
|
||||
if (!mayReceiveKeyEvents(focusElement.current)) return;
|
||||
|
||||
if (event.key === " ") {
|
||||
spacebarHeld.current = false;
|
||||
setMicrophoneMuted(true);
|
||||
}
|
||||
},
|
||||
[focusElement, setMicrophoneMuted]
|
||||
)
|
||||
);
|
||||
|
||||
useEventTarget(
|
||||
window,
|
||||
"blur",
|
||||
useCallback(() => {
|
||||
if (spacebarHeld.current) {
|
||||
spacebarHeld.current = false;
|
||||
setMicrophoneMuted(true);
|
||||
}
|
||||
}, [setMicrophoneMuted, spacebarHeld])
|
||||
);
|
||||
}
|
||||
39
src/useMergedRefs.ts
Normal file
39
src/useMergedRefs.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
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 { MutableRefObject, RefCallback, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Combines multiple refs into one, useful for attaching multiple refs to the
|
||||
* same DOM node.
|
||||
*/
|
||||
export const useMergedRefs = <T>(
|
||||
...refs: (MutableRefObject<T | null> | RefCallback<T | null>)[]
|
||||
): RefCallback<T | null> =>
|
||||
useCallback(
|
||||
(value) =>
|
||||
refs.forEach((ref) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
} else {
|
||||
ref.current = value;
|
||||
}
|
||||
}),
|
||||
// Since this isn't an array literal, we can't use the static dependency
|
||||
// checker, but that's okay
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
refs
|
||||
);
|
||||
67
src/useReactiveState.ts
Normal file
67
src/useReactiveState.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
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 {
|
||||
DependencyList,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Hook creating a stateful value that updates automatically whenever the
|
||||
* dependencies change. Or equivalently, a version of useMemo that takes its own
|
||||
* previous value as an input, and can be updated manually.
|
||||
*/
|
||||
export const useReactiveState = <T>(
|
||||
updateFn: (prevState?: T) => T,
|
||||
deps: DependencyList
|
||||
): [T, Dispatch<SetStateAction<T>>] => {
|
||||
const state = useRef<T>();
|
||||
if (state.current === undefined) state.current = updateFn();
|
||||
const prevDeps = useRef<DependencyList>();
|
||||
|
||||
// Since we store the state in a ref, we use this counter to force an update
|
||||
// when someone calls setState
|
||||
const [, setNumUpdates] = useState(0);
|
||||
|
||||
// If this is the first render or the deps have changed, recalculate the state
|
||||
if (
|
||||
prevDeps.current === undefined ||
|
||||
deps.length !== prevDeps.current.length ||
|
||||
deps.some((d, i) => d !== prevDeps.current![i])
|
||||
) {
|
||||
state.current = updateFn(state.current);
|
||||
}
|
||||
prevDeps.current = deps;
|
||||
|
||||
return [
|
||||
state.current,
|
||||
useCallback(
|
||||
(action) => {
|
||||
if (typeof action === "function") {
|
||||
state.current = (action as (prevValue: T) => T)(state.current!);
|
||||
} else {
|
||||
state.current = action;
|
||||
}
|
||||
setNumUpdates((n) => n + 1); // Force an update
|
||||
},
|
||||
[setNumUpdates]
|
||||
),
|
||||
];
|
||||
};
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
import { useCallFeed } from "./useCallFeed";
|
||||
import { useMediaStream } from "./useMediaStream";
|
||||
@@ -23,6 +24,7 @@ import { useMediaStream } from "./useMediaStream";
|
||||
interface Props {
|
||||
tileDescriptor: TileDescriptor;
|
||||
audioOutput: string;
|
||||
otelGroupCallMembership?: OTelGroupCallMembership;
|
||||
}
|
||||
|
||||
// Renders and <audio> element on the page playing the given stream
|
||||
@@ -30,15 +32,20 @@ interface Props {
|
||||
export const AudioSink: React.FC<Props> = ({
|
||||
tileDescriptor,
|
||||
audioOutput,
|
||||
otelGroupCallMembership,
|
||||
}: Props) => {
|
||||
const { audioMuted, localVolume, stream } = useCallFeed(
|
||||
tileDescriptor.callFeed
|
||||
const { localVolume, stream } = useCallFeed(
|
||||
tileDescriptor.callFeed,
|
||||
otelGroupCallMembership
|
||||
);
|
||||
|
||||
const audioElementRef = useMediaStream(
|
||||
stream,
|
||||
audioOutput,
|
||||
audioMuted,
|
||||
// We don't compare the audioMuted flag of useCallFeed here, since unmuting
|
||||
// depends on to-device messages which may lag behind the audio actually
|
||||
// starting to flow over the stream
|
||||
tileDescriptor.isLocal,
|
||||
localVolume
|
||||
);
|
||||
|
||||
|
||||
48
src/video-grid/NewVideoGrid.module.css
Normal file
48
src/video-grid/NewVideoGrid.module.css
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.grid {
|
||||
contain: strict;
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
padding: 0 20px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.slotGrid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-auto-rows: 163px;
|
||||
gap: 8px;
|
||||
padding-bottom: var(--footerHeight);
|
||||
}
|
||||
|
||||
.slot {
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.grid {
|
||||
padding: 0 22px;
|
||||
}
|
||||
|
||||
.slotGrid {
|
||||
grid-auto-rows: 183px;
|
||||
column-gap: 18px;
|
||||
row-gap: 21px;
|
||||
}
|
||||
}
|
||||
462
src/video-grid/NewVideoGrid.tsx
Normal file
462
src/video-grid/NewVideoGrid.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
/*
|
||||
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 { SpringRef, TransitionFn, useTransition } from "@react-spring/web";
|
||||
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
|
||||
import React, {
|
||||
Dispatch,
|
||||
FC,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { zipWith } from "lodash";
|
||||
|
||||
import styles from "./NewVideoGrid.module.css";
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
import { VideoGridProps as Props, TileSpring } from "./VideoGrid";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import {
|
||||
Grid,
|
||||
Cell,
|
||||
row,
|
||||
column,
|
||||
fillGaps,
|
||||
forEachCellInArea,
|
||||
cycleTileSize,
|
||||
appendItems,
|
||||
} from "./model";
|
||||
|
||||
interface GridState extends Grid {
|
||||
/**
|
||||
* The ID of the current state of the grid.
|
||||
*/
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const useGridState = (
|
||||
columns: number | null,
|
||||
items: TileDescriptor[]
|
||||
): [GridState | null, Dispatch<SetStateAction<Grid>>] => {
|
||||
const [grid, setGrid_] = useReactiveState<GridState | null>(
|
||||
(prevGrid = null) => {
|
||||
if (prevGrid === null) {
|
||||
// We can't do anything if the column count isn't known yet
|
||||
if (columns === null) {
|
||||
return null;
|
||||
} else {
|
||||
prevGrid = { generation: 0, columns, cells: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Update tiles that still exist, and remove tiles that have left
|
||||
// the grid
|
||||
const itemsById = new Map(items.map((i) => [i.id, i]));
|
||||
const grid1: Grid = {
|
||||
...prevGrid,
|
||||
cells: prevGrid.cells.map((c) => {
|
||||
if (c === undefined) return undefined;
|
||||
const item = itemsById.get(c.item.id);
|
||||
return item === undefined ? undefined : { ...c, item };
|
||||
}),
|
||||
};
|
||||
|
||||
// Step 2: Backfill gaps left behind by removed tiles
|
||||
const grid2 = fillGaps(grid1);
|
||||
|
||||
// Step 3: Add new tiles to the end of the grid
|
||||
const existingItemIds = new Set(
|
||||
grid2.cells.filter((c) => c !== undefined).map((c) => c!.item.id)
|
||||
);
|
||||
const newItems = items.filter((i) => !existingItemIds.has(i.id));
|
||||
const grid3 = appendItems(newItems, grid2);
|
||||
|
||||
return { ...grid3, generation: prevGrid.generation + 1 };
|
||||
},
|
||||
[columns, items]
|
||||
);
|
||||
|
||||
const setGrid: Dispatch<SetStateAction<Grid>> = useCallback(
|
||||
(action) => {
|
||||
if (typeof action === "function") {
|
||||
setGrid_((prevGrid) =>
|
||||
prevGrid === null
|
||||
? null
|
||||
: {
|
||||
...(action as (prev: Grid) => Grid)(prevGrid),
|
||||
generation: prevGrid.generation + 1,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setGrid_((prevGrid) => ({
|
||||
...action,
|
||||
generation: prevGrid?.generation ?? 1,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[setGrid_]
|
||||
);
|
||||
|
||||
return [grid, setGrid];
|
||||
};
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Tile extends Rect {
|
||||
item: TileDescriptor;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
tileId: string;
|
||||
tileX: number;
|
||||
tileY: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An interactive, animated grid of video tiles.
|
||||
*/
|
||||
export const NewVideoGrid: FC<Props> = ({
|
||||
items,
|
||||
disableAnimations,
|
||||
children,
|
||||
}) => {
|
||||
// Overview: This component lays out tiles by rendering an invisible template
|
||||
// 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 template.
|
||||
|
||||
// To know when the rendered grid becomes consistent with the layout we've
|
||||
// requested, we give it a data-generation attribute which holds the ID of the
|
||||
// most recently rendered generation of the grid, and watch it with a
|
||||
// MutationObserver.
|
||||
|
||||
const [slotGrid, setSlotGrid] = useState<HTMLDivElement | null>(null);
|
||||
const [slotGridGeneration, setSlotGridGeneration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (slotGrid !== null) {
|
||||
setSlotGridGeneration(
|
||||
parseInt(slotGrid.getAttribute("data-generation")!)
|
||||
);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((m) => m.type === "attributes")) {
|
||||
setSlotGridGeneration(
|
||||
parseInt(slotGrid.getAttribute("data-generation")!)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(slotGrid, { attributes: true });
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [slotGrid, setSlotGridGeneration]);
|
||||
|
||||
const [gridRef1, gridBounds] = useMeasure();
|
||||
const gridRef2 = useRef<HTMLDivElement | null>(null);
|
||||
const gridRef = useMergedRefs(gridRef1, gridRef2);
|
||||
|
||||
const slotRects = useMemo(() => {
|
||||
if (slotGrid === null) return [];
|
||||
|
||||
const slots = slotGrid.getElementsByClassName(styles.slot);
|
||||
const rects = new Array<Rect>(slots.length);
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const slot = slots[i] as HTMLElement;
|
||||
rects[i] = {
|
||||
x: slot.offsetLeft,
|
||||
y: slot.offsetTop,
|
||||
width: slot.offsetWidth,
|
||||
height: slot.offsetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
return rects;
|
||||
// The rects may change due to the grid being resized or rerendered, but
|
||||
// eslint can't statically verify this
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slotGrid, slotGridGeneration, gridBounds]);
|
||||
|
||||
const [columns] = useReactiveState<number | null>(
|
||||
// Since grid resizing isn't implemented yet, pick a column count on mount
|
||||
// and stick to it
|
||||
(prevColumns) =>
|
||||
prevColumns !== undefined && prevColumns !== null
|
||||
? prevColumns
|
||||
: // The grid bounds might not be known yet
|
||||
gridBounds.width === 0
|
||||
? null
|
||||
: Math.max(2, Math.floor(gridBounds.width * 0.0045)),
|
||||
[gridBounds]
|
||||
);
|
||||
|
||||
const [grid, setGrid] = useGridState(columns, items);
|
||||
|
||||
const [tiles] = useReactiveState<Tile[]>(
|
||||
(prevTiles) => {
|
||||
// If React hasn't yet rendered the current generation of the grid, skip
|
||||
// the update, because grid and slotRects will be out of sync
|
||||
if (slotGridGeneration !== grid?.generation) return prevTiles ?? [];
|
||||
|
||||
const tileCells = grid.cells.filter((c) => c?.origin) as Cell[];
|
||||
const tileRects = new Map<TileDescriptor, Rect>(
|
||||
zipWith(tileCells, slotRects, (cell, rect) => [cell.item, rect])
|
||||
);
|
||||
return items.map((item) => ({ ...tileRects.get(item)!, item }));
|
||||
},
|
||||
[slotRects, grid, slotGridGeneration]
|
||||
);
|
||||
|
||||
// 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(
|
||||
tiles,
|
||||
() => ({
|
||||
key: ({ item }: Tile) => item.id,
|
||||
from: ({ x, y, width, height }: Tile) => ({
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
shadow: 1,
|
||||
shadowSpread: 0,
|
||||
zIndex: 1,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
immediate: disableAnimations,
|
||||
}),
|
||||
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
|
||||
update: ({ item, x, y, width, height }: Tile) =>
|
||||
item.id === dragState.current?.tileId
|
||||
? {}
|
||||
: {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
immediate: disableAnimations,
|
||||
},
|
||||
leave: { opacity: 0, scale: 0, immediate: disableAnimations },
|
||||
config: { mass: 0.7, tension: 252, friction: 25 },
|
||||
}),
|
||||
[tiles, disableAnimations]
|
||||
// react-spring's types are bugged and can't infer the spring type
|
||||
) as unknown as [TransitionFn<Tile, TileSpring>, SpringRef<TileSpring>];
|
||||
|
||||
const animateDraggedTile = (endOfGesture: boolean) => {
|
||||
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
|
||||
const tile = tiles.find((t) => t.item.id === tileId)!;
|
||||
|
||||
springRef.start((_i, controller) => {
|
||||
if ((controller.item as Tile).item.id === tileId) {
|
||||
if (endOfGesture) {
|
||||
return {
|
||||
scale: 1,
|
||||
zIndex: 1,
|
||||
shadow: 1,
|
||||
x: tile.x,
|
||||
y: tile.y,
|
||||
width: tile.width,
|
||||
height: tile.height,
|
||||
immediate: disableAnimations || ((key) => key === "zIndex"),
|
||||
// Allow the tile's position to settle before pushing its
|
||||
// z-index back down
|
||||
delay: (key) => (key === "zIndex" ? 500 : 0),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
scale: 1.1,
|
||||
zIndex: 2,
|
||||
shadow: 15,
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
immediate:
|
||||
disableAnimations ||
|
||||
((key) => key === "zIndex" || key === "x" || key === "y"),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const overTile = tiles.find(
|
||||
(t) =>
|
||||
cursorX >= t.x &&
|
||||
cursorX < t.x + t.width &&
|
||||
cursorY >= t.y &&
|
||||
cursorY < t.y + t.height
|
||||
);
|
||||
if (overTile !== undefined && overTile.item.id !== tileId) {
|
||||
setGrid((g) => ({
|
||||
...g!,
|
||||
cells: g!.cells.map((c) => {
|
||||
if (c?.item === overTile.item) return { ...c, item: tile.item };
|
||||
if (c?.item === tile.item) return { ...c, item: overTile.item };
|
||||
return c;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
{
|
||||
tap,
|
||||
initial: [initialX, initialY],
|
||||
delta: [dx, dy],
|
||||
last,
|
||||
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
||||
) => {
|
||||
if (tap) {
|
||||
setGrid((g) => cycleTileSize(tileId, g!));
|
||||
} else {
|
||||
const tileSpring = springRef.current
|
||||
.find((c) => (c.item as Tile).item.id === tileId)!
|
||||
.get();
|
||||
|
||||
if (dragState.current === null) {
|
||||
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);
|
||||
|
||||
if (last) dragState.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onTileDragRef = useRef(onTileDrag);
|
||||
onTileDragRef.current = onTileDrag;
|
||||
|
||||
const scrollOffset = useRef(0);
|
||||
|
||||
useScroll(
|
||||
({ xy: [, y], delta: [, dy] }) => {
|
||||
scrollOffset.current = y;
|
||||
|
||||
if (dragState.current !== null) {
|
||||
dragState.current.tileY += dy;
|
||||
dragState.current.cursorY += dy;
|
||||
animateDraggedTile(false);
|
||||
}
|
||||
},
|
||||
{ target: gridRef2 }
|
||||
);
|
||||
|
||||
const slotGridStyle = useMemo(() => {
|
||||
if (grid === null) return {};
|
||||
|
||||
const areas = new Array<(number | null)[]>(
|
||||
Math.ceil(grid.cells.length / grid.columns)
|
||||
);
|
||||
for (let i = 0; i < areas.length; i++)
|
||||
areas[i] = new Array<number | null>(grid.columns).fill(null);
|
||||
|
||||
let slotId = 0;
|
||||
for (let i = 0; i < grid.cells.length; i++) {
|
||||
const cell = grid.cells[i];
|
||||
if (cell?.origin) {
|
||||
const slotEnd = i + cell.columns - 1 + grid.columns * (cell.rows - 1);
|
||||
forEachCellInArea(
|
||||
i,
|
||||
slotEnd,
|
||||
grid,
|
||||
(_c, j) => (areas[row(j, grid)][column(j, grid)] = slotId)
|
||||
);
|
||||
slotId++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gridTemplateAreas: areas
|
||||
.map(
|
||||
(row) =>
|
||||
`'${row
|
||||
.map((slotId) => (slotId === null ? "." : `s${slotId}`))
|
||||
.join(" ")}'`
|
||||
)
|
||||
.join(" "),
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
};
|
||||
}, [grid, columns]);
|
||||
|
||||
const slots = useMemo(() => {
|
||||
const slots = new Array<ReactNode>(items.length);
|
||||
for (let i = 0; i < items.length; i++)
|
||||
slots[i] = (
|
||||
<div className={styles.slot} key={i} style={{ gridArea: `s${i}` }} />
|
||||
);
|
||||
return slots;
|
||||
}, [items.length]);
|
||||
|
||||
// Render nothing if the grid has yet to be generated
|
||||
if (grid === null) {
|
||||
return <div ref={gridRef} className={styles.grid} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={gridRef} className={styles.grid}>
|
||||
<div
|
||||
style={slotGridStyle}
|
||||
ref={setSlotGrid}
|
||||
className={styles.slotGrid}
|
||||
data-generation={grid.generation}
|
||||
>
|
||||
{slots}
|
||||
</div>
|
||||
{tileTransitions((style, tile) =>
|
||||
children({
|
||||
...style,
|
||||
key: tile.item.id,
|
||||
targetWidth: tile.width,
|
||||
targetHeight: tile.height,
|
||||
item: tile.item,
|
||||
onDragRef: onTileDragRef,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -19,4 +19,5 @@ limitations under the License.
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
touch-action: none;
|
||||
margin-bottom: var(--footerHeight);
|
||||
}
|
||||
|
||||
@@ -16,9 +16,14 @@ limitations under the License.
|
||||
|
||||
import React, { Key, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FullGestureState, useDrag, useGesture } from "@use-gesture/react";
|
||||
import { Interpolation, SpringValue, useSprings } from "@react-spring/web";
|
||||
import {
|
||||
SpringRef,
|
||||
SpringValue,
|
||||
SpringValues,
|
||||
useSprings,
|
||||
} from "@react-spring/web";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { ResizeObserver } from "@juggle/resize-observer";
|
||||
import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer";
|
||||
import { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/types";
|
||||
|
||||
import styles from "./VideoGrid.module.css";
|
||||
@@ -42,6 +47,18 @@ interface Tile {
|
||||
presenter: boolean;
|
||||
}
|
||||
|
||||
export interface TileSpring {
|
||||
opacity: number;
|
||||
scale: number;
|
||||
shadow: number;
|
||||
shadowSpread: number;
|
||||
zIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type LayoutDirection = "vertical" | "horizontal";
|
||||
|
||||
export function useVideoGridLayout(hasScreenshareFeeds: boolean): {
|
||||
@@ -156,8 +173,16 @@ function getOneOnOneLayoutTilePositions(
|
||||
const gridAspectRatio = gridWidth / gridHeight;
|
||||
|
||||
const smallPip = gridAspectRatio < 1 || gridWidth < 700;
|
||||
const pipWidth = smallPip ? 114 : 230;
|
||||
const pipHeight = smallPip ? 163 : 155;
|
||||
const maxPipWidth = smallPip ? 114 : 230;
|
||||
const maxPipHeight = smallPip ? 163 : 155;
|
||||
// Cap the PiP size at 1/3 the remote tile size, preserving aspect ratio
|
||||
const pipScaleFactor = Math.min(
|
||||
1,
|
||||
remotePosition.width / 3 / maxPipWidth,
|
||||
remotePosition.height / 3 / maxPipHeight
|
||||
);
|
||||
const pipWidth = maxPipWidth * pipScaleFactor;
|
||||
const pipHeight = maxPipHeight * pipScaleFactor;
|
||||
const pipGap = getPipGap(gridAspectRatio, gridWidth);
|
||||
|
||||
const pipMinX = remotePosition.x + pipGap;
|
||||
@@ -692,20 +717,23 @@ interface DragTileData {
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ChildrenProperties extends ReactDOMAttributes {
|
||||
export interface ChildrenProperties extends ReactDOMAttributes {
|
||||
key: Key;
|
||||
style: {
|
||||
scale: SpringValue<number>;
|
||||
opacity: SpringValue<number>;
|
||||
boxShadow: Interpolation<number, string>;
|
||||
};
|
||||
width: number;
|
||||
height: number;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
item: TileDescriptor;
|
||||
opacity: SpringValue<number>;
|
||||
scale: SpringValue<number>;
|
||||
shadow: SpringValue<number>;
|
||||
zIndex: SpringValue<number>;
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
width: SpringValue<number>;
|
||||
height: SpringValue<number>;
|
||||
[index: string]: unknown;
|
||||
}
|
||||
|
||||
interface VideoGridProps {
|
||||
export interface VideoGridProps {
|
||||
items: TileDescriptor[];
|
||||
layout: Layout;
|
||||
disableAnimations?: boolean;
|
||||
@@ -735,7 +763,13 @@ export function VideoGrid({
|
||||
const lastLayoutRef = useRef<Layout>(layout);
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [gridRef, gridBounds] = useMeasure({ polyfill: ResizeObserver });
|
||||
// The 'polyfill' argument to useMeasure is not a polyfill at all but is the impl that is always used
|
||||
// if passed, whether the browser has native support or not, so pass in either the browser native
|
||||
// version or the ponyfill (yes, pony) because Juggle's resizeobserver ponyfill is being weirdly
|
||||
// buggy for me on my dev env my never updating the size until the window resizes.
|
||||
const [gridRef, gridBounds] = useMeasure({
|
||||
polyfill: window.ResizeObserver ?? JuggleResizeObserver,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTileState(({ tiles, ...rest }) => {
|
||||
@@ -867,6 +901,8 @@ export function VideoGrid({
|
||||
// Whether the tile positions were valid at the time of the previous
|
||||
// animation
|
||||
const tilePositionsWereValid = tilePositionsValid.current;
|
||||
const oneOnOneLayout =
|
||||
tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused);
|
||||
|
||||
return (tileIndex: number) => {
|
||||
const tile = tiles[tileIndex];
|
||||
@@ -886,16 +922,19 @@ export function VideoGrid({
|
||||
opacity: 1,
|
||||
zIndex: 2,
|
||||
shadow: 15,
|
||||
shadowSpread: 0,
|
||||
immediate: (key: string) =>
|
||||
disableAnimations ||
|
||||
key === "zIndex" ||
|
||||
key === "x" ||
|
||||
key === "y" ||
|
||||
key === "shadow",
|
||||
key === "shadow" ||
|
||||
key === "shadowSpread",
|
||||
from: {
|
||||
shadow: 0,
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
zIndex: 0,
|
||||
},
|
||||
reset: false,
|
||||
};
|
||||
@@ -919,6 +958,7 @@ export function VideoGrid({
|
||||
shadow: number;
|
||||
scale: number;
|
||||
opacity: number;
|
||||
zIndex?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
@@ -947,10 +987,14 @@ export function VideoGrid({
|
||||
opacity: remove ? 0 : 1,
|
||||
zIndex: tilePosition.zIndex,
|
||||
shadow: 1,
|
||||
shadowSpread: oneOnOneLayout && tile.item.isLocal ? 1 : 0,
|
||||
from,
|
||||
reset,
|
||||
immediate: (key: string) =>
|
||||
disableAnimations || key === "zIndex" || key === "shadow",
|
||||
disableAnimations ||
|
||||
key === "zIndex" ||
|
||||
key === "shadow" ||
|
||||
key === "shadowSpread",
|
||||
// If we just stopped dragging a tile, give it time for the
|
||||
// animation to settle before pushing its z-index back down
|
||||
delay: (key: string) => (key === "zIndex" ? 500 : 0),
|
||||
@@ -965,7 +1009,8 @@ export function VideoGrid({
|
||||
tilePositions,
|
||||
tiles,
|
||||
scrollPosition,
|
||||
]);
|
||||
// react-spring's types are bugged and can't infer the spring type
|
||||
]) as unknown as [SpringValues<TileSpring>[], SpringRef<TileSpring>];
|
||||
|
||||
const onTap = useCallback(
|
||||
(tileKey: Key) => {
|
||||
@@ -1175,21 +1220,16 @@ export function VideoGrid({
|
||||
|
||||
return (
|
||||
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
|
||||
{springs.map(({ shadow, ...style }, i) => {
|
||||
{springs.map((style, i) => {
|
||||
const tile = tiles[i];
|
||||
const tilePosition = tilePositions[tile.order];
|
||||
|
||||
return children({
|
||||
...bindTile(tile.key),
|
||||
key: tile.key,
|
||||
style: {
|
||||
boxShadow: shadow.to(
|
||||
(s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px`
|
||||
),
|
||||
...style,
|
||||
},
|
||||
width: tilePosition.width,
|
||||
height: tilePosition.height,
|
||||
...style,
|
||||
key: tile.item.id,
|
||||
targetWidth: tilePosition.width,
|
||||
targetHeight: tilePosition.height,
|
||||
item: tile.item,
|
||||
});
|
||||
})}
|
||||
|
||||
@@ -16,11 +16,16 @@ limitations under the License.
|
||||
|
||||
.videoTile {
|
||||
position: absolute;
|
||||
will-change: transform, width, height, opacity, box-shadow;
|
||||
border-radius: 20px;
|
||||
contain: strict;
|
||||
top: 0;
|
||||
width: var(--tileWidth);
|
||||
height: var(--tileHeight);
|
||||
--tileRadius: 8px;
|
||||
border-radius: var(--tileRadius);
|
||||
box-shadow: rgba(0, 0, 0, 0.5) 0px var(--tileShadow)
|
||||
calc(2 * var(--tileShadow)) var(--tileShadowSpread);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
|
||||
/* HACK: This has no visual effect due to the short duration, but allows the
|
||||
JS to detect movement via the transform property for audio spatialization */
|
||||
@@ -28,9 +33,6 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.videoTile * {
|
||||
touch-action: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -45,15 +47,21 @@ limitations under the License.
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.videoTile.speaking::after {
|
||||
.videoTile::after {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
content: "";
|
||||
border-radius: 20px;
|
||||
border-radius: var(--tileRadius);
|
||||
box-shadow: inset 0 0 0 4px var(--accent) !important;
|
||||
opacity: 0;
|
||||
transition: opacity ease 0.15s;
|
||||
}
|
||||
|
||||
.videoTile.speaking::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.videoTile.maximised {
|
||||
@@ -83,6 +91,12 @@ limitations under the License.
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.infoBubble > svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -126,10 +140,6 @@ limitations under the License.
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
.memberName > * {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.memberName > :last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
@@ -143,13 +153,6 @@ limitations under the License.
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.videoMutedAvatar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.videoMutedOverlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -179,3 +182,9 @@ limitations under the License.
|
||||
max-width: 360px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.videoTile {
|
||||
--tileRadius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { animated } from "@react-spring/web";
|
||||
import React, { ForwardedRef, forwardRef } from "react";
|
||||
import { animated, SpringValue } from "@react-spring/web";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./VideoTile.module.css";
|
||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
|
||||
import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg";
|
||||
import { AudioButton, FullscreenButton } from "../button/Button";
|
||||
import { ConnectionState } from "../room/useGroupCall";
|
||||
|
||||
@@ -36,6 +36,7 @@ interface Props {
|
||||
mediaRef?: React.RefObject<MediaElement>;
|
||||
onOptionsPress?: () => void;
|
||||
localVolume?: number;
|
||||
hasAudio?: boolean;
|
||||
maximised?: boolean;
|
||||
fullscreen?: boolean;
|
||||
onFullscreen?: () => void;
|
||||
@@ -43,9 +44,18 @@ interface Props {
|
||||
showOptions?: boolean;
|
||||
isLocal?: boolean;
|
||||
disableSpeakingIndicator?: boolean;
|
||||
opacity?: SpringValue<number>;
|
||||
scale?: SpringValue<number>;
|
||||
shadow?: SpringValue<number>;
|
||||
shadowSpread?: SpringValue<number>;
|
||||
zIndex?: SpringValue<number>;
|
||||
x?: SpringValue<number>;
|
||||
y?: SpringValue<number>;
|
||||
width?: SpringValue<number>;
|
||||
height?: SpringValue<number>;
|
||||
}
|
||||
|
||||
export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
(
|
||||
{
|
||||
name,
|
||||
@@ -58,6 +68,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
mediaRef,
|
||||
onOptionsPress,
|
||||
localVolume,
|
||||
hasAudio,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onFullscreen,
|
||||
@@ -66,6 +77,15 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
isLocal,
|
||||
// TODO: disableSpeakingIndicator is not used atm.
|
||||
disableSpeakingIndicator,
|
||||
opacity,
|
||||
scale,
|
||||
shadow,
|
||||
shadowSpread,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
@@ -74,14 +94,16 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
|
||||
const toolbarButtons: JSX.Element[] = [];
|
||||
if (connectionState == ConnectionState.Connected && !isLocal) {
|
||||
toolbarButtons.push(
|
||||
<AudioButton
|
||||
key="localVolume"
|
||||
className={styles.button}
|
||||
volume={localVolume}
|
||||
onPress={onOptionsPress}
|
||||
/>
|
||||
);
|
||||
if (hasAudio) {
|
||||
toolbarButtons.push(
|
||||
<AudioButton
|
||||
key="localVolume"
|
||||
className={styles.button}
|
||||
volume={localVolume}
|
||||
onPress={onOptionsPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screenshare) {
|
||||
toolbarButtons.push(
|
||||
@@ -118,7 +140,22 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
[styles.screenshare]: screenshare,
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
ref={ref}
|
||||
style={{
|
||||
opacity,
|
||||
scale,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore React does in fact support assigning custom properties,
|
||||
// but React's types say no
|
||||
"--tileWidth": width?.to((w) => `${w}px`),
|
||||
"--tileHeight": height?.to((h) => `${h}px`),
|
||||
"--tileShadow": shadow?.to((s) => `${s}px`),
|
||||
"--tileShadowSpread": shadowSpread?.to((s) => `${s}px`),
|
||||
}}
|
||||
ref={ref as ForwardedRef<HTMLDivElement>}
|
||||
data-testid="videoTile"
|
||||
{...rest}
|
||||
>
|
||||
{toolbarButtons.length > 0 && !maximised && (
|
||||
@@ -137,12 +174,24 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames(styles.infoBubble, styles.memberName)}>
|
||||
{audioMuted && !videoMuted && <MicMutedIcon />}
|
||||
{videoMuted && <VideoMutedIcon />}
|
||||
<span title={caption}>{caption}</span>
|
||||
{
|
||||
/* If the user is speaking, it's safe to say they're unmuted.
|
||||
Mute state is currently sent over to-device messages, which
|
||||
aren't quite real-time, so this is an important kludge to make
|
||||
sure no one appears muted when they've clearly begun talking. */
|
||||
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
|
||||
}
|
||||
<span data-testid="videoTile_caption" title={caption}>
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<video ref={mediaRef} playsInline disablePictureInPicture />
|
||||
<video
|
||||
data-testid="videoTile_video"
|
||||
ref={mediaRef}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
/>
|
||||
</animated.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
|
||||
import React from "react";
|
||||
import React, { FC, memo, RefObject } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { SpringValue } from "@react-spring/web";
|
||||
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
||||
|
||||
import { useCallFeed } from "./useCallFeed";
|
||||
import { useSpatialMediaStream } from "./useMediaStream";
|
||||
@@ -29,8 +31,8 @@ import { TileDescriptor } from "./TileDescriptor";
|
||||
|
||||
interface Props {
|
||||
item: TileDescriptor;
|
||||
width?: number;
|
||||
height?: number;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
getAvatar: (
|
||||
roomMember: RoomMember,
|
||||
width: number,
|
||||
@@ -42,84 +44,114 @@ interface Props {
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onFullscreen: (item: TileDescriptor) => void;
|
||||
opacity?: SpringValue<number>;
|
||||
scale?: SpringValue<number>;
|
||||
shadow?: SpringValue<number>;
|
||||
shadowSpread?: SpringValue<number>;
|
||||
zIndex?: SpringValue<number>;
|
||||
x?: SpringValue<number>;
|
||||
y?: SpringValue<number>;
|
||||
width?: SpringValue<number>;
|
||||
height?: SpringValue<number>;
|
||||
onDragRef?: RefObject<
|
||||
(
|
||||
tileId: string,
|
||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
||||
) => void
|
||||
>;
|
||||
}
|
||||
|
||||
export function VideoTileContainer({
|
||||
item,
|
||||
width,
|
||||
height,
|
||||
getAvatar,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
disableSpeakingIndicator,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onFullscreen,
|
||||
...rest
|
||||
}: Props) {
|
||||
const {
|
||||
isLocal,
|
||||
audioMuted,
|
||||
videoMuted,
|
||||
localVolume,
|
||||
speaking,
|
||||
stream,
|
||||
purpose,
|
||||
} = useCallFeed(item.callFeed);
|
||||
const { rawDisplayName } = useRoomMemberName(item.member);
|
||||
const [tileRef, mediaRef] = useSpatialMediaStream(
|
||||
stream ?? null,
|
||||
export const VideoTileContainer: FC<Props> = memo(
|
||||
({
|
||||
item,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
getAvatar,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
localVolume,
|
||||
// The feed is muted if it's local audio (because we don't want our own audio,
|
||||
// but it's a hook and we can't call it conditionally so we're stuck with it)
|
||||
// or if there's a maximised feed in which case we always render audio via audio
|
||||
// elements because we wire it up at the video tile container level and only one
|
||||
// video tile container is displayed.
|
||||
isLocal || maximised
|
||||
);
|
||||
const {
|
||||
modalState: videoTileSettingsModalState,
|
||||
modalProps: videoTileSettingsModalProps,
|
||||
} = useModalTriggerState();
|
||||
const onOptionsPress = () => {
|
||||
videoTileSettingsModalState.open();
|
||||
};
|
||||
disableSpeakingIndicator,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onFullscreen,
|
||||
onDragRef,
|
||||
...rest
|
||||
}) => {
|
||||
const {
|
||||
isLocal,
|
||||
audioMuted,
|
||||
videoMuted,
|
||||
localVolume,
|
||||
hasAudio,
|
||||
speaking,
|
||||
stream,
|
||||
purpose,
|
||||
} = useCallFeed(item.callFeed);
|
||||
const { rawDisplayName } = useRoomMemberName(item.member);
|
||||
|
||||
const onFullscreenCallback = useCallback(() => {
|
||||
onFullscreen(item);
|
||||
}, [onFullscreen, item]);
|
||||
const [tileRef, mediaRef] = useSpatialMediaStream(
|
||||
stream ?? null,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
localVolume,
|
||||
// The feed is muted if it's local audio (because we don't want our own audio,
|
||||
// but it's a hook and we can't call it conditionally so we're stuck with it)
|
||||
// or if there's a maximised feed in which case we always render audio via audio
|
||||
// elements because we wire it up at the video tile container level and only one
|
||||
// video tile container is displayed.
|
||||
isLocal || maximised
|
||||
);
|
||||
|
||||
// Firefox doesn't respect the disablePictureInPicture attribute
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
|
||||
useDrag((state) => onDragRef?.current!(item.id, state), {
|
||||
target: tileRef,
|
||||
filterTaps: true,
|
||||
preventScroll: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<VideoTile
|
||||
isLocal={isLocal}
|
||||
speaking={speaking && !disableSpeakingIndicator}
|
||||
audioMuted={audioMuted}
|
||||
videoMuted={videoMuted}
|
||||
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
|
||||
name={rawDisplayName}
|
||||
connectionState={item.connectionState}
|
||||
ref={tileRef}
|
||||
mediaRef={mediaRef}
|
||||
avatar={getAvatar && getAvatar(item.member, width, height)}
|
||||
onOptionsPress={onOptionsPress}
|
||||
localVolume={localVolume}
|
||||
maximised={maximised}
|
||||
fullscreen={fullscreen}
|
||||
onFullscreen={onFullscreenCallback}
|
||||
{...rest}
|
||||
/>
|
||||
{videoTileSettingsModalState.isOpen && !maximised && item.callFeed && (
|
||||
<VideoTileSettingsModal
|
||||
{...videoTileSettingsModalProps}
|
||||
feed={item.callFeed}
|
||||
const {
|
||||
modalState: videoTileSettingsModalState,
|
||||
modalProps: videoTileSettingsModalProps,
|
||||
} = useModalTriggerState();
|
||||
const onOptionsPress = () => {
|
||||
videoTileSettingsModalState.open();
|
||||
};
|
||||
|
||||
const onFullscreenCallback = useCallback(() => {
|
||||
onFullscreen(item);
|
||||
}, [onFullscreen, item]);
|
||||
|
||||
// Firefox doesn't respect the disablePictureInPicture attribute
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
|
||||
|
||||
return (
|
||||
<>
|
||||
<VideoTile
|
||||
isLocal={isLocal}
|
||||
speaking={speaking && !disableSpeakingIndicator}
|
||||
audioMuted={audioMuted}
|
||||
videoMuted={videoMuted}
|
||||
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
|
||||
name={rawDisplayName}
|
||||
connectionState={item.connectionState}
|
||||
ref={tileRef}
|
||||
mediaRef={mediaRef}
|
||||
avatar={
|
||||
getAvatar && getAvatar(item.member, targetWidth, targetHeight)
|
||||
}
|
||||
onOptionsPress={onOptionsPress}
|
||||
localVolume={localVolume}
|
||||
hasAudio={hasAudio}
|
||||
maximised={maximised}
|
||||
fullscreen={fullscreen}
|
||||
onFullscreen={onFullscreenCallback}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
{videoTileSettingsModalState.isOpen && !maximised && item.callFeed && (
|
||||
<VideoTileSettingsModal
|
||||
{...videoTileSettingsModalProps}
|
||||
feed={item.callFeed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
416
src/video-grid/model.ts
Normal file
416
src/video-grid/model.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
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 TinyQueue from "tinyqueue";
|
||||
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
|
||||
/**
|
||||
* A 1×1 cell in a grid which belongs to a tile.
|
||||
*/
|
||||
export interface Cell {
|
||||
/**
|
||||
* The item displayed on the tile.
|
||||
*/
|
||||
item: TileDescriptor;
|
||||
/**
|
||||
* Whether this cell is the origin (top left corner) of the tile.
|
||||
*/
|
||||
origin: boolean;
|
||||
/**
|
||||
* The width, in columns, of the tile.
|
||||
*/
|
||||
columns: number;
|
||||
/**
|
||||
* The height, in rows, of the tile.
|
||||
*/
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface Grid {
|
||||
columns: number;
|
||||
/**
|
||||
* The cells of the grid, in left-to-right top-to-bottom order.
|
||||
* undefined = empty.
|
||||
*/
|
||||
cells: (Cell | undefined)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the paths that tiles should travel along in the grid to reach a
|
||||
* particular destination.
|
||||
* @param dest The destination index.
|
||||
* @param g The grid.
|
||||
* @returns An array in which each cell holds the index of the next cell to move
|
||||
* to to reach the destination, or null if it is the destination.
|
||||
*/
|
||||
export function getPaths(dest: number, g: Grid): (number | null)[] {
|
||||
const destRow = row(dest, g);
|
||||
const destColumn = column(dest, g);
|
||||
|
||||
// This is Dijkstra's algorithm
|
||||
|
||||
const distances = new Array<number>(dest + 1).fill(Infinity);
|
||||
distances[dest] = 0;
|
||||
const edges = new Array<number | null | undefined>(dest).fill(undefined);
|
||||
edges[dest] = null;
|
||||
const heap = new TinyQueue([dest], (i) => distances[i]);
|
||||
|
||||
const visit = (curr: number, via: number) => {
|
||||
const viaCell = g.cells[via];
|
||||
const viaLargeTile =
|
||||
viaCell !== undefined && (viaCell.rows > 1 || viaCell.columns > 1);
|
||||
// Since it looks nicer to have paths go around large tiles, we impose an
|
||||
// increased cost for moving through them
|
||||
const distanceVia = distances[via] + (viaLargeTile ? 8 : 1);
|
||||
|
||||
if (distanceVia < distances[curr]) {
|
||||
distances[curr] = distanceVia;
|
||||
edges[curr] = via;
|
||||
heap.push(curr);
|
||||
}
|
||||
};
|
||||
|
||||
while (heap.length > 0) {
|
||||
const via = heap.pop()!;
|
||||
const viaRow = row(via, g);
|
||||
const viaColumn = column(via, g);
|
||||
|
||||
// Visit each neighbor
|
||||
if (viaRow > 0) visit(via - g.columns, via);
|
||||
if (viaColumn > 0) visit(via - 1, via);
|
||||
if (viaColumn < (viaRow === destRow ? destColumn : g.columns - 1))
|
||||
visit(via + 1, via);
|
||||
if (
|
||||
viaRow < destRow - 1 ||
|
||||
(viaRow === destRow - 1 && viaColumn <= destColumn)
|
||||
)
|
||||
visit(via + g.columns, via);
|
||||
}
|
||||
|
||||
// The heap is empty, so we've generated all paths
|
||||
return edges as (number | null)[];
|
||||
}
|
||||
|
||||
function findLastIndex<T>(
|
||||
array: T[],
|
||||
predicate: (item: T) => boolean
|
||||
): number | null {
|
||||
for (let i = array.length - 1; i >= 0; i--) {
|
||||
if (predicate(array[i])) return i;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const findLast1By1Index = (g: Grid): number | null =>
|
||||
findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1);
|
||||
|
||||
export function row(index: number, g: Grid): number {
|
||||
return Math.floor(index / g.columns);
|
||||
}
|
||||
|
||||
export function column(index: number, g: Grid): number {
|
||||
return ((index % g.columns) + g.columns) % g.columns;
|
||||
}
|
||||
|
||||
function inArea(index: number, start: number, end: number, g: Grid): boolean {
|
||||
const indexColumn = column(index, g);
|
||||
const indexRow = row(index, g);
|
||||
return (
|
||||
indexRow >= row(start, g) &&
|
||||
indexRow <= row(end, g) &&
|
||||
indexColumn >= column(start, g) &&
|
||||
indexColumn <= column(end, g)
|
||||
);
|
||||
}
|
||||
|
||||
function* cellsInArea(
|
||||
start: number,
|
||||
end: number,
|
||||
g: Grid
|
||||
): Generator<number, void, unknown> {
|
||||
const startColumn = column(start, g);
|
||||
const endColumn = column(end, g);
|
||||
for (
|
||||
let i = start;
|
||||
i <= end;
|
||||
i =
|
||||
column(i, g) === endColumn
|
||||
? i + g.columns + startColumn - endColumn
|
||||
: i + 1
|
||||
)
|
||||
yield i;
|
||||
}
|
||||
|
||||
export function forEachCellInArea(
|
||||
start: number,
|
||||
end: number,
|
||||
g: Grid,
|
||||
fn: (c: Cell | undefined, i: number) => void
|
||||
): void {
|
||||
for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i);
|
||||
}
|
||||
|
||||
function allCellsInArea(
|
||||
start: number,
|
||||
end: number,
|
||||
g: Grid,
|
||||
fn: (c: Cell | undefined, i: number) => boolean
|
||||
): boolean {
|
||||
for (const i of cellsInArea(start, end, g)) {
|
||||
if (!fn(g.cells[i], i)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const areaEnd = (
|
||||
start: number,
|
||||
columns: number,
|
||||
rows: number,
|
||||
g: Grid
|
||||
): number => start + columns - 1 + g.columns * (rows - 1);
|
||||
|
||||
/**
|
||||
* Gets the index of the next gap in the grid that should be backfilled by 1×1
|
||||
* tiles.
|
||||
*/
|
||||
function getNextGap(g: Grid): number | null {
|
||||
const last1By1Index = findLast1By1Index(g);
|
||||
if (last1By1Index === null) return null;
|
||||
|
||||
for (let i = 0; i < last1By1Index; i++) {
|
||||
// To make the backfilling process look natural when there are multiple
|
||||
// gaps, we actually scan each row from right to left
|
||||
const j =
|
||||
(row(i, g) === row(last1By1Index, g)
|
||||
? last1By1Index
|
||||
: (row(i, g) + 1) * g.columns) -
|
||||
1 -
|
||||
column(i, g);
|
||||
|
||||
if (g.cells[j] === undefined) return j;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill any gaps in the grid.
|
||||
*/
|
||||
export function fillGaps(g: Grid): Grid {
|
||||
const result: Grid = { ...g, cells: [...g.cells] };
|
||||
let gap = getNextGap(result);
|
||||
|
||||
if (gap !== null) {
|
||||
const pathsToEnd = getPaths(findLast1By1Index(result)!, result);
|
||||
|
||||
do {
|
||||
let filled = false;
|
||||
let to = gap;
|
||||
let from = pathsToEnd[gap];
|
||||
|
||||
// First, attempt to fill the gap by moving 1×1 tiles backwards from the
|
||||
// end of the grid along a set path
|
||||
while (from !== null) {
|
||||
const toCell = result.cells[to];
|
||||
const fromCell = result.cells[from];
|
||||
|
||||
// Skip over slots that are already full
|
||||
if (toCell !== undefined) {
|
||||
to = pathsToEnd[to]!;
|
||||
// Skip over large tiles. Also, we might run into gaps along the path
|
||||
// created during the filling of previous gaps. Skip over those too;
|
||||
// they'll be picked up on the next iteration of the outer loop.
|
||||
} else if (
|
||||
fromCell === undefined ||
|
||||
fromCell.rows > 1 ||
|
||||
fromCell.columns > 1
|
||||
) {
|
||||
from = pathsToEnd[from];
|
||||
} else {
|
||||
result.cells[to] = result.cells[from];
|
||||
result.cells[from] = undefined;
|
||||
filled = true;
|
||||
to = pathsToEnd[to]!;
|
||||
from = pathsToEnd[from];
|
||||
}
|
||||
}
|
||||
|
||||
// In case the path approach failed, fall back to taking the very last 1×1
|
||||
// tile, and just dropping it into place
|
||||
if (!filled) {
|
||||
const last1By1Index = findLast1By1Index(result)!;
|
||||
result.cells[gap] = result.cells[last1By1Index];
|
||||
result.cells[last1By1Index] = undefined;
|
||||
}
|
||||
|
||||
gap = getNextGap(result);
|
||||
} while (gap !== null);
|
||||
}
|
||||
|
||||
// TODO: If there are any large tiles on the last row, shuffle them back
|
||||
// upwards into a full row
|
||||
|
||||
// Shrink the array to remove trailing gaps
|
||||
const finalLength =
|
||||
(findLastIndex(result.cells, (c) => c !== undefined) ?? -1) + 1;
|
||||
if (finalLength < result.cells.length)
|
||||
result.cells = result.cells.slice(0, finalLength);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function appendItems(items: TileDescriptor[], g: Grid): Grid {
|
||||
return {
|
||||
...g,
|
||||
cells: [
|
||||
...g.cells,
|
||||
...items.map((i) => ({
|
||||
item: i,
|
||||
origin: true,
|
||||
columns: 1,
|
||||
rows: 1,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the size of a tile, rearranging the grid to make space.
|
||||
* @param tileId The ID of the tile to modify.
|
||||
* @param g The grid.
|
||||
* @returns The updated grid.
|
||||
*/
|
||||
export function cycleTileSize(tileId: string, g: Grid): Grid {
|
||||
const from = g.cells.findIndex((c) => c?.item.id === tileId);
|
||||
if (from === -1) return g; // Tile removed, no change
|
||||
const fromWidth = g.cells[from]!.columns;
|
||||
const fromHeight = g.cells[from]!.rows;
|
||||
const fromEnd = areaEnd(from, fromWidth, fromHeight, g);
|
||||
|
||||
// The target dimensions, which toggle between 1×1 and larger than 1×1
|
||||
const [toWidth, toHeight] =
|
||||
fromWidth === 1 && fromHeight === 1
|
||||
? [Math.min(3, Math.max(2, g.columns - 1)), 2]
|
||||
: [1, 1];
|
||||
|
||||
// If we're expanding the tile, we want to create enough new rows at the
|
||||
// tile's target position such that every new unit of grid area created during
|
||||
// the expansion can fit within the new rows.
|
||||
// We do it this way, since it's easier to backfill gaps in the grid than it
|
||||
// is to push colliding tiles outwards.
|
||||
const newRows = Math.max(
|
||||
0,
|
||||
Math.ceil((toWidth * toHeight - fromWidth * fromHeight) / g.columns)
|
||||
);
|
||||
|
||||
// This is the grid with the new rows added
|
||||
const gappyGrid: Grid = {
|
||||
...g,
|
||||
cells: new Array(g.cells.length + newRows * g.columns),
|
||||
};
|
||||
|
||||
// The next task is to scan for a spot to place the modified tile. Since we
|
||||
// might be creating new rows at the target position, this spot can be shorter
|
||||
// than the target height.
|
||||
const candidateWidth = toWidth;
|
||||
const candidateHeight = toHeight - newRows;
|
||||
|
||||
// To make the tile appear to expand outwards from its center, we're actually
|
||||
// scanning for locations to put the *center* of the tile. These numbers are
|
||||
// the offsets between the tile's origin and its center.
|
||||
const scanColumnOffset = Math.floor((toWidth - 1) / 2);
|
||||
const scanRowOffset = Math.floor((toHeight - 1) / 2);
|
||||
|
||||
const nextScanLocations = new Set<number>([from]);
|
||||
const rows = row(g.cells.length - 1, g) + 1;
|
||||
let to: number | null = null;
|
||||
|
||||
// The contents of a given cell are 'displaceable' if it's empty, holds a 1×1
|
||||
// tile, or is part of the original tile we're trying to reposition
|
||||
const displaceable = (c: Cell | undefined, i: number): boolean =>
|
||||
c === undefined ||
|
||||
(c.columns === 1 && c.rows === 1) ||
|
||||
inArea(i, from, fromEnd, g);
|
||||
|
||||
// Do the scanning
|
||||
for (const scanLocation of nextScanLocations) {
|
||||
const start = scanLocation - scanColumnOffset - g.columns * scanRowOffset;
|
||||
const end = areaEnd(start, candidateWidth, candidateHeight, g);
|
||||
const startColumn = column(start, g);
|
||||
const startRow = row(start, g);
|
||||
const endColumn = column(end, g);
|
||||
const endRow = row(end, g);
|
||||
|
||||
if (
|
||||
start >= 0 &&
|
||||
endColumn - startColumn + 1 === candidateWidth &&
|
||||
allCellsInArea(start, end, g, displaceable)
|
||||
) {
|
||||
// This location works!
|
||||
to = start;
|
||||
break;
|
||||
}
|
||||
|
||||
// Scan outwards in all directions
|
||||
if (startColumn > 0) nextScanLocations.add(scanLocation - 1);
|
||||
if (endColumn < g.columns - 1) nextScanLocations.add(scanLocation + 1);
|
||||
if (startRow > 0) nextScanLocations.add(scanLocation - g.columns);
|
||||
if (endRow < rows - 1) nextScanLocations.add(scanLocation + g.columns);
|
||||
}
|
||||
|
||||
// If there is no space in the grid, give up
|
||||
if (to === null) return g;
|
||||
|
||||
const toRow = row(to, g);
|
||||
|
||||
// Copy tiles from the original grid to the new one, with the new rows
|
||||
// inserted at the target location
|
||||
g.cells.forEach((c, src) => {
|
||||
if (c?.origin && c.item.id !== tileId) {
|
||||
const offset =
|
||||
row(src, g) > toRow + candidateHeight - 1 ? g.columns * newRows : 0;
|
||||
forEachCellInArea(src, areaEnd(src, c.columns, c.rows, g), g, (c, i) => {
|
||||
gappyGrid.cells[i + offset] = c;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Place the tile in its target position, making a note of the tiles being
|
||||
// overwritten
|
||||
const displacedTiles: Cell[] = [];
|
||||
const toEnd = areaEnd(to, toWidth, toHeight, g);
|
||||
forEachCellInArea(to, toEnd, gappyGrid, (c, i) => {
|
||||
if (c !== undefined) displacedTiles.push(c);
|
||||
gappyGrid.cells[i] = {
|
||||
item: g.cells[from]!.item,
|
||||
origin: i === to,
|
||||
columns: toWidth,
|
||||
rows: toHeight,
|
||||
};
|
||||
});
|
||||
|
||||
// Place the displaced tiles in the remaining space
|
||||
for (let i = 0; displacedTiles.length > 0; i++) {
|
||||
if (gappyGrid.cells[i] === undefined)
|
||||
gappyGrid.cells[i] = displacedTiles.shift();
|
||||
}
|
||||
|
||||
// Fill any gaps that remain
|
||||
return fillGaps(gappyGrid);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { useState, useEffect } from "react";
|
||||
import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
|
||||
|
||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
|
||||
interface CallFeedState {
|
||||
callFeed: CallFeed | undefined;
|
||||
isLocal: boolean;
|
||||
@@ -25,6 +27,7 @@ interface CallFeedState {
|
||||
videoMuted: boolean;
|
||||
audioMuted: boolean;
|
||||
localVolume: number;
|
||||
hasAudio: boolean;
|
||||
disposed: boolean | undefined;
|
||||
stream: MediaStream | undefined;
|
||||
purpose: SDPStreamMetadataPurpose | undefined;
|
||||
@@ -38,46 +41,53 @@ function getCallFeedState(callFeed: CallFeed | undefined): CallFeedState {
|
||||
videoMuted: callFeed ? callFeed.isVideoMuted() : true,
|
||||
audioMuted: callFeed ? callFeed.isAudioMuted() : true,
|
||||
localVolume: callFeed ? callFeed.getLocalVolume() : 0,
|
||||
hasAudio: callFeed ? callFeed.stream.getAudioTracks().length >= 1 : false,
|
||||
disposed: callFeed ? callFeed.disposed : undefined,
|
||||
stream: callFeed ? callFeed.stream : undefined,
|
||||
purpose: callFeed ? callFeed.purpose : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCallFeed(callFeed: CallFeed | undefined): CallFeedState {
|
||||
export function useCallFeed(
|
||||
callFeed: CallFeed | undefined,
|
||||
otelGroupCallMembership?: OTelGroupCallMembership
|
||||
): CallFeedState {
|
||||
const [state, setState] = useState<CallFeedState>(() =>
|
||||
getCallFeedState(callFeed)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onSpeaking(speaking: boolean) {
|
||||
setState((prevState) => ({ ...prevState, speaking }));
|
||||
}
|
||||
|
||||
function onMuteStateChanged(audioMuted: boolean, videoMuted: boolean) {
|
||||
setState((prevState) => ({ ...prevState, audioMuted, videoMuted }));
|
||||
}
|
||||
|
||||
function onLocalVolumeChanged(localVolume: number) {
|
||||
setState((prevState) => ({ ...prevState, localVolume }));
|
||||
}
|
||||
|
||||
function onUpdateCallFeed() {
|
||||
setState(getCallFeedState(callFeed));
|
||||
}
|
||||
|
||||
onUpdateCallFeed();
|
||||
|
||||
if (callFeed) {
|
||||
const onSpeaking = (speaking: boolean) => {
|
||||
otelGroupCallMembership?.onSpeaking(
|
||||
callFeed.getMember()!,
|
||||
callFeed.deviceId!,
|
||||
speaking
|
||||
);
|
||||
setState((prevState) => ({ ...prevState, speaking }));
|
||||
};
|
||||
|
||||
const onMuteStateChanged = (audioMuted: boolean, videoMuted: boolean) => {
|
||||
setState((prevState) => ({ ...prevState, audioMuted, videoMuted }));
|
||||
};
|
||||
|
||||
const onLocalVolumeChanged = (localVolume: number) => {
|
||||
setState((prevState) => ({ ...prevState, localVolume }));
|
||||
};
|
||||
|
||||
callFeed.on(CallFeedEvent.Speaking, onSpeaking);
|
||||
callFeed.on(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
|
||||
callFeed.on(CallFeedEvent.LocalVolumeChanged, onLocalVolumeChanged);
|
||||
callFeed.on(CallFeedEvent.NewStream, onUpdateCallFeed);
|
||||
callFeed.on(CallFeedEvent.Disposed, onUpdateCallFeed);
|
||||
}
|
||||
|
||||
onUpdateCallFeed();
|
||||
|
||||
return () => {
|
||||
if (callFeed) {
|
||||
return () => {
|
||||
callFeed.removeListener(CallFeedEvent.Speaking, onSpeaking);
|
||||
callFeed.removeListener(
|
||||
CallFeedEvent.MuteStateChanged,
|
||||
@@ -88,9 +98,9 @@ export function useCallFeed(callFeed: CallFeed | undefined): CallFeedState {
|
||||
onLocalVolumeChanged
|
||||
);
|
||||
callFeed.removeListener(CallFeedEvent.NewStream, onUpdateCallFeed);
|
||||
}
|
||||
};
|
||||
}, [callFeed]);
|
||||
};
|
||||
}
|
||||
}, [callFeed, otelGroupCallMembership]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -158,8 +158,8 @@ export const useSpatialMediaStream = (
|
||||
audioDestination: AudioNode,
|
||||
localVolume: number,
|
||||
mute = false
|
||||
): [RefObject<HTMLDivElement>, RefObject<MediaElement>] => {
|
||||
const tileRef = useRef<HTMLDivElement | null>(null);
|
||||
): [RefObject<HTMLElement>, RefObject<MediaElement>] => {
|
||||
const tileRef = useRef<HTMLElement | null>(null);
|
||||
const [spatialAudio] = useSpatialAudio();
|
||||
|
||||
// This media stream is only used for the video - the audio goes via the audio
|
||||
|
||||
@@ -101,7 +101,7 @@ export const widget: WidgetHelpers | null = (() => {
|
||||
// We need to do this now rather than later because it has capabilities to
|
||||
// request, and is responsible for starting the transport (should it be?)
|
||||
|
||||
const { roomId, userId, deviceId, baseUrl } = getUrlParams();
|
||||
const { roomId, userId, deviceId, baseUrl, e2eEnabled } = getUrlParams();
|
||||
if (!roomId) throw new Error("Room ID must be supplied");
|
||||
if (!userId) throw new Error("User ID must be supplied");
|
||||
if (!deviceId) throw new Error("Device ID must be supplied");
|
||||
@@ -147,6 +147,7 @@ export const widget: WidgetHelpers | null = (() => {
|
||||
userId,
|
||||
deviceId,
|
||||
timelineSupport: true,
|
||||
useE2eForGroupCall: e2eEnabled,
|
||||
}
|
||||
);
|
||||
const clientPromise = client.startClient().then(() => client);
|
||||
|
||||
243
test/otel/ObjectFlattener-test.ts
Normal file
243
test/otel/ObjectFlattener-test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { GroupCallStatsReport } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import {
|
||||
AudioConcealment,
|
||||
ConnectionStatsReport,
|
||||
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
|
||||
import { ObjectFlattener } from "../../src/otel/ObjectFlattener";
|
||||
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
describe("ObjectFlattener", () => {
|
||||
const noConcealment: AudioConcealment = {
|
||||
concealedAudio: 0,
|
||||
totalAudioDuration: 0,
|
||||
};
|
||||
|
||||
const statsReport: GroupCallStatsReport<ConnectionStatsReport> = {
|
||||
report: {
|
||||
bandwidth: { upload: 426, download: 0 },
|
||||
bitrate: {
|
||||
upload: 426,
|
||||
download: 0,
|
||||
audio: {
|
||||
upload: 124,
|
||||
download: 0,
|
||||
},
|
||||
video: {
|
||||
upload: 302,
|
||||
download: 0,
|
||||
},
|
||||
},
|
||||
packetLoss: {
|
||||
total: 0,
|
||||
download: 0,
|
||||
upload: 0,
|
||||
},
|
||||
framerate: {
|
||||
local: new Map([
|
||||
["LOCAL_AUDIO_TRACK_ID", 0],
|
||||
["LOCAL_VIDEO_TRACK_ID", 30],
|
||||
]),
|
||||
remote: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", 0],
|
||||
["REMOTE_VIDEO_TRACK_ID", 60],
|
||||
]),
|
||||
},
|
||||
resolution: {
|
||||
local: new Map([
|
||||
["LOCAL_AUDIO_TRACK_ID", { height: -1, width: -1 }],
|
||||
["LOCAL_VIDEO_TRACK_ID", { height: 460, width: 780 }],
|
||||
]),
|
||||
remote: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", { height: -1, width: -1 }],
|
||||
["REMOTE_VIDEO_TRACK_ID", { height: 960, width: 1080 }],
|
||||
]),
|
||||
},
|
||||
jitter: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", 2],
|
||||
["REMOTE_VIDEO_TRACK_ID", 50],
|
||||
]),
|
||||
codec: {
|
||||
local: new Map([
|
||||
["LOCAL_AUDIO_TRACK_ID", "opus"],
|
||||
["LOCAL_VIDEO_TRACK_ID", "v8"],
|
||||
]),
|
||||
remote: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", "opus"],
|
||||
["REMOTE_VIDEO_TRACK_ID", "v9"],
|
||||
]),
|
||||
},
|
||||
transport: [
|
||||
{
|
||||
ip: "ff11::5fa:abcd:999c:c5c5:50000",
|
||||
type: "udp",
|
||||
localIp: "2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000",
|
||||
isFocus: true,
|
||||
localCandidateType: "host",
|
||||
remoteCandidateType: "host",
|
||||
networkType: "ethernet",
|
||||
rtt: NaN,
|
||||
},
|
||||
{
|
||||
ip: "10.10.10.2:22222",
|
||||
type: "tcp",
|
||||
localIp: "10.10.10.100:33333",
|
||||
isFocus: true,
|
||||
localCandidateType: "srfx",
|
||||
remoteCandidateType: "srfx",
|
||||
networkType: "ethernet",
|
||||
rtt: null,
|
||||
},
|
||||
],
|
||||
audioConcealment: new Map([
|
||||
["REMOTE_AUDIO_TRACK_ID", noConcealment],
|
||||
["REMOTE_VIDEO_TRACK_ID", noConcealment],
|
||||
]),
|
||||
totalAudioConcealment: noConcealment,
|
||||
},
|
||||
};
|
||||
|
||||
describe("on flattenObjectRecursive", () => {
|
||||
it("should flatter an Map object", () => {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
statsReport.report.resolution,
|
||||
flatObject,
|
||||
"matrix.stats.conn.resolution.",
|
||||
0
|
||||
);
|
||||
expect(flatObject).toEqual({
|
||||
"matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.height": -1,
|
||||
"matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.width": -1,
|
||||
|
||||
"matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.height": 460,
|
||||
"matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.width": 780,
|
||||
|
||||
"matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.height": -1,
|
||||
"matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.width": -1,
|
||||
|
||||
"matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.height": 960,
|
||||
"matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.width": 1080,
|
||||
});
|
||||
});
|
||||
it("should flatter an Array object", () => {
|
||||
const flatObject = {};
|
||||
ObjectFlattener.flattenObjectRecursive(
|
||||
statsReport.report.transport,
|
||||
flatObject,
|
||||
"matrix.stats.conn.transport.",
|
||||
0
|
||||
);
|
||||
expect(flatObject).toEqual({
|
||||
"matrix.stats.conn.transport.0.ip": "ff11::5fa:abcd:999c:c5c5:50000",
|
||||
"matrix.stats.conn.transport.0.type": "udp",
|
||||
"matrix.stats.conn.transport.0.localIp":
|
||||
"2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000",
|
||||
"matrix.stats.conn.transport.0.isFocus": true,
|
||||
"matrix.stats.conn.transport.0.localCandidateType": "host",
|
||||
"matrix.stats.conn.transport.0.remoteCandidateType": "host",
|
||||
"matrix.stats.conn.transport.0.networkType": "ethernet",
|
||||
"matrix.stats.conn.transport.0.rtt": "NaN",
|
||||
"matrix.stats.conn.transport.1.ip": "10.10.10.2:22222",
|
||||
"matrix.stats.conn.transport.1.type": "tcp",
|
||||
"matrix.stats.conn.transport.1.localIp": "10.10.10.100:33333",
|
||||
"matrix.stats.conn.transport.1.isFocus": true,
|
||||
"matrix.stats.conn.transport.1.localCandidateType": "srfx",
|
||||
"matrix.stats.conn.transport.1.remoteCandidateType": "srfx",
|
||||
"matrix.stats.conn.transport.1.networkType": "ethernet",
|
||||
"matrix.stats.conn.transport.1.rtt": "null",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("on flattenConnectionStatsReportObject", () => {
|
||||
it("should flatten a Report to otel Attributes Object", () => {
|
||||
expect(
|
||||
ObjectFlattener.flattenConnectionStatsReportObject(statsReport)
|
||||
).toEqual({
|
||||
"matrix.stats.conn.bandwidth.download": 0,
|
||||
"matrix.stats.conn.bandwidth.upload": 426,
|
||||
"matrix.stats.conn.bitrate.audio.download": 0,
|
||||
"matrix.stats.conn.bitrate.audio.upload": 124,
|
||||
"matrix.stats.conn.bitrate.download": 0,
|
||||
"matrix.stats.conn.bitrate.upload": 426,
|
||||
"matrix.stats.conn.bitrate.video.download": 0,
|
||||
"matrix.stats.conn.bitrate.video.upload": 302,
|
||||
"matrix.stats.conn.codec.local.LOCAL_AUDIO_TRACK_ID": "opus",
|
||||
"matrix.stats.conn.codec.local.LOCAL_VIDEO_TRACK_ID": "v8",
|
||||
"matrix.stats.conn.codec.remote.REMOTE_AUDIO_TRACK_ID": "opus",
|
||||
"matrix.stats.conn.codec.remote.REMOTE_VIDEO_TRACK_ID": "v9",
|
||||
"matrix.stats.conn.framerate.local.LOCAL_AUDIO_TRACK_ID": 0,
|
||||
"matrix.stats.conn.framerate.local.LOCAL_VIDEO_TRACK_ID": 30,
|
||||
"matrix.stats.conn.framerate.remote.REMOTE_AUDIO_TRACK_ID": 0,
|
||||
"matrix.stats.conn.framerate.remote.REMOTE_VIDEO_TRACK_ID": 60,
|
||||
"matrix.stats.conn.jitter.REMOTE_AUDIO_TRACK_ID": 2,
|
||||
"matrix.stats.conn.jitter.REMOTE_VIDEO_TRACK_ID": 50,
|
||||
"matrix.stats.conn.packetLoss.download": 0,
|
||||
"matrix.stats.conn.packetLoss.total": 0,
|
||||
"matrix.stats.conn.packetLoss.upload": 0,
|
||||
"matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.height": -1,
|
||||
"matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.width": -1,
|
||||
"matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.height": 460,
|
||||
"matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.width": 780,
|
||||
"matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.height": -1,
|
||||
"matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.width": -1,
|
||||
"matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.height": 960,
|
||||
"matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.width": 1080,
|
||||
"matrix.stats.conn.transport.0.ip": "ff11::5fa:abcd:999c:c5c5:50000",
|
||||
"matrix.stats.conn.transport.0.type": "udp",
|
||||
"matrix.stats.conn.transport.0.localIp":
|
||||
"2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000",
|
||||
"matrix.stats.conn.transport.0.isFocus": true,
|
||||
"matrix.stats.conn.transport.0.localCandidateType": "host",
|
||||
"matrix.stats.conn.transport.0.remoteCandidateType": "host",
|
||||
"matrix.stats.conn.transport.0.networkType": "ethernet",
|
||||
"matrix.stats.conn.transport.0.rtt": "NaN",
|
||||
"matrix.stats.conn.transport.1.ip": "10.10.10.2:22222",
|
||||
"matrix.stats.conn.transport.1.type": "tcp",
|
||||
"matrix.stats.conn.transport.1.localIp": "10.10.10.100:33333",
|
||||
"matrix.stats.conn.transport.1.isFocus": true,
|
||||
"matrix.stats.conn.transport.1.localCandidateType": "srfx",
|
||||
"matrix.stats.conn.transport.1.remoteCandidateType": "srfx",
|
||||
"matrix.stats.conn.transport.1.networkType": "ethernet",
|
||||
"matrix.stats.conn.transport.1.rtt": "null",
|
||||
"matrix.stats.conn.audioConcealment.REMOTE_AUDIO_TRACK_ID.concealedAudio": 0,
|
||||
"matrix.stats.conn.audioConcealment.REMOTE_AUDIO_TRACK_ID.totalAudioDuration": 0,
|
||||
"matrix.stats.conn.audioConcealment.REMOTE_VIDEO_TRACK_ID.concealedAudio": 0,
|
||||
"matrix.stats.conn.audioConcealment.REMOTE_VIDEO_TRACK_ID.totalAudioDuration": 0,
|
||||
"matrix.stats.conn.totalAudioConcealment.concealedAudio": 0,
|
||||
"matrix.stats.conn.totalAudioConcealment.totalAudioDuration": 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("on flattenByteSendStatsReportObject", () => {
|
||||
const byteSent = {
|
||||
report: new Map([
|
||||
["4aa92608-04c6-428e-8312-93e17602a959", 132093],
|
||||
["a08e4237-ee30-4015-a932-b676aec894b1", 913448],
|
||||
]),
|
||||
};
|
||||
it("should flatten a Report to otel Attributes Object", () => {
|
||||
expect(
|
||||
ObjectFlattener.flattenByteSentStatsReportObject(byteSent)
|
||||
).toEqual({
|
||||
"matrix.stats.bytesSent.4aa92608-04c6-428e-8312-93e17602a959": 132093,
|
||||
"matrix.stats.bytesSent.a08e4237-ee30-4015-a932-b676aec894b1": 913448,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
171
test/room/checkForParallelCalls-test.ts
Normal file
171
test/room/checkForParallelCalls-test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
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 { Mocked, mocked } from "jest-mock";
|
||||
import { RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||
import { PosthogAnalytics } from "../../src/analytics/PosthogAnalytics";
|
||||
import { checkForParallelCalls } from "../../src/room/checkForParallelCalls";
|
||||
|
||||
const withFakeTimers = (continuation: () => void) => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
continuation();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
};
|
||||
|
||||
const withMockedPosthog = (
|
||||
continuation: (posthog: Mocked<PosthogAnalytics>) => void
|
||||
) => {
|
||||
const posthog = mocked({
|
||||
trackEvent: jest.fn(),
|
||||
} as unknown as PosthogAnalytics);
|
||||
const instanceSpy = jest
|
||||
.spyOn(PosthogAnalytics, "instance", "get")
|
||||
.mockReturnValue(posthog);
|
||||
try {
|
||||
continuation(posthog);
|
||||
} finally {
|
||||
instanceSpy.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
const mockRoomState = (
|
||||
groupCallMemberContents: Record<string, unknown>[]
|
||||
): RoomState => {
|
||||
const stateEvents = groupCallMemberContents.map((content) => ({
|
||||
getContent: () => content,
|
||||
}));
|
||||
return { getStateEvents: () => stateEvents } as unknown as RoomState;
|
||||
};
|
||||
|
||||
test("checkForParallelCalls does nothing if all participants are in the same call", () => {
|
||||
withFakeTimers(() => {
|
||||
withMockedPosthog((posthog) => {
|
||||
const roomState = mockRoomState([
|
||||
{
|
||||
"m.calls": [
|
||||
{
|
||||
"m.call_id": "1",
|
||||
"m.devices": [
|
||||
{
|
||||
device_id: "Element Call",
|
||||
session_id: "a",
|
||||
expires_ts: Date.now() + 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"m.call_id": null, // invalid
|
||||
"m.devices": [
|
||||
{
|
||||
device_id: "Element Android",
|
||||
session_id: "a",
|
||||
expires_ts: Date.now() + 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
null, // invalid
|
||||
],
|
||||
},
|
||||
{
|
||||
"m.calls": [
|
||||
{
|
||||
"m.call_id": "1",
|
||||
"m.devices": [
|
||||
{
|
||||
device_id: "Element Desktop",
|
||||
session_id: "a",
|
||||
expires_ts: Date.now() + 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
checkForParallelCalls(roomState);
|
||||
expect(posthog.trackEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("checkForParallelCalls sends diagnostics to PostHog if there is a split-brain", () => {
|
||||
withFakeTimers(() => {
|
||||
withMockedPosthog((posthog) => {
|
||||
const roomState = mockRoomState([
|
||||
{
|
||||
"m.calls": [
|
||||
{
|
||||
"m.call_id": "1",
|
||||
"m.devices": [
|
||||
{
|
||||
device_id: "Element Call",
|
||||
session_id: "a",
|
||||
expires_ts: Date.now() + 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"m.call_id": "2",
|
||||
"m.devices": [
|
||||
{
|
||||
device_id: "Element Android",
|
||||
session_id: "a",
|
||||
expires_ts: Date.now() + 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"m.calls": [
|
||||
{
|
||||
"m.call_id": "1",
|
||||
"m.devices": [
|
||||
{
|
||||
device_id: "Element Desktop",
|
||||
session_id: "a",
|
||||
expires_ts: Date.now() + 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"m.call_id": "2",
|
||||
"m.devices": [
|
||||
{
|
||||
device_id: "Element Call",
|
||||
session_id: "a",
|
||||
expires_ts: Date.now() - 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
checkForParallelCalls(roomState);
|
||||
expect(posthog.trackEvent).toHaveBeenCalledWith({
|
||||
eventName: "ParallelCalls",
|
||||
participantsPerCall: {
|
||||
"1": 2,
|
||||
"2": 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user