Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c66b32b49 | ||
|
|
5eb552e36a | ||
|
|
29e41c7227 | ||
|
|
58d87db55f | ||
|
|
0423a494c4 | ||
|
|
2454daeef9 | ||
|
|
64703fd3cc | ||
|
|
53bc8eb82f | ||
|
|
cdf2d560b8 | ||
|
|
9dff42b437 | ||
|
|
1fc181dc28 | ||
|
|
fbc72283d4 | ||
|
|
9a0dfad5f9 | ||
|
|
efbf319fa1 | ||
|
|
ef4a62ca62 | ||
|
|
8c818b9ce1 | ||
|
|
58ed372afa | ||
|
|
69e6ba93c1 | ||
|
|
b2b2f0bb15 | ||
|
|
8d0bf4cacc | ||
|
|
5448744871 | ||
|
|
933dc4e2d3 | ||
|
|
605dd44df0 | ||
|
|
07a4de638f | ||
|
|
eda11cfc08 | ||
|
|
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 |
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,7 +18,7 @@
|
||||
},
|
||||
"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",
|
||||
"@react-aria/button": "^3.3.4",
|
||||
"@react-aria/dialog": "^3.1.4",
|
||||
"@react-aria/focus": "^3.5.0",
|
||||
@@ -45,7 +45,8 @@
|
||||
"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",
|
||||
"lodash": "^4.17.21",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#8cbbdaa239e449848e8874f041ef1879c1956696",
|
||||
"matrix-widget-api": "^1.0.0",
|
||||
"mermaid": "^8.13.8",
|
||||
"normalize.css": "^8.0.1",
|
||||
@@ -62,6 +63,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": {
|
||||
|
||||
@@ -136,5 +136,8 @@
|
||||
"<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>"
|
||||
}
|
||||
|
||||
@@ -137,5 +137,7 @@
|
||||
"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>"
|
||||
}
|
||||
|
||||
@@ -16,13 +16,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 ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our ": "By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our ",
|
||||
"Call link copied": "Call link copied",
|
||||
"Call type menu": "Call type menu",
|
||||
"Camera": "Camera",
|
||||
@@ -41,10 +40,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",
|
||||
@@ -84,6 +85,7 @@
|
||||
"Press and hold spacebar to talk over {{name}}": "Press and hold spacebar to talk over {{name}}",
|
||||
"Press and hold to talk": "Press and hold to talk",
|
||||
"Press and hold to talk over {{name}}": "Press and hold to talk over {{name}}",
|
||||
"Privacy Policy": "Privacy Policy",
|
||||
"Profile": "Profile",
|
||||
"Recaptcha dismissed": "Recaptcha dismissed",
|
||||
"Recaptcha not loaded": "Recaptcha not loaded",
|
||||
@@ -120,10 +122,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,5 +136,8 @@
|
||||
"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>"
|
||||
}
|
||||
|
||||
@@ -137,5 +137,7 @@
|
||||
"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>"
|
||||
}
|
||||
|
||||
@@ -137,5 +137,7 @@
|
||||
"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>"
|
||||
}
|
||||
|
||||
@@ -137,5 +137,7 @@
|
||||
"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>"
|
||||
}
|
||||
|
||||
@@ -1 +1,107 @@
|
||||
{}
|
||||
{
|
||||
"{{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.": "通話に参加する場合、カメラ・マイクの許可が必要です。",
|
||||
"Allow analytics": "アナリティクスを許可",
|
||||
"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": "音声",
|
||||
"Advanced": "高度",
|
||||
"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…": "読み込んでいます…",
|
||||
"Loading room…": "ルームを読み込んでいます…",
|
||||
"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}}、通話が終了しました"
|
||||
}
|
||||
|
||||
@@ -124,5 +124,16 @@
|
||||
"{{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",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Czy włączyć skróty klawiszowe pojedynczych klawiszy, np. 'm' aby wyciszyć/załączyć mikrofon.",
|
||||
"This feature is only supported on Firefox.": "Ta funkcjonalność jest dostępna tylko w Firefox.",
|
||||
"Single-key keyboard shortcuts": "Skróty klawiszowe (pojedyncze klawisze)",
|
||||
"Copy": "Kopiuj",
|
||||
"Allow analytics": "Zezwól na analitykę",
|
||||
"Advanced": "Zaawansowane",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Wysłanie logó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 teraz</0><1>Or</1><2>Skopiuj link do rozmowy i dołącz później</2>",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Oczekiwanie na wideo...)",
|
||||
"{{name}} (Connecting...)": "{{name}} (Łączenie...)"
|
||||
}
|
||||
|
||||
@@ -132,6 +132,12 @@
|
||||
"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>cкопировать ссылку на звонок и присоединиться позже</2>",
|
||||
"{{name}} (Connecting...)": "{{name}} (Соединение...)",
|
||||
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Включить горячие клавиши, например 'm' чтобы отключить/включить микрофон.",
|
||||
"This feature is only supported on Firefox.": "Эта возможность доступна только в Firefox.",
|
||||
"Single-key keyboard shortcuts": "Горячие клавиши",
|
||||
"<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}} (Ожидание видео...)"
|
||||
}
|
||||
|
||||
@@ -137,5 +137,7 @@
|
||||
"{{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>"
|
||||
}
|
||||
|
||||
@@ -137,5 +137,7 @@
|
||||
"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>"
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
20
src/analytics/AnalyticsOptInDescription.tsx
Normal file
20
src/analytics/AnalyticsOptInDescription.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { t } from "i18next";
|
||||
import React from "react";
|
||||
|
||||
import { Link } from "../typography/Typography";
|
||||
|
||||
export const optInDescription: () => JSX.Element = () => {
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
{t(
|
||||
"By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our "
|
||||
)}
|
||||
</>
|
||||
<Link color="primary" href="https://element.io/privacy">
|
||||
<>{t("Privacy Policy")}</>
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -136,6 +137,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();
|
||||
@@ -224,9 +228,7 @@ export class PosthogAnalytics {
|
||||
}
|
||||
|
||||
public async identifyUser(analyticsIdGenerator: () => string) {
|
||||
// There might be a better way to get the client here.
|
||||
|
||||
if (this.anonymity == Anonymity.Pseudonymous) {
|
||||
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();
|
||||
@@ -415,4 +417,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -44,6 +44,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
|
||||
|
||||
@@ -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";
|
||||
@@ -38,6 +42,8 @@ import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { Title } from "../typography/Typography";
|
||||
import { Form } from "../form/Form";
|
||||
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
|
||||
import { useOptInAnalytics } from "../settings/useSetting";
|
||||
import { optInDescription } from "../analytics/AnalyticsOptInDescription";
|
||||
|
||||
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, setOptInAnalytics] = 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() {
|
||||
@@ -134,6 +144,15 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
{loading ? t("Loading…") : t("Go")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
<InputField
|
||||
id="optInAnalytics"
|
||||
type="checkbox"
|
||||
checked={optInAnalytics}
|
||||
description={optInDescription()}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setOptInAnalytics(event.target.checked)
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
@@ -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 { useOptInAnalytics } from "../settings/useSetting";
|
||||
import { optInDescription } from "../analytics/AnalyticsOptInDescription";
|
||||
|
||||
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, setOptInAnalytics] = 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;
|
||||
|
||||
@@ -148,6 +155,15 @@ export const UnauthenticatedView: FC = () => {
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldRow>
|
||||
<InputField
|
||||
id="optInAnalytics"
|
||||
type="checkbox"
|
||||
checked={optInAnalytics}
|
||||
description={optInDescription()}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setOptInAnalytics(event.target.checked)
|
||||
}
|
||||
/>
|
||||
<Caption>
|
||||
<Trans>
|
||||
By clicking "Go", you agree to our{" "}
|
||||
|
||||
@@ -214,5 +214,5 @@ export class Initializer {
|
||||
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]
|
||||
|
||||
@@ -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,
|
||||
@@ -34,6 +35,7 @@ import { CallEvent } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import styles from "./GroupCallInspector.module.css";
|
||||
import { SelectInput } from "../input/SelectInput";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
|
||||
interface InspectorContextState {
|
||||
eventsByUserId?: { [userId: string]: SequenceDiagramMatrixEvent[] };
|
||||
@@ -108,6 +110,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 +202,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) => {
|
||||
@@ -389,12 +390,23 @@ function useGroupCallState(
|
||||
function onSendVoipEvent(event: Record<string, unknown>) {
|
||||
dispatch({ type: CallEvent.SendVoipEvent, rawEvent: event });
|
||||
}
|
||||
|
||||
function onUndecryptableToDevice(event: MatrixEvent) {
|
||||
dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
|
||||
|
||||
Sentry.captureMessage("Undecryptable to-device Event");
|
||||
PosthogAnalytics.instance.eventUndecryptableToDevice.track(
|
||||
groupCall.groupCallId
|
||||
);
|
||||
}
|
||||
|
||||
client.on(RoomStateEvent.Events, onUpdateRoomState);
|
||||
//groupCall.on("calls_changed", onCallsChanged);
|
||||
groupCall.on(CallEvent.SendVoipEvent, onSendVoipEvent);
|
||||
//client.on("state", onCallsChanged);
|
||||
//client.on("hangup", onCallHangup);
|
||||
client.on(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
|
||||
client.on(ClientEvent.UndecryptableToDeviceEvent, onUndecryptableToDevice);
|
||||
|
||||
onUpdateRoomState();
|
||||
|
||||
@@ -405,6 +417,10 @@ function useGroupCallState(
|
||||
//client.removeListener("state", onCallsChanged);
|
||||
//client.removeListener("hangup", onCallHangup);
|
||||
client.removeListener(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
|
||||
client.removeListener(
|
||||
ClientEvent.UndecryptableToDeviceEvent,
|
||||
onUndecryptableToDevice
|
||||
);
|
||||
};
|
||||
}, [client, groupCall]);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ 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";
|
||||
|
||||
@@ -75,6 +75,7 @@ export function GroupCallView({
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
toggleScreensharing,
|
||||
setMicrophoneMuted,
|
||||
requestingScreenshare,
|
||||
isScreensharing,
|
||||
screenshareFeeds,
|
||||
@@ -251,6 +252,7 @@ export function GroupCallView({
|
||||
localVideoMuted={localVideoMuted}
|
||||
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
||||
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
||||
setMicrophoneMuted={setMicrophoneMuted}
|
||||
userMediaFeeds={userMediaFeeds}
|
||||
activeSpeaker={activeSpeaker}
|
||||
onLeave={onLeave}
|
||||
|
||||
@@ -20,9 +20,10 @@ limitations under the License.
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
--footerPadding: 8px;
|
||||
--footerHeight: calc(50px + 2 * var(--footerPadding));
|
||||
}
|
||||
|
||||
.centerMessage {
|
||||
@@ -39,11 +40,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 +82,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,8 @@ 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";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
||||
@@ -81,6 +91,7 @@ interface Props {
|
||||
toggleLocalVideoMuted: () => void;
|
||||
toggleMicrophoneMuted: () => void;
|
||||
toggleScreensharing: () => void;
|
||||
setMicrophoneMuted: (muted: boolean) => void;
|
||||
userMediaFeeds: CallFeed[];
|
||||
activeSpeaker: CallFeed | null;
|
||||
onLeave: () => void;
|
||||
@@ -101,6 +112,7 @@ export function InCallView({
|
||||
localVideoMuted,
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
setMicrophoneMuted,
|
||||
userMediaFeeds,
|
||||
activeSpeaker,
|
||||
onLeave,
|
||||
@@ -141,6 +153,13 @@ export function InCallView({
|
||||
|
||||
const { hideScreensharing } = useUrlParams();
|
||||
|
||||
useCallViewKeyboardShortcuts(
|
||||
!feedbackModalState.isOpen,
|
||||
toggleMicrophoneMuted,
|
||||
toggleLocalVideoMuted,
|
||||
setMicrophoneMuted
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport.send(
|
||||
layout === "freedom"
|
||||
@@ -266,6 +285,8 @@ export function InCallView({
|
||||
[]
|
||||
);
|
||||
|
||||
const [newGrid] = useNewGrid();
|
||||
const Grid = newGrid ? NewVideoGrid : VideoGrid;
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
@@ -279,8 +300,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 +316,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 +334,7 @@ export function InCallView({
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</VideoGrid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -358,27 +372,36 @@ 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}
|
||||
/>,
|
||||
<VideoButton
|
||||
key="2"
|
||||
muted={localVideoMuted}
|
||||
onPress={toggleLocalVideoMuted}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!reducedControls) {
|
||||
if (canScreenshare && !hideScreensharing && !isSafari) {
|
||||
buttons.push(
|
||||
<ScreenshareButton
|
||||
key="3"
|
||||
enabled={isScreensharing}
|
||||
onPress={toggleScreensharing}
|
||||
/>
|
||||
)}
|
||||
{!maximisedParticipant && (
|
||||
);
|
||||
}
|
||||
if (!maximisedParticipant) {
|
||||
buttons.push(
|
||||
<OverflowMenu
|
||||
key="4"
|
||||
inCall
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
groupCall={groupCall}
|
||||
@@ -386,10 +409,12 @@ export function InCallView({
|
||||
feedbackModalState={feedbackModalState}
|
||||
feedbackModalProps={feedbackModalProps}
|
||||
/>
|
||||
)}
|
||||
<HangupButton onPress={onLeave} />
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
buttons.push(<HangupButton key="6" onPress={onLeave} />);
|
||||
footer = <div className={styles.footer}>{buttons}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -210,36 +210,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 &&
|
||||
|
||||
@@ -50,9 +50,9 @@ export const RoomPage: FC = () => {
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If we're not already authed and we've been given a display name as
|
||||
// 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 +63,7 @@ export const RoomPage: FC = () => {
|
||||
displayName,
|
||||
setIsRegistering,
|
||||
registerPasswordlessUser,
|
||||
loading,
|
||||
]);
|
||||
|
||||
const groupCallView = useCallback(
|
||||
|
||||
@@ -29,11 +29,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { IWidgetApiRequest } from "matrix-widget-api";
|
||||
|
||||
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";
|
||||
|
||||
export enum ConnectionState {
|
||||
EstablishingCall = "establishing call", // call hasn't been established yet
|
||||
@@ -60,6 +58,7 @@ export interface UseGroupCallReturnType {
|
||||
toggleLocalVideoMuted: () => void;
|
||||
toggleMicrophoneMuted: () => void;
|
||||
toggleScreensharing: () => void;
|
||||
setMicrophoneMuted: (muted: boolean) => void;
|
||||
requestingScreenshare: boolean;
|
||||
isScreensharing: boolean;
|
||||
screenshareFeeds: CallFeed[];
|
||||
@@ -99,12 +98,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,
|
||||
});
|
||||
}
|
||||
@@ -159,6 +170,38 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
[setState]
|
||||
);
|
||||
|
||||
const doNothingMediaActionCallback = useCallback(
|
||||
(details: MediaSessionActionDetails) => {},
|
||||
[]
|
||||
);
|
||||
|
||||
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({
|
||||
@@ -472,68 +515,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,
|
||||
@@ -548,6 +529,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
toggleScreensharing,
|
||||
setMicrophoneMuted,
|
||||
requestingScreenshare,
|
||||
isScreensharing,
|
||||
screenshareFeeds,
|
||||
|
||||
@@ -52,12 +52,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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,3 +26,12 @@ limitations under the License.
|
||||
.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 {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
@@ -33,11 +33,14 @@ import {
|
||||
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 { optInDescription } from "../analytics/AnalyticsOptInDescription";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -61,7 +64,10 @@ export const SettingsModal = (props: Props) => {
|
||||
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
|
||||
const [showInspector, setShowInspector] = useShowInspector();
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
const [developerSettingsTab, setDeveloperSettingsTab] =
|
||||
useDeveloperSettingsTab();
|
||||
const [keyboardShortcuts, setKeyboardShortcuts] = useKeyboardShortcuts();
|
||||
const [newGrid, setNewGrid] = useNewGrid();
|
||||
|
||||
const downloadDebugLog = useDownloadDebugLog();
|
||||
|
||||
@@ -78,7 +84,7 @@ export const SettingsModal = (props: Props) => {
|
||||
title={
|
||||
<>
|
||||
<AudioIcon width={16} height={16} />
|
||||
<span>{t("Audio")}</span>
|
||||
<span className={styles.tabLabel}>{t("Audio")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -156,24 +162,11 @@ export const SettingsModal = (props: Props) => {
|
||||
title={
|
||||
<>
|
||||
<OverflowIcon width={16} height={16} />
|
||||
<span>{t("Advanced")}</span>
|
||||
<span>{t("More")}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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."
|
||||
)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setOptInAnalytics(event.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<h4>Keyboard</h4>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="keyboardShortcuts"
|
||||
@@ -188,40 +181,79 @@ export const SettingsModal = (props: Props) => {
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
<h4>Analytics</h4>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="showInspector"
|
||||
name="inspector"
|
||||
label={t("Show call inspector")}
|
||||
id="optInAnalytics"
|
||||
type="checkbox"
|
||||
checked={showInspector}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setShowInspector(e.target.checked)
|
||||
checked={optInAnalytics}
|
||||
description={optInDescription()}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setOptInAnalytics(event.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Button onPress={downloadDebugLog}>
|
||||
{t("Download debug logs")}
|
||||
</Button>
|
||||
<InputField
|
||||
id="developerSettingsTab"
|
||||
type="checkbox"
|
||||
checked={developerSettingsTab}
|
||||
label={t("Developer Settings")}
|
||||
description={t(
|
||||
"Expose developer settings in the settings window."
|
||||
)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setDeveloperSettingsTab(event.target.checked)
|
||||
}
|
||||
/>
|
||||
</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: "-",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -90,3 +90,6 @@ export const useShowInspector = () => useSetting("show-inspector", false);
|
||||
export const useOptInAnalytics = () => useSetting("opt-in-analytics", false);
|
||||
export const useKeyboardShortcuts = () =>
|
||||
useSetting("keyboard-shortcuts", true);
|
||||
export const useNewGrid = () => useSetting("new-grid", false);
|
||||
export const useDeveloperSettingsTab = () =>
|
||||
useSetting("developer-settings-tab", false);
|
||||
|
||||
93
src/useCallViewKeyboardShortcuts.ts
Normal file
93
src/useCallViewKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
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 { useCallback, useState } from "react";
|
||||
|
||||
import { getSetting } from "./settings/useSetting";
|
||||
import { useEventTarget } from "./useEvents";
|
||||
|
||||
export function useCallViewKeyboardShortcuts(
|
||||
enabled: boolean,
|
||||
toggleMicrophoneMuted: () => void,
|
||||
toggleLocalVideoMuted: () => void,
|
||||
setMicrophoneMuted: (muted: boolean) => void
|
||||
) {
|
||||
const [spacebarHeld, setSpacebarHeld] = useState(false);
|
||||
|
||||
useEventTarget(
|
||||
window,
|
||||
"keydown",
|
||||
useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
// 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 === " " && !spacebarHeld) {
|
||||
setSpacebarHeld(true);
|
||||
setMicrophoneMuted(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
enabled,
|
||||
spacebarHeld,
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
setMicrophoneMuted,
|
||||
setSpacebarHeld,
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
useEventTarget(
|
||||
window,
|
||||
"keyup",
|
||||
useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
// Check if keyboard shortcuts are enabled
|
||||
const keyboardShortcuts = getSetting("keyboard-shortcuts", true);
|
||||
if (!keyboardShortcuts) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === " ") {
|
||||
setSpacebarHeld(false);
|
||||
setMicrophoneMuted(true);
|
||||
}
|
||||
},
|
||||
[enabled, setMicrophoneMuted, setSpacebarHeld]
|
||||
)
|
||||
);
|
||||
|
||||
useEventTarget(
|
||||
window,
|
||||
"blur",
|
||||
useCallback(() => {
|
||||
if (spacebarHeld) {
|
||||
setSpacebarHeld(false);
|
||||
setMicrophoneMuted(true);
|
||||
}
|
||||
}, [setMicrophoneMuted, setSpacebarHeld, 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]
|
||||
),
|
||||
];
|
||||
};
|
||||
@@ -31,14 +31,15 @@ export const AudioSink: React.FC<Props> = ({
|
||||
tileDescriptor,
|
||||
audioOutput,
|
||||
}: Props) => {
|
||||
const { audioMuted, localVolume, stream } = useCallFeed(
|
||||
tileDescriptor.callFeed
|
||||
);
|
||||
const { localVolume, stream } = useCallFeed(tileDescriptor.callFeed);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
461
src/video-grid/NewVideoGrid.tsx
Normal file
461
src/video-grid/NewVideoGrid.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
/*
|
||||
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,
|
||||
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,7 +16,12 @@ 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 { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/types";
|
||||
@@ -42,6 +47,17 @@ interface Tile {
|
||||
presenter: boolean;
|
||||
}
|
||||
|
||||
export interface TileSpring {
|
||||
opacity: number;
|
||||
scale: number;
|
||||
shadow: number;
|
||||
zIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type LayoutDirection = "vertical" | "horizontal";
|
||||
|
||||
export function useVideoGridLayout(hasScreenshareFeeds: boolean): {
|
||||
@@ -692,20 +708,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;
|
||||
@@ -896,6 +915,7 @@ export function VideoGrid({
|
||||
shadow: 0,
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
zIndex: 0,
|
||||
},
|
||||
reset: false,
|
||||
};
|
||||
@@ -919,6 +939,7 @@ export function VideoGrid({
|
||||
shadow: number;
|
||||
scale: number;
|
||||
opacity: number;
|
||||
zIndex?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
@@ -965,7 +986,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 +1197,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,13 @@ 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);
|
||||
border-radius: 8px;
|
||||
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 +30,6 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.videoTile * {
|
||||
touch-action: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -143,13 +142,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 +171,9 @@ limitations under the License.
|
||||
max-width: 360px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.videoTile {
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ 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";
|
||||
|
||||
@@ -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,17 @@ interface Props {
|
||||
showOptions?: boolean;
|
||||
isLocal?: boolean;
|
||||
disableSpeakingIndicator?: boolean;
|
||||
opacity?: SpringValue<number>;
|
||||
scale?: SpringValue<number>;
|
||||
shadow?: 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 +67,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
mediaRef,
|
||||
onOptionsPress,
|
||||
localVolume,
|
||||
hasAudio,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onFullscreen,
|
||||
@@ -66,6 +76,14 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
isLocal,
|
||||
// TODO: disableSpeakingIndicator is not used atm.
|
||||
disableSpeakingIndicator,
|
||||
opacity,
|
||||
scale,
|
||||
shadow,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
@@ -74,14 +92,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 +138,22 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
[styles.screenshare]: screenshare,
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
ref={ref}
|
||||
style={{
|
||||
opacity,
|
||||
scale,
|
||||
boxShadow: shadow?.to(
|
||||
(s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px`
|
||||
),
|
||||
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`),
|
||||
}}
|
||||
ref={ref as ForwardedRef<HTMLDivElement>}
|
||||
{...rest}
|
||||
>
|
||||
{toolbarButtons.length > 0 && !maximised && (
|
||||
@@ -137,7 +172,13 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames(styles.infoBubble, styles.memberName)}>
|
||||
{audioMuted && !videoMuted && <MicMutedIcon />}
|
||||
{
|
||||
/* 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. */
|
||||
audioMuted && !videoMuted && !speaking && <MicMutedIcon />
|
||||
}
|
||||
{videoMuted && <VideoMutedIcon />}
|
||||
<span title={caption}>{caption}</span>
|
||||
</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,113 @@ interface Props {
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onFullscreen: (item: TileDescriptor) => void;
|
||||
opacity?: SpringValue<number>;
|
||||
scale?: SpringValue<number>;
|
||||
shadow?: 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);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ interface CallFeedState {
|
||||
videoMuted: boolean;
|
||||
audioMuted: boolean;
|
||||
localVolume: number;
|
||||
hasAudio: boolean;
|
||||
disposed: boolean | undefined;
|
||||
stream: MediaStream | undefined;
|
||||
purpose: SDPStreamMetadataPurpose | undefined;
|
||||
@@ -38,6 +39,7 @@ 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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
283
test/video-grid/model-test.ts
Normal file
283
test/video-grid/model-test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
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 {
|
||||
appendItems,
|
||||
column,
|
||||
cycleTileSize,
|
||||
fillGaps,
|
||||
forEachCellInArea,
|
||||
Grid,
|
||||
row,
|
||||
} from "../../src/video-grid/model";
|
||||
import { TileDescriptor } from "../../src/video-grid/TileDescriptor";
|
||||
|
||||
/**
|
||||
* Builds a grid from a string specifying the contents of each cell as a letter.
|
||||
*/
|
||||
function mkGrid(spec: string): Grid {
|
||||
const secondNewline = spec.indexOf("\n", 1);
|
||||
const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
|
||||
const cells = spec.match(/[a-z ]/g) ?? [];
|
||||
const areas = new Set(cells);
|
||||
areas.delete(" "); // Space represents an empty cell, not an area
|
||||
const grid: Grid = { columns, cells: new Array(cells.length) };
|
||||
|
||||
for (const area of areas) {
|
||||
const start = cells.indexOf(area);
|
||||
const end = cells.lastIndexOf(area);
|
||||
const rows = row(end, grid) - row(start, grid) + 1;
|
||||
const columns = column(end, grid) - column(start, grid) + 1;
|
||||
|
||||
forEachCellInArea(start, end, grid, (_c, i) => {
|
||||
grid.cells[i] = {
|
||||
item: { id: area } as unknown as TileDescriptor,
|
||||
origin: i === start,
|
||||
rows,
|
||||
columns,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a grid into a string showing the contents of each cell as a letter.
|
||||
*/
|
||||
function showGrid(g: Grid): string {
|
||||
let result = "\n";
|
||||
g.cells.forEach((c, i) => {
|
||||
if (i > 0 && i % g.columns == 0) result += "\n";
|
||||
result += c?.item.id ?? " ";
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function testFillGaps(title: string, input: string, output: string): void {
|
||||
test(`fillGaps ${title}`, () => {
|
||||
expect(showGrid(fillGaps(mkGrid(input)))).toBe(output);
|
||||
});
|
||||
}
|
||||
|
||||
testFillGaps(
|
||||
"does nothing on an empty grid",
|
||||
`
|
||||
`,
|
||||
`
|
||||
`
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"does nothing if there are no gaps",
|
||||
`
|
||||
ab
|
||||
cd
|
||||
ef`,
|
||||
`
|
||||
ab
|
||||
cd
|
||||
ef`
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"fills a gap",
|
||||
`
|
||||
a b
|
||||
cde
|
||||
f`,
|
||||
`
|
||||
cab
|
||||
fde`
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"fills multiple gaps",
|
||||
`
|
||||
a bc
|
||||
defgh
|
||||
ijkl
|
||||
mno`,
|
||||
`
|
||||
aebch
|
||||
difgl
|
||||
monjk`
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"fills a big gap",
|
||||
`
|
||||
abcd
|
||||
e f
|
||||
g h
|
||||
ijkl`,
|
||||
`
|
||||
abcd
|
||||
elhf
|
||||
gkji`
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"only moves 1×1 tiles",
|
||||
`
|
||||
|
||||
aa
|
||||
bc`,
|
||||
`
|
||||
bc
|
||||
aa`
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"prefers moving around large tiles",
|
||||
`
|
||||
a bc
|
||||
ddde
|
||||
dddf
|
||||
ghij
|
||||
k`,
|
||||
`
|
||||
abce
|
||||
dddf
|
||||
dddj
|
||||
kghi`
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"moves through large tiles if necessary",
|
||||
`
|
||||
a bc
|
||||
dddd
|
||||
efgh
|
||||
i`,
|
||||
`
|
||||
afbc
|
||||
dddd
|
||||
iegh`
|
||||
);
|
||||
|
||||
function testCycleTileSize(
|
||||
title: string,
|
||||
tileId: string,
|
||||
input: string,
|
||||
output: string
|
||||
): void {
|
||||
test(`cycleTileSize ${title}`, () => {
|
||||
expect(showGrid(cycleTileSize(tileId, mkGrid(input)))).toBe(output);
|
||||
});
|
||||
}
|
||||
|
||||
testCycleTileSize(
|
||||
"does nothing if the tile is not present",
|
||||
"z",
|
||||
`
|
||||
abcd
|
||||
efgh`,
|
||||
`
|
||||
abcd
|
||||
efgh`
|
||||
);
|
||||
|
||||
testCycleTileSize(
|
||||
"expands a tile to 2×2 in a 3 column layout",
|
||||
"c",
|
||||
`
|
||||
abc
|
||||
def
|
||||
ghi`,
|
||||
`
|
||||
acc
|
||||
bcc
|
||||
def
|
||||
ghi`
|
||||
);
|
||||
|
||||
testCycleTileSize(
|
||||
"expands a tile to 3×3 in a 4 column layout",
|
||||
"g",
|
||||
`
|
||||
abcd
|
||||
efgh`,
|
||||
`
|
||||
abcd
|
||||
eggg
|
||||
fggg
|
||||
h`
|
||||
);
|
||||
|
||||
testCycleTileSize(
|
||||
"restores a tile to 1×1",
|
||||
"b",
|
||||
`
|
||||
abbc
|
||||
dbbe
|
||||
fghi
|
||||
jk`,
|
||||
`
|
||||
abhc
|
||||
djge
|
||||
fik`
|
||||
);
|
||||
|
||||
testCycleTileSize(
|
||||
"expands a tile even in a crowded grid",
|
||||
"c",
|
||||
`
|
||||
abb
|
||||
cbb
|
||||
dde
|
||||
ddf
|
||||
ghi
|
||||
klm`,
|
||||
`
|
||||
abb
|
||||
gbb
|
||||
dde
|
||||
ddf
|
||||
cci
|
||||
cch
|
||||
klm`
|
||||
);
|
||||
|
||||
testCycleTileSize(
|
||||
"does nothing if the tile has no room to expand",
|
||||
"c",
|
||||
`
|
||||
abb
|
||||
cbb
|
||||
dde
|
||||
ddf`,
|
||||
`
|
||||
abb
|
||||
cbb
|
||||
dde
|
||||
ddf`
|
||||
);
|
||||
|
||||
test("appendItems appends 1×1 tiles", () => {
|
||||
const grid1 = `
|
||||
aab
|
||||
aac
|
||||
d`;
|
||||
const grid2 = `
|
||||
aab
|
||||
aac
|
||||
def`;
|
||||
const newItems = ["e", "f"].map(
|
||||
(i) => ({ id: i } as unknown as TileDescriptor)
|
||||
);
|
||||
expect(showGrid(appendItems(newItems, mkGrid(grid1)))).toBe(grid2);
|
||||
});
|
||||
156
yarn.lock
156
yarn.lock
@@ -1795,7 +1795,7 @@
|
||||
"@jridgewell/gen-mapping" "^0.3.0"
|
||||
"@jridgewell/trace-mapping" "^0.3.9"
|
||||
|
||||
"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10":
|
||||
"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13":
|
||||
version "1.4.14"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
|
||||
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
|
||||
@@ -1821,9 +1821,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
|
||||
integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
|
||||
|
||||
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz":
|
||||
version "3.2.8"
|
||||
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856"
|
||||
"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.3":
|
||||
version "0.1.0-alpha.4"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.4.tgz#1b20294e0354c3dcc9c7dc810d883198a4042f04"
|
||||
integrity sha512-mdaDKrw3P5ZVCpq0ioW0pV6ihviDEbS8ZH36kpt9stLKHwwDSopPogE6CkQhi0B1jn1yBUtOYi32mBV/zcOR7g==
|
||||
|
||||
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz":
|
||||
version "3.2.14"
|
||||
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz#acd96c00a881d0f462e1f97a56c73742c8dbc984"
|
||||
|
||||
"@mdx-js/mdx@^1.6.22":
|
||||
version "1.6.22"
|
||||
@@ -2380,6 +2385,28 @@
|
||||
"@sentry/utils" "6.19.7"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/bundler-plugin-core@0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-0.3.0.tgz#de35a908c01a383611274572156845db6e46d30b"
|
||||
integrity sha512-484beABAjdJa6thVgzOC0hBdgVpC0kg5r3S88U0zx3b/i6CTwUemHwamh6+RZ/gu8ChmooWb6NznaBAlpHgxCA==
|
||||
dependencies:
|
||||
"@sentry/cli" "^2.10.0"
|
||||
"@sentry/node" "^7.19.0"
|
||||
"@sentry/tracing" "^7.19.0"
|
||||
magic-string "0.27.0"
|
||||
unplugin "0.10.1"
|
||||
|
||||
"@sentry/cli@^2.10.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.11.0.tgz#a324cd1ae98e7d206f5ed739dc40c9728eb0fce8"
|
||||
integrity sha512-qfCf/R0VhmlWcdfu2rntejqbIgovx7FQTwFreQpbISlB/JS9xHF8KEEJXZTdDFoPCi2H9KHg4CPUsCNAKbAdMA==
|
||||
dependencies:
|
||||
https-proxy-agent "^5.0.0"
|
||||
node-fetch "^2.6.7"
|
||||
progress "^2.0.3"
|
||||
proxy-from-env "^1.1.0"
|
||||
which "^2.0.2"
|
||||
|
||||
"@sentry/core@6.19.7":
|
||||
version "6.19.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.19.7.tgz#156aaa56dd7fad8c89c145be6ad7a4f7209f9785"
|
||||
@@ -2391,6 +2418,15 @@
|
||||
"@sentry/utils" "6.19.7"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/core@7.28.1":
|
||||
version "7.28.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.28.1.tgz#c712ce17469b18b01606108817be24a99ed2116e"
|
||||
integrity sha512-7wvnuvn/mrAfcugWoCG/3pqDIrUgH5t+HisMJMGw0h9Tc33KqrmqMDCQVvjlrr2pWrw/vuUCFdm8CbUHJ832oQ==
|
||||
dependencies:
|
||||
"@sentry/types" "7.28.1"
|
||||
"@sentry/utils" "7.28.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/hub@6.19.7":
|
||||
version "6.19.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.19.7.tgz#58ad7776bbd31e9596a8ec46365b45cd8b9cfd11"
|
||||
@@ -2409,6 +2445,19 @@
|
||||
"@sentry/types" "6.19.7"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/node@^7.19.0":
|
||||
version "7.28.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.28.1.tgz#fc53675a048c29c86e5a8cd3ba570c454f492c18"
|
||||
integrity sha512-n7AbpJqZJjWPpKNGc55mP7AdQ+XSomS9MZJuZ+Xt2AU52aVwGPI4z9aHUJFSDGaMHHiu/toyPnoUES+XZf6/hw==
|
||||
dependencies:
|
||||
"@sentry/core" "7.28.1"
|
||||
"@sentry/types" "7.28.1"
|
||||
"@sentry/utils" "7.28.1"
|
||||
cookie "^0.4.1"
|
||||
https-proxy-agent "^5.0.0"
|
||||
lru_map "^0.3.3"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/react@^6.13.3":
|
||||
version "6.19.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.19.7.tgz#58cc2d6da20f7d3b0df40638dfbbbc86c9c85caf"
|
||||
@@ -2432,11 +2481,26 @@
|
||||
"@sentry/utils" "6.19.7"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/tracing@^7.19.0":
|
||||
version "7.28.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-7.28.1.tgz#d276e4d17a79190a88112696c73de12c209607a1"
|
||||
integrity sha512-uWspnuz+7FyW8ES5lRaVA7O/YJSzMlSkvBFtgzaoKmdaueokU/sRLwlCsrdgwavG1wpm79df7R1iiSeqhaXDlw==
|
||||
dependencies:
|
||||
"@sentry/core" "7.28.1"
|
||||
"@sentry/types" "7.28.1"
|
||||
"@sentry/utils" "7.28.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/types@6.19.7":
|
||||
version "6.19.7"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.19.7.tgz#c6b337912e588083fc2896eb012526cf7cfec7c7"
|
||||
integrity sha512-jH84pDYE+hHIbVnab3Hr+ZXr1v8QABfhx39KknxqKWr2l0oEItzepV0URvbEhB446lk/S/59230dlUUIBGsXbg==
|
||||
|
||||
"@sentry/types@7.28.1":
|
||||
version "7.28.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.28.1.tgz#9018b4c152b475de9bedd267237393d3c9b1253d"
|
||||
integrity sha512-DvSplMVrVEmOzR2M161V5+B8Up3vR71xMqJOpWTzE9TqtFJRGPtqT/5OBsNJJw1+/j2ssMcnKwbEo9Q2EGeS6g==
|
||||
|
||||
"@sentry/types@^7.2.0":
|
||||
version "7.13.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.13.0.tgz#398e33e5c92ea0ce91e2c86e3ab003fe00c471a2"
|
||||
@@ -2450,6 +2514,21 @@
|
||||
"@sentry/types" "6.19.7"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/utils@7.28.1":
|
||||
version "7.28.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.28.1.tgz#0a7b6aa4b09e91e4d1aded2a8c8dbaf818cee96e"
|
||||
integrity sha512-75/jzLUO9HH09iC9TslNimGbxOP3jgn89P+q7uR+rp2fJfRExHVeKJZQdK0Ij4/SmE7TJ3Uh2r154N0INZEx1g==
|
||||
dependencies:
|
||||
"@sentry/types" "7.28.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/vite-plugin@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-0.3.0.tgz#37d6a99ab7bf307b6ac3a5dc85ca16c52c2fff9c"
|
||||
integrity sha512-clu0yfVk9ejk3l22NHrFUPqHzR9ukYccm7Q5qBKgMyLXnWGLRf4WahmzzuW9XHDu4s3tYVjV/rTTxGxLPT9dMQ==
|
||||
dependencies:
|
||||
"@sentry/bundler-plugin-core" "0.3.0"
|
||||
|
||||
"@sinclair/typebox@^0.24.1":
|
||||
version "0.24.51"
|
||||
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f"
|
||||
@@ -5143,7 +5222,7 @@ cheerio@^1.0.0-rc.2:
|
||||
parse5 "^7.0.0"
|
||||
parse5-htmlparser2-tree-adapter "^7.0.0"
|
||||
|
||||
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1, chokidar@^3.4.2:
|
||||
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1, chokidar@^3.4.2, chokidar@^3.5.3:
|
||||
version "3.5.3"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
|
||||
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
|
||||
@@ -5532,6 +5611,11 @@ cookie@0.5.0:
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
|
||||
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
||||
|
||||
cookie@^0.4.1:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
|
||||
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
|
||||
|
||||
copy-concurrently@^1.0.0:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0"
|
||||
@@ -8649,7 +8733,7 @@ https-browserify@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
|
||||
integrity sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==
|
||||
|
||||
https-proxy-agent@^5.0.1:
|
||||
https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
|
||||
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
|
||||
@@ -10199,11 +10283,23 @@ lru-cache@^6.0.0:
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
lru_map@^0.3.3:
|
||||
version "0.3.3"
|
||||
resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd"
|
||||
integrity sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==
|
||||
|
||||
lz-string@^1.4.4:
|
||||
version "1.4.4"
|
||||
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
|
||||
integrity sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==
|
||||
|
||||
magic-string@0.27.0:
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3"
|
||||
integrity sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.4.13"
|
||||
|
||||
make-dir@^2.0.0, make-dir@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
|
||||
@@ -10266,11 +10362,12 @@ matrix-events-sdk@0.0.1:
|
||||
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd"
|
||||
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
|
||||
|
||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#2c8eece5ca5333c6e6a14e8ed53f359ed0e9e9bf":
|
||||
version "21.2.0"
|
||||
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2c8eece5ca5333c6e6a14e8ed53f359ed0e9e9bf"
|
||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#8cbbdaa239e449848e8874f041ef1879c1956696":
|
||||
version "23.4.0"
|
||||
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8cbbdaa239e449848e8874f041ef1879c1956696"
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
"@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.3"
|
||||
another-json "^0.2.0"
|
||||
bs58 "^5.0.0"
|
||||
content-type "^1.0.4"
|
||||
@@ -10278,9 +10375,9 @@ matrix-events-sdk@0.0.1:
|
||||
matrix-events-sdk "0.0.1"
|
||||
matrix-widget-api "^1.0.0"
|
||||
p-retry "4"
|
||||
qs "^6.9.6"
|
||||
sdp-transform "^2.14.1"
|
||||
unhomoglyph "^1.0.6"
|
||||
uuid "9"
|
||||
|
||||
matrix-widget-api@^1.0.0:
|
||||
version "1.1.1"
|
||||
@@ -11848,6 +11945,11 @@ process@^0.11.10:
|
||||
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
||||
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
|
||||
|
||||
progress@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
|
||||
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
|
||||
|
||||
promise-inflight@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
|
||||
@@ -11918,6 +12020,11 @@ proxy-addr@~2.0.7:
|
||||
forwarded "0.2.0"
|
||||
ipaddr.js "1.9.1"
|
||||
|
||||
proxy-from-env@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
|
||||
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
|
||||
|
||||
prr@~1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
|
||||
@@ -11992,7 +12099,7 @@ qs@6.10.3:
|
||||
dependencies:
|
||||
side-channel "^1.0.4"
|
||||
|
||||
qs@^6.10.0, qs@^6.9.6:
|
||||
qs@^6.10.0:
|
||||
version "6.11.0"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
|
||||
integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
|
||||
@@ -13610,6 +13717,11 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
|
||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||
|
||||
tinyqueue@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08"
|
||||
integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==
|
||||
|
||||
tmpl@1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
|
||||
@@ -14051,6 +14163,16 @@ unpipe@1.0.0, unpipe@~1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
|
||||
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
|
||||
|
||||
unplugin@0.10.1:
|
||||
version "0.10.1"
|
||||
resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-0.10.1.tgz#e00dc951c1901aef4124121057102a8c290e28b3"
|
||||
integrity sha512-y1hdBitiLOJvCmer0/IGrMGmHplsm2oFRGWleoAJTRQ8aMHxHOe9gLntYlh1WNLKufBuQ2sOTrHF+KWH4xE8Ag==
|
||||
dependencies:
|
||||
acorn "^8.8.0"
|
||||
chokidar "^3.5.3"
|
||||
webpack-sources "^3.2.3"
|
||||
webpack-virtual-modules "^0.4.5"
|
||||
|
||||
unset-value@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
|
||||
@@ -14183,6 +14305,11 @@ utils-merge@1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
|
||||
|
||||
uuid@9:
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
|
||||
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
|
||||
|
||||
uuid@^3.3.2:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
|
||||
@@ -14487,6 +14614,11 @@ webpack-virtual-modules@^0.2.2:
|
||||
dependencies:
|
||||
debug "^3.0.0"
|
||||
|
||||
webpack-virtual-modules@^0.4.5:
|
||||
version "0.4.6"
|
||||
resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.4.6.tgz#3e4008230731f1db078d9cb6f68baf8571182b45"
|
||||
integrity sha512-5tyDlKLqPfMqjT3Q9TAqf2YqjwmnUleZwzJi1A5qXnlBCdj2AtOJ6wAWdglTIDOPgOiOrXeBeFcsQ8+aGQ6QbA==
|
||||
|
||||
webpack@4:
|
||||
version "4.46.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.46.0.tgz#bf9b4404ea20a073605e0a011d188d77cb6ad542"
|
||||
@@ -14607,7 +14739,7 @@ which-typed-array@^1.1.2:
|
||||
has-tostringtag "^1.0.0"
|
||||
is-typed-array "^1.1.9"
|
||||
|
||||
which@^2.0.1:
|
||||
which@^2.0.1, which@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
|
||||
integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
|
||||
|
||||
Reference in New Issue
Block a user