Compare commits

...

353 Commits

Author SHA1 Message Date
David Baker
54fe2aa7a3 Bump js-sdk for addTransceiver fix 2022-10-19 18:06:24 +01:00
David Baker
3ff201562b Bump js-sdk 2022-10-19 16:02:48 +01:00
Robin
e139ac6584 Merge pull request #635 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2022-10-19 09:18:55 -04:00
Glandos
85210df28e Translated using Weblate (French)
Currently translated at 100.0% (131 of 131 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/fr/
2022-10-19 06:57:34 +00:00
Rodion Borisov
0af116ce76 Translated using Weblate (Russian)
Currently translated at 100.0% (131 of 131 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ru/
2022-10-19 06:57:34 +00:00
Vri
a09bb109fd Translated using Weblate (German)
Currently translated at 100.0% (131 of 131 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-19 06:57:34 +00:00
Nui Harime
c97185a50e Added translation using Weblate (Ukrainian) 2022-10-19 06:57:34 +00:00
Robin
50f7fedfa0 Merge pull request #632 from robintown/freedom-screenshare
Prevent screenshare feeds from collapsing when you're alone in freedom mode
2022-10-18 11:58:53 -04:00
Robin Townsend
178c6496bd yarn prettier:format 2022-10-18 00:48:29 -04:00
Robin Townsend
c5eb9f0b99 Prevent screenshare feeds from collapsing when you're alone in freedom mode
The code was previously confusing focused and presenter tiles quite a bit, and also had a couple different spots that would mistakenly engage 1:1 layout behavior when you're alone with your own screensharing feed.
2022-10-18 00:30:37 -04:00
Robin
af4c1280f5 Merge pull request #631 from robintown/fix-fullscreen
Fix fullscreen buttons fullscreening the wrong feed
2022-10-17 14:53:31 -04:00
Robin Townsend
97ae11f656 Fix fullscreen buttons fullscreening the wrong feed 2022-10-17 12:31:56 -04:00
Robin
e182dd50f2 Merge pull request #630 from robintown/matryoshka-baseurl
Make avatars work in matryoshka mode
2022-10-17 09:32:30 -04:00
Robin Townsend
43f98e6be6 yarn prettier:format 2022-10-17 09:30:22 -04:00
Robin Townsend
70ba6c3c6b Make avatars work in matryoshka mode
The client just didn't have a homeserver URL to perform media queries against.
2022-10-17 01:46:44 -04:00
Robin
29a7376bc7 Merge pull request #629 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2022-10-17 01:11:07 -04:00
Robin Townsend
db02178fce Revert en-GB changes 2022-10-17 01:07:43 -04:00
Robin Townsend
1d69bef7f9 Remove country codes from most language codes 2022-10-17 01:07:25 -04:00
fkwp
0a83a8804f Translated using Weblate (German)
Currently translated at 100.0% (131 of 131 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-17 04:54:35 +00:00
Thibault Martin
5795e20865 Translated using Weblate (French)
Currently translated at 100.0% (131 of 131 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/fr/
2022-10-15 13:59:22 +00:00
Robin Townsend
4aba1c8b74 Translated using Weblate (Indonesian)
Currently translated at 99.2% (130 of 131 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/id/
2022-10-15 13:59:22 +00:00
Robin Townsend
dc694d4ffe Translated using Weblate (Turkish)
Currently translated at 77.0% (101 of 131 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/tr/
2022-10-15 13:59:21 +00:00
Robin Townsend
fafc56bb90 Translated using Weblate (Korean)
Currently translated at 5.3% (7 of 131 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ko/
2022-10-15 13:59:21 +00:00
fkwp
a83611c287 Translated using Weblate (German)
Currently translated at 100.0% (131 of 131 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:59:21 +00:00
Weblate
2cca320291 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/
2022-10-15 13:21:32 +00:00
Thibault Martin
834582a870 Translated using Weblate (French)
Currently translated at 96.2% (128 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/fr/
2022-10-15 13:21:31 +00:00
fkwp
2390b990c5 Translated using Weblate (German)
Currently translated at 98.4% (131 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
kongo09
166046a4b1 Translated using Weblate (German)
Currently translated at 98.4% (131 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Thibault Martin
f2dbe8abbe Translated using Weblate (French)
Currently translated at 75.1% (100 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/fr/
2022-10-15 13:21:31 +00:00
Linerly
1a814713df Translated using Weblate (Indonesian)
Currently translated at 0.0% (0 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/id/
2022-10-15 13:21:31 +00:00
Erkin Alp Güney
fceb10e2df Translated using Weblate (Turkish)
Currently translated at 0.0% (0 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/tr/
2022-10-15 13:21:31 +00:00
Youngbin Han
94323b3597 Translated using Weblate (Korean)
Currently translated at 0.0% (0 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ko/
2022-10-15 13:21:31 +00:00
Vri
a8c5cb4821 Translated using Weblate (German)
Currently translated at 97.7% (130 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
fkwp
6e32aad729 Translated using Weblate (German)
Currently translated at 97.7% (130 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
fkwp
49f6249144 Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (133 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/en_GB/
2022-10-15 13:21:31 +00:00
Thibault Martin
ab08b58ef5 Added translation using Weblate (French) 2022-10-15 13:21:31 +00:00
Nui Harime
ba9efc64c3 Added translation using Weblate (Russian) 2022-10-15 13:21:31 +00:00
Slavi Pantaleev
e986ef914f Translated using Weblate (Bulgarian)
Currently translated at 99.2% (132 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/bg/
2022-10-15 13:21:31 +00:00
Erkin Alp Güney
68117cd9e4 Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (133 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/en_GB/
2022-10-15 13:21:31 +00:00
Slavi Pantaleev
ccb4f8c0e4 Added translation using Weblate (Bulgarian) 2022-10-15 13:21:31 +00:00
Linerly
1367a50b75 Added translation using Weblate (Indonesian) 2022-10-15 13:21:31 +00:00
Erkin Alp Güney
aec21e661d Added translation using Weblate (Turkish) 2022-10-15 13:21:31 +00:00
Youngbin Han
ae7697b33c Added translation using Weblate (Korean) 2022-10-15 13:21:31 +00:00
Robin Townsend
37f72fe0b6 Translated using Weblate (German)
Currently translated at 94.7% (126 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Johannes Marbach
5660938f47 Translated using Weblate (German)
Currently translated at 94.7% (126 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
kongo09
1c76385d79 Translated using Weblate (German)
Currently translated at 94.7% (126 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
fkwp
208a3d9045 Translated using Weblate (German)
Currently translated at 94.7% (126 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Travis Ralston
16c9483f37 Deleted translation using Weblate (English (United States)) 2022-10-15 13:21:31 +00:00
Travis Ralston
70939fa8f0 Added translation using Weblate (English (United States)) 2022-10-15 13:21:31 +00:00
Robin Townsend
ec1f846c92 Translated using Weblate (German)
Currently translated at 25.5% (34 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
fkwp
1570657176 Translated using Weblate (German)
Currently translated at 25.5% (34 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Timo
7e78f7a670 Translated using Weblate (German)
Currently translated at 25.5% (34 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Robin Townsend
d556fe188a Translated using Weblate (German)
Currently translated at 16.5% (22 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Johannes Marbach
c07aeb3ba8 Translated using Weblate (German)
Currently translated at 16.5% (22 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Timo
a6c6aed61c Translated using Weblate (German)
Currently translated at 16.5% (22 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Robin Townsend
28a20d9b1e Translated using Weblate (German)
Currently translated at 16.5% (22 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Johannes Marbach
077e361a26 Translated using Weblate (German)
Currently translated at 12.7% (17 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Timo
6180f2e1b9 Translated using Weblate (German)
Currently translated at 12.7% (17 of 133 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2022-10-15 13:21:31 +00:00
Robin
5e57a56d21 Merge pull request #627 from robintown/dedup-strings
Consolidate some similar strings
2022-10-15 09:16:45 -04:00
Robin Townsend
402f62e09a Consolidate some similar strings 2022-10-14 18:38:33 -04:00
Robin
6ec2e9c822 Merge pull request #621 from robintown/hide-invite
Hide the invite button in non-public rooms
2022-10-14 10:56:22 -04:00
Robin Townsend
684defdc19 Merge branch 'main' into hide-invite 2022-10-14 10:51:41 -04:00
Robin Townsend
5ed2dc6e0e Split room state hooks out into separate files 2022-10-14 10:50:36 -04:00
Šimon Brandner
ce86a6f120 Merge pull request #622 from vector-im/SimonBrandner/feat/hide-screen 2022-10-14 16:49:54 +02:00
Šimon Brandner
96b1a5f296 hideScreensharing
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-10-14 16:17:50 +02:00
Robin
e9ebccf0df Merge pull request #619 from robintown/unmute
Leave audio elements unmuted regardless of mute state
2022-10-14 09:59:54 -04:00
Robin Townsend
02b2aef958 Hide the invite button in non-public rooms 2022-10-14 09:40:21 -04:00
Robin
c6d60cff64 Merge pull request #620 from robintown/update-js-sdk
Update matrix-js-sdk
2022-10-14 08:08:13 -04:00
Robin Townsend
81771f511c Fix types 2022-10-13 21:25:15 -04:00
Robin Townsend
004160b664 Update matrix-js-sdk 2022-10-13 20:24:48 -04:00
Robin
2d25d3c2bc Merge pull request #618 from robintown/i18n
Set up translation with i18next
2022-10-13 19:53:43 -04:00
Robin Townsend
4728804a33 Leave audio elements unmuted regardless of mute state 2022-10-13 10:49:16 -04:00
Robin Townsend
8524b9ecd6 Set up translation with i18next 2022-10-12 14:53:49 -04:00
David Baker
eca598e28f Merge pull request #609 from vector-im/dbkr/device_by_name
Use device labels rather than IDs in widget API
2022-09-30 17:28:59 +01:00
David Baker
f808c56121 Type 2022-09-29 17:08:48 +01:00
David Baker
77da0c912f Match device type too
Because lots of audio & video inputs have the same name
2022-09-29 17:07:10 +01:00
Šimon Brandner
e8a875eb32 Merge pull request #610 from vector-im/SimonBrandner/task/update-js 2022-09-29 16:11:00 +02:00
Šimon Brandner
e7a94426c2 Update js-sdk (again)
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-09-29 15:20:55 +02:00
David Baker
17613837b6 Hold a user media stream open while we get devices
As per comment.
2022-09-29 13:19:46 +01:00
Šimon Brandner
4b4c98066c Update js-sdk
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-09-28 18:15:34 +02:00
David Baker
4a5b69800c Use device labels rather than IDs in widget API
device IDs are different for each origin, so won't match up when passed
in & out of widgets. Use the label instead.

For https://github.com/vector-im/element-web/issues/23331
2022-09-27 16:19:48 +01:00
Robin
70d6c3e9bf Merge pull request #608 from robintown/reduced-controls
Show a reduced set of controls when the window is narrow
2022-09-27 07:54:24 -04:00
Robin
90e32af220 Merge pull request #607 from robintown/tooltip-style
Make tooltips look more like Element Web's
2022-09-27 07:54:14 -04:00
Robin Townsend
fdc0272940 Show a reduced set of controls when the window is narrow 2022-09-26 20:36:51 -04:00
Robin Townsend
d90a837714 Make tooltips look more like Element Web's
The only thing they're missing now is animated fading.
2022-09-26 20:17:55 -04:00
Robin
47f7e0e5a0 Merge pull request #605 from robintown/maximized-fill
Let the maximized video feed fill the window
2022-09-26 12:28:53 -04:00
David Baker
25388a77aa Merge pull request #603 from vector-im/dbkr/fix_improper_logout
Clear storage after logout
2022-09-26 15:04:28 +01:00
Robin Townsend
2155d9bb80 Let the maximized video feed fill the window
instead of getting letterboxed.
2022-09-26 09:55:39 -04:00
David Baker
46ab10f733 Remove unintentional commenting 2022-09-26 13:03:39 +01:00
David Baker
6e91ec3a0e Clear storage after logout 2022-09-26 13:01:43 +01:00
David Baker
b55aa12100 Merge pull request #602 from vector-im/dbkr/fix_capture_devices_left_on
Fix bug causing mic/webcam to remain open after call
2022-09-23 17:09:39 +01:00
David Baker
ded6a80b58 Fix passworldess user prompt screen
This is how boolean logic works
2022-09-23 15:38:35 +01:00
David Baker
7435f1101a Fix bug causing mic/webcam to remain open after call
Fixes https://github.com/vector-im/element-call/issues/596
2022-09-23 15:35:05 +01:00
David Baker
7720770c67 Merge pull request #601 from vector-im/dbkr/yet_another_splitbrain_fix
Fix another cause of split-brain rooms
2022-09-23 14:17:10 +01:00
David Baker
d9fc9e82ab Fix another cause of split-brain rooms
Wait for the client to start syncing before we attempt to join a
room.

Fixes https://github.com/vector-im/element-call/issues/600 (detailed
bug analysis is also in that issue).
2022-09-23 10:50:42 +01:00
Robin
ae66e4b3f8 Merge pull request #599 from robintown/simplify-maximised
Further simplify the maximised speaker view
2022-09-23 00:30:44 -04:00
Robin Townsend
1e65f10d3f Merge branch 'main' into simplify-maximised 2022-09-23 00:29:29 -04:00
Robin
a76f27152b Merge pull request #598 from robintown/no-fullscreen-self
Don't allow the user to fullscreen their own screenshare feed
2022-09-23 00:28:02 -04:00
Robin Townsend
de0df4b534 Further simplify the maximised speaker view 2022-09-22 17:52:05 -04:00
Robin Townsend
f78cf6e79a Don't allow the user to fullscreen their own screenshare feed 2022-09-22 17:35:23 -04:00
David Baker
b84c36eb2e Merge pull request #595 from vector-im/dbkr/fix_spotlight_scroll
Fix scroll bug in spotlight view
2022-09-22 14:49:27 +01:00
David Baker
6355aa863c Fix scroll bug in spotlight view
This was a confusion between indicies of the tile and the tile position:
the spotlight tile is the 0th TilePosition, ie. the tile with order
0, not the tile with index 0.

Also comment one method to hopefully make this slightly easier to
understand.
2022-09-22 12:03:57 +01:00
Robin
80cc10e8b9 Merge pull request #586 from robintown/update-js-sdk
Update matrix-js-sdk and matrix-widget-api
2022-09-16 11:12:09 -04:00
Robin Townsend
10c37d205a Update matrix-js-sdk and matrix-widget-api 2022-09-16 11:09:08 -04:00
Robin Townsend
a9e94c341c Update matrix-js-sdk 2022-09-16 10:27:56 -04:00
Robin
3b181224fd Merge pull request #581 from robintown/maximise
Maximise the active speaker when the window is small
2022-09-16 10:25:55 -04:00
Robin Townsend
89fa9dfd64 Only maximise a participant when the window is narrow, too 2022-09-16 10:23:23 -04:00
Robin Townsend
4a08ae75b3 Make the maximised prop of VideoTile optional 2022-09-16 10:21:41 -04:00
Robin Townsend
d9b0f45c6a Merge branch 'main' into maximise 2022-09-16 10:20:29 -04:00
Robin
c5a3fb72e1 Merge pull request #584 from robintown/strict-plugin
Enable strict mode checks with typescript-strict-plugin
2022-09-15 10:27:18 -04:00
Robin Townsend
f0d7d8fac6 Enable strict mode checks with typescript-strict-plugin
No CI checks at this time, the only effect this will have is adding IDE errors.
2022-09-15 08:31:24 -04:00
David Baker
1f485bfd55 Merge pull request #580 from vector-im/dbkr/mute_null_pointer_check
Update js-sdk for exception fix
2022-09-15 09:32:49 +01:00
Robin Townsend
9e367db324 Maximise the active speaker when the window is small 2022-09-14 19:05:05 -04:00
David Baker
a2fdab8eb9 Update js-sdk for exception fix
For https://github.com/matrix-org/matrix-js-sdk/pull/2667
2022-09-14 09:47:53 +01:00
David Baker
2c052c162f Merge pull request #579 from vector-im/dbkr/fix_call_race
Bump js-sdk dependency
2022-09-13 17:12:00 +01:00
David Baker
b1c9e8c07a Bump js-sdk dependency
For https://github.com/matrix-org/matrix-js-sdk/pull/2662
2022-09-13 16:39:14 +01:00
Timo
f71817b0a2 fix logout (#577)
Co-authored-by: Timo K <timok@element.io>
2022-09-13 16:48:04 +02:00
Robin
73d09bc99c Merge pull request #576 from robintown/unpersist
Unpersist widget after hanging up
2022-09-13 08:34:41 -04:00
Robin
5ebb54a857 Merge pull request #575 from robintown/dont-dedup-widgets
Don't kill other sessions when running as a widget
2022-09-13 08:34:28 -04:00
Robin Townsend
8725b2c230 Unpersist widget after hanging up
Otherwise it can get stuck on screen in Element Web.
2022-09-12 22:54:20 -04:00
Robin Townsend
fd18f2acdf Don't kill other sessions when running as a widget 2022-09-12 15:37:39 -04:00
Robin
3bffe58549 Merge pull request #574 from robintown/ec-in-ew
Prepare for integration into Element Web
2022-09-09 10:01:40 -04:00
Robin Townsend
e8bc22370b Upgrade matrix-js-sdk 2022-09-09 09:54:26 -04:00
Robin Townsend
b7be3011da Add widget actions for joining and leaving calls and switching layouts
These actions are processed lazily to ensure that even if the app takes a while to start up, they won't be missed.
2022-09-09 02:14:12 -04:00
Robin Townsend
f0045c9406 Initialize all widget-related things at the top level 2022-09-09 02:09:12 -04:00
Robin Townsend
3186b5f24b Add a URL parameter for hiding the room header 2022-09-09 02:04:53 -04:00
David Baker
ca5ce7d468 Update to latest js-sdk group call branch 2022-09-08 11:25:21 +01:00
David Baker
a05f6a64a8 Merge pull request #568 from vector-im/dbkr/dont_log_objects
Log ID instead of object
2022-09-07 15:55:52 +01:00
David Baker
70dffe95ff Handle groupcall being null 2022-09-07 11:42:37 +01:00
David Baker
0360889fd6 Log ID instead of object
as otherwise it recurses and logs the entire client + store
2022-09-06 15:11:45 +01:00
David Baker
7304411c5d Merge pull request #567 from vector-im/dbkr/waitUntilRoomReadyForGroupCalls
Use new method to wait until a room is ready for group calls
2022-09-06 14:00:06 +01:00
David Baker
22dd095ea9 Update js-sdk to latest on group-call branch 2022-09-06 13:55:32 +01:00
David Baker
30a270193f Use js-sdk branch 2022-09-06 12:14:24 +01:00
David Baker
ee1dd2293e Use new method to wait until a room is ready fopr group calls
We were waiting for the group call event handler to process the room,
but only if we couldn't get the room from the client - if getRoom returned
a room, we just wouldn't wait. This just uses promises rather than
an event to wait for the room to be ready.

Requires https://github.com/matrix-org/matrix-js-sdk/pull/2641
2022-09-06 11:57:07 +01:00
David Baker
34d5e88def Merge pull request #564 from vector-im/dbkr/fix_multiple_group_calls
Fix bug where additional group calls could be created
2022-09-01 16:18:43 +01:00
David Baker
30c9dfce02 Remove unused import 2022-09-01 13:36:02 +01:00
David Baker
48ad4d040d Actually wait for the right event
& update js-sdk dependency
2022-09-01 13:32:11 +01:00
David Baker
1b4f097b1c Fix bug where additional group calls could be created
This (hopefully) fixes the remaining bug where extra group calls
could be created when entering a room.

We waited for the Room event to arrive, but didn't wait for the
group call event handler to actually process the event, so it would
have depended what order the event handlers were run in.

If this doesn't fix it, it at least adds logging so we'll have more
to go on next time.

Fixes https://github.com/vector-im/element-call/issues/563
2022-09-01 11:41:22 +01:00
David Baker
7b6193ab62 Update js-sdk for ICE end-of-candidates fix 2022-08-26 10:06:18 +01:00
David Baker
10a2733fd5 Merge pull request #552 from vector-im/dbkr/fix_rageshake_groupcall_txt
Fix groupcall debug info in rageshakes
2022-08-25 15:20:47 +01:00
David Baker
e7353e184f Fix groupcall debug info in rageshakes
We were putting the whole array from setState in, so the debug info
was wrapped in an array when it shouldn't be.

Also comment the groupCallInspector setState/context dance which I
now *finally* understand.
2022-08-25 11:43:47 +01:00
David Baker
a479863f88 Merge pull request #551 from vector-im/dbkr/fix_rageshake_form
Fix 'submit debug logs' checkbox in the rageshake form
2022-08-24 09:53:47 +01:00
David Baker
c550545116 Fix 'submit debug logs' checkbox in the rageshake form
Fixes https://github.com/vector-im/element-call/issues/550
2022-08-23 20:29:41 +01:00
Šimon Brandner
1d7da9c455 Merge pull request #541 from vector-im/SimonBrandner/fix/full-screen 2022-08-19 17:36:04 +02:00
Šimon Brandner
5be0fdea0b Update js-sdk
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-19 17:34:21 +02:00
Šimon Brandner
a2a6eaf695 Update-jssdk
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-19 17:26:02 +02:00
Šimon Brandner
d08573b6b8 Update js-sdk
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-19 17:18:06 +02:00
Šimon Brandner
af7daee3e7 Handle screen-sharing feed ending
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-19 17:16:57 +02:00
Robin
3406b46db5 Merge pull request #535 from robintown/fix-call-type-dropdown
Fix the call type selector
2022-08-19 09:09:32 -04:00
Robin Townsend
2b45cf1f67 Convert UnauthenticatedView to TypeScript 2022-08-18 18:48:24 -04:00
Robin Townsend
ba4258aa89 Fix the call type selector 2022-08-18 18:48:17 -04:00
Šimon Brandner
fc0a3f38ac Merge pull request #512 from vector-im/SimonBrandner/fix/audio 2022-08-16 10:07:55 +02:00
Šimon Brandner
ad96da59c3 Merge pull request #529 from vector-im/SimonBrandner/fix/audio2 2022-08-15 15:42:44 +02:00
Šimon Brandner
c7ce689739 Fix spatial audio
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-15 15:11:51 +02:00
Šimon Brandner
fa0a8d30e7 Fix audio
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-15 15:11:20 +02:00
Šimon Brandner
b57ef84e66 Filter out local streams
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-15 15:05:30 +02:00
Šimon Brandner
e5432ef260 Merge pull request #520 from vector-im/SimonBrandner/fix/feedback 2022-08-14 13:29:50 +02:00
Šimon Brandner
719156aadf Fix the Feedback modal not being closable
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-14 10:42:57 +02:00
Šimon Brandner
0720005c93 Delint
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-14 09:01:32 +02:00
Šimon Brandner
897f127fbd Check for audio track count
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-14 09:01:16 +02:00
Šimon Brandner
fd8ade1bf1 Delint
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-14 09:00:36 +02:00
Šimon Brandner
7f6b0f572b Merge remote-tracking branch 'upstream/main' into SimonBrandner/fix/audio
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-14 08:57:49 +02:00
Šimon Brandner
a4d982ea62 Merge pull request #519 from vector-im/SimonBrandner/fix/audio-less 2022-08-14 08:48:16 +02:00
Šimon Brandner
317f27e5f9 Don't re-run hook on every mute
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-13 18:44:11 +02:00
Šimon Brandner
b2427bd810 Handle audio-less
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-13 18:29:30 +02:00
Šimon Brandner
4ac5c2c677 Merge remote-tracking branch 'upstream/main' into SimonBrandner/fix/audio 2022-08-13 18:28:27 +02:00
Šimon Brandner
2234962acc Fix handling of streams with no audio tracks
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-13 18:19:31 +02:00
Robin
8f95da4b07 Merge pull request #518 from robintown/logout-lost-sessions
Log out lost sessions
2022-08-13 09:38:40 -04:00
Robin
102bde65ba Merge pull request #517 from robintown/fix-imports
Remove top level matrix-js-sdk imports
2022-08-13 09:38:11 -04:00
Robin Townsend
3d5421819f Stop the temporary client 2022-08-12 20:13:52 -04:00
Robin Townsend
5167cacee8 Log out lost sessions
To prevent sessions from piling up quite as much
2022-08-12 17:58:29 -04:00
Robin Townsend
882eed0737 Remove top level matrix-js-sdk imports 2022-08-12 16:46:53 -04:00
Šimon Brandner
e82ed2cbcb Merge remote-tracking branch 'upstream/main' into SimonBrandner/fix/audio
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-12 20:54:04 +02:00
Šimon Brandner
05466fbd7f Merge pull request #513 from vector-im/SimonBrandner/fix/slider 2022-08-12 20:50:29 +02:00
Šimon Brandner
2bfd26b2b5 Fix spelling
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-12 20:48:39 +02:00
Robin
a17b62b14c Merge pull request #516 from robintown/missing-audio
Fix a case where someone's audio could be missing if the audio track arrived late
2022-08-12 14:28:12 -04:00
Robin Townsend
88cffdb70e Fix a case where someone's audio could be missing if the audio track
arrived late
2022-08-12 14:24:19 -04:00
Timo
51ae1c819a typescript src/video-grid (#511) 2022-08-12 19:27:34 +02:00
Šimon Brandner
2608f9558c Merge pull request #514 from vector-im/SimonBrandner/fix/name-11 2022-08-12 15:03:06 +02:00
Šimon Brandner
8176d60d96 Show name in 1:1 calls
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-12 10:33:59 +02:00
Šimon Brandner
2ce99b969d Fix the look of volume slider on Firefox
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-12 10:25:58 +02:00
Šimon Brandner
8b97904144 Fix full-screen audio
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-12 09:53:44 +02:00
Šimon Brandner
0e34f9a464 Add useAudioOutputDevice()
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-12 09:36:46 +02:00
Timo
c09380644b typescript src/tabs, src/typography (#491)
* first iteration

* tabs generic - remove as from typography

* typography using React.component function

* comma mistake

* ...

* review + add back `as` option for typography.

* linter

* quick fix

* us location descriptor
2022-08-11 17:59:00 +02:00
Robin
1dfffce606 Merge pull request #416 from robintown/matroska
Matroska mode
2022-08-09 10:01:05 -04:00
Robin Townsend
7e98b19587 Update matrix-js-sdk 2022-08-09 09:53:45 -04:00
Robin Townsend
2a1689009a Extract state event capabilities into a variable 2022-08-09 09:43:12 -04:00
Robin Townsend
5ef3b055ff Merge branch 'main' into matroska 2022-08-09 09:03:02 -04:00
Timo
f554afd6b1 typescript src/input (#487) 2022-08-09 11:44:46 +02:00
Šimon Brandner
5474693711 Merge pull request #502 from vector-im/SimonBrandner/feat/fullscreen 2022-08-09 10:29:26 +02:00
Robin Townsend
f9a41be530 Merge branch 'main' into matroska 2022-08-08 14:46:24 -04:00
Šimon Brandner
c61bc46673 Use useCallback()
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-08 20:05:44 +02:00
Šimon Brandner
dd304d3569 Add missing type
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-08 20:05:15 +02:00
Šimon Brandner
2eff251e0c Add missing space
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-08 20:01:58 +02:00
Šimon Brandner
531db48c25 Show toolbar only on toolbar hover
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-08 14:22:49 +02:00
Matthew Hodgson
9c0ce6526c Merge pull request #501 from vector-im/matthew/fix-mirror-text
fix mirror text on FF by reverting weird css hack.
2022-08-08 10:23:45 +01:00
Šimon Brandner
96123ccf63 Fix presenter label
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-07 19:21:11 +02:00
Šimon Brandner
305c2cb806 Add support for screen-sharing
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-07 19:09:45 +02:00
Šimon Brandner
9af122b96e Add useFullscreen()
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-07 19:05:49 +02:00
Šimon Brandner
7ca08f2f30 Add FullscreenButton
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-07 19:04:59 +02:00
Šimon Brandner
c7dbfca53d Add icons
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-07 19:04:00 +02:00
Matthew Hodgson
8aa66dddfd fix mirror text on FF by reverting weird css hack.
this reverts some of d1368f4622
it's very unclear why the width of the preview was pushed out to 100%+1px (and the transform then flipped to 1.01)
but i see no ill effects on having reverted it.
2022-08-07 02:43:59 +01:00
Robin Townsend
eb43b96a1b Merge branch 'main' into matroska 2022-08-05 16:16:59 -04:00
Robin Townsend
a2963adbee Upgrade matrix-widget-api 2022-08-05 15:41:25 -04:00
Timo
baebfdb0bb typescript src/popover (#488) 2022-08-03 12:22:07 +02:00
Robin
c3c2f409e7 Merge pull request #495 from robintown/fix-crash
Fix a crash
2022-08-02 13:34:32 -04:00
Robin Townsend
89312ceb58 Fix types 2022-08-02 13:31:11 -04:00
Robin Townsend
9b915d289b Fix a crash
CallEvent.SendVoipEvent is sent with a raw dictionary, not an actual
MatrixEvent.
2022-08-02 13:21:44 -04:00
Šimon Brandner
3de8f9077d Merge pull request #493 from vector-im/SimonBrandner/feat/volume-design 2022-08-02 18:00:50 +02:00
Šimon Brandner
90b4e44bbe Fix screen-sharing and uncomment
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 16:09:53 +02:00
Šimon Brandner
bd25b7f3b7 Improve look of toolbar
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 16:05:36 +02:00
Šimon Brandner
85dfb3c1e5 Don't use a gradient
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 14:53:47 +02:00
Šimon Brandner
d16e42374f Use ::before to avoid conflicts
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 14:51:50 +02:00
Šimon Brandner
d56b802786 Make modal title thicker
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 14:46:15 +02:00
Šimon Brandner
93db217239 Update where we jump form icon to icon
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 14:31:16 +02:00
Šimon Brandner
33ef680c41 Update design of VideoTileSettingsModal
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 14:30:33 +02:00
Šimon Brandner
a150619d08 Make the button icon change
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 14:30:12 +02:00
Šimon Brandner
7d5fb5f041 Add VolumeIcon
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 14:29:32 +02:00
Šimon Brandner
e824b3cfe2 Update icons
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 14:28:52 +02:00
Šimon Brandner
cd885e3b3a Add hover gradient
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 13:51:05 +02:00
Šimon Brandner
005622800d Fix tooltip (again)
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 13:50:47 +02:00
Šimon Brandner
aef4fd39b9 Add env var for background-85
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 13:33:09 +02:00
Šimon Brandner
2e57eaad1d Fix var name
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 12:45:21 +02:00
Šimon Brandner
a5d5f75f52 Add hover effect back
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 12:43:27 +02:00
Šimon Brandner
130073689d Fix button tooltip
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-02 12:38:09 +02:00
Timo
2d99acabe2 typescript src/room (#437) 2022-08-02 00:46:16 +02:00
Šimon Brandner
0e5231ba43 Make buttons only visible on hover
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-01 19:29:28 +02:00
Šimon Brandner
e62d76a6f2 Use more vars
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-01 19:26:56 +02:00
Šimon Brandner
ce55ed8221 Use vars
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-01 19:24:28 +02:00
Šimon Brandner
c5e7fe7bdc Merge remote-tracking branch 'upstream/main' into SimonBrandner/feat/volume-design 2022-08-01 19:23:07 +02:00
Šimon Brandner
c723fae0e2 Merge pull request #494 from vector-im/SimonBrandner/fix/ts 2022-08-01 19:17:29 +02:00
Šimon Brandner
68172d12b0 Make tslint pass
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-01 19:04:43 +02:00
Šimon Brandner
44ce76bcb1 Get volume button inline with design
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-01 18:58:59 +02:00
Timo
44b9bd0046 Merge pull request #485 from toger5/ts_Form+Home 2022-08-01 18:20:59 +02:00
Šimon Brandner
2e38558a9d Merge pull request #489 from vector-im/SimonBrandner/task/ts-src 2022-08-01 18:10:41 +02:00
Šimon Brandner
a679bfcd95 Add missing copyrights
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-31 22:11:46 +02:00
Šimon Brandner
44315f327b Add missing extends
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-31 22:09:33 +02:00
Šimon Brandner
4f7724dbaf Fix prop order
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-31 22:07:08 +02:00
Šimon Brandner
dc3cc33893 Fix exiting dialog
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 10:33:44 +02:00
Šimon Brandner
2537088099 Accompanying changes
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 10:06:28 +02:00
Šimon Brandner
02aaa06cb3 Modal
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 10:06:09 +02:00
Šimon Brandner
abf5121b74 UserMenu
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 10:02:20 +02:00
Šimon Brandner
cc7584a223 UserMenuContainer
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 10:02:07 +02:00
Šimon Brandner
43b6351237 SequenceDiagramViewerPage
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 10:00:51 +02:00
Šimon Brandner
3b74920ece useLocationNavigation
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 10:00:34 +02:00
Šimon Brandner
005762a1a2 usePageFocusStyle
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 10:00:10 +02:00
Šimon Brandner
5841c4f38d usePageTitle
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 09:59:51 +02:00
Šimon Brandner
6acc84fd9e Tooltip
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 09:59:20 +02:00
Šimon Brandner
afc072da2c Menu
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 09:51:32 +02:00
Šimon Brandner
8634c16a47 ListBox
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 09:50:58 +02:00
Šimon Brandner
0aa3359f96 IndexDBWorker
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 09:50:36 +02:00
Šimon Brandner
077e5b2998 Header
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 09:50:16 +02:00
Šimon Brandner
4b01000d4c FullScreenView
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 09:48:29 +02:00
Šimon Brandner
949d28a88f Facepile
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 09:46:47 +02:00
Šimon Brandner
57cde41983 App
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-30 09:41:45 +02:00
Timo K
cb5b3e9468 review changes 2022-07-29 15:07:35 +02:00
Robin Townsend
69f19d24a3 Merge branch 'main' into matroska 2022-07-28 16:27:04 -04:00
Robin Townsend
549c54e311 Request fewer permissions 2022-07-28 16:26:14 -04:00
Šimon Brandner
ec7f9effd8 Merge pull request #473 from vector-im/SimonBrandner/feat/audio-share 2022-07-28 18:22:39 +02:00
Šimon Brandner
1f4cc7bb19 Update js-sdk
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-28 18:13:35 +02:00
Šimon Brandner
1d78e2bc20 Merge remote-tracking branch 'upstream/main' into SimonBrandner/feat/audio-share 2022-07-28 18:12:33 +02:00
Šimon Brandner
942800a2a6 Merge pull request #468 from vector-im/SimonBrandner/feat/local-volume 2022-07-28 18:09:32 +02:00
David Baker
414996c3f5 Merge pull request #481 from vector-im/dbkr/softcrash_screen
Make the error boundary work
2022-07-28 09:39:20 +01:00
Šimon Brandner
0c3dab8dd2 Add GainNode
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-28 09:16:49 +02:00
Šimon Brandner
c48f9a69cc Use ch
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-28 09:02:27 +02:00
Šimon Brandner
3277887089 Remove unnecessary prefixed rules
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-28 08:46:19 +02:00
Šimon Brandner
304339f589 Improve TS around OptionsButton
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-28 08:15:32 +02:00
Šimon Brandner
45cfdef45d Use ...rest
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-28 08:11:19 +02:00
Šimon Brandner
f440c3f2c8 Add TS todo
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-28 08:11:07 +02:00
Šimon Brandner
db74a486c5 Fix copyright
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-28 08:07:37 +02:00
Timo K
4f36d149d7 make error optional in ClientState 2022-07-28 00:22:48 +02:00
Timo K
3727bfb67f more types 2022-07-28 00:17:09 +02:00
Timo K
f26ab2f941 Merge branch 'main' into ts_Form+Home 2022-07-27 23:47:56 +02:00
Robin Townsend
cf56b24dda Add a URL param for room ID
And consolidate our URL params logic
2022-07-27 16:31:48 -04:00
Robin Townsend
2a8cb3c4e2 Merge branch 'main' into matroska 2022-07-26 14:58:40 -04:00
Šimon Brandner
5478e648a7 Update js-sdk
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-25 16:02:29 +02:00
Šimon Brandner
b47d633727 Merge remote-tracking branch 'upstream/main' into SimonBrandner/feat/local-volume 2022-07-25 15:50:30 +02:00
David Baker
810cdeeab4 Merge pull request #482 from vector-im/dbkr/fix_screenshare_crash
Fix crash on screen share
2022-07-21 11:48:55 +01:00
David Baker
075049abc4 Merge pull request #479 from vector-im/dbkr/wait_for_room
Fix 'cannot find room' error
2022-07-21 11:48:23 +01:00
David Baker
56afbe6eb1 Fix crash on screen share
Don't try to wire up audio nodes if the stream has no audio track,
'cos it'll crash.

Fixes https://github.com/vector-im/element-call/issues/421
2022-07-20 20:49:07 +01:00
David Baker
cf309102a2 Make the error boundary work
We had an error boundary at the top level of the app, but it didn't
work because it used ErrorPage which tried to use a bunch of things
like useLocation() and an error prop. Also it wasn't passed in correctly
anyway.

This wires it up correctly to a separate view with a button to send
debug logs, and also moves it down a few layers so it has access to
enough things to be able to send rageshakes.

Related: https://github.com/vector-im/element-call/issues/421
2022-07-20 20:43:11 +01:00
David Baker
32b37ed8f0 Fix 'cannot find room' error
We weren't waiting for rooms to arrive down the sync stream after
joining them but before trying to use them.

More regression details in linked issue.

Fixes https://github.com/vector-im/element-call/issues/477
2022-07-20 16:01:29 +01:00
Šimon Brandner
ce8ac0a81c Fix formatting
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-16 17:54:49 +02:00
Šimon Brandner
4d8e0d7b85 Merge remote-tracking branch 'upstream/main' into SimonBrandner/feat/audio-share 2022-07-16 17:54:24 +02:00
Šimon Brandner
6d7f52d2d6 Merge pull request #472 from vector-im/SimonBrandner/task/vs-code 2022-07-16 17:49:58 +02:00
Šimon Brandner
e63b3d1b3e Add support for audio sharing
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-16 09:08:38 +02:00
Šimon Brandner
d77d953f84 Be more explicit in .vscode/settings.json
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-16 08:28:08 +02:00
Robin Townsend
689835cc17 Merge branch 'main' into matroska 2022-07-15 16:56:52 -04:00
Robin
6456a6b0c0 Merge pull request #471 from robintown/await-room-creation
Make room setup more reliable
2022-07-15 16:17:55 -04:00
Robin Townsend
996c5f86c1 Refactor to use fewer else's 2022-07-15 16:08:26 -04:00
Robin Townsend
3fc8fe505b Merge branch 'main' into matroska 2022-07-15 14:38:12 -04:00
Robin Townsend
daeecc9b68 Add a missing type 2022-07-15 13:07:19 -04:00
Robin Townsend
982398b32f Remove unnecessary complexity from createRoom
With fae4c504c9, the changes from
b4a56f6dd7 are no longer necessary.
2022-07-15 13:05:06 -04:00
Robin Townsend
fae4c504c9 Consolidate all group call creation into useLoadGroupCall
This enables us to automatically create a group call in rooms that
exist, but contain no calls.
2022-07-15 12:59:54 -04:00
Robin Townsend
b4a56f6dd7 Wait for the created room to come down sync before placing a group call 2022-07-15 11:31:52 -04:00
Robin Townsend
fc26bef80a Make Vite work with matrix-widget-api 2022-07-15 11:24:38 -04:00
Šimon Brandner
034552a063 Merge pull request #469 from vector-im/SimonBrandner/task/env 2022-07-15 15:59:40 +02:00
Šimon Brandner
bb505273f4 Add .env instruction
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:32:07 +02:00
Šimon Brandner
f876df6acc Remove .env
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:30:52 +02:00
Šimon Brandner
d097223d41 Add .env to .gitignore
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:29:25 +02:00
Šimon Brandner
d01f7be58a Add .env.example
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:28:16 +02:00
Šimon Brandner
d5375ca9ed Delint
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:22:13 +02:00
Šimon Brandner
eda8404144 Add UI for local volume control
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:18:56 +02:00
Timo K
e17a7cedb6 form_home 2022-07-14 19:20:52 +02:00
Šimon Brandner
4ad4cff23f Add handling for local volume control
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-14 16:18:10 +02:00
David Baker
cc7a44dc17 Merge pull request #466 from vector-im/dbkr/check_indexeddb
Don't restore session unless crypto data is found
2022-07-14 13:43:21 +01:00
David Baker
873e68e1e1 Add notes from thinking through the need for storing what crypto db we use 2022-07-14 13:24:22 +01:00
David Baker
4f44a68198 Merge pull request #465 from vector-im/dbkr/display_name_url_param
Auto-register if displayName URL param is given
2022-07-14 13:20:36 +01:00
David Baker
1eab957d85 Fix typescript syntax 2022-07-14 13:11:47 +01:00
David Baker
4c145af7a3 Don't restore session unless crypto data is found
Add a check to ensure that we find crypto data in the crypto store
when we're restoring a session and otherwise abort the session restore.

This will prevent us from restoring a session and generating new keys
when there was a previous session with different keys.

***This will force a logout for all users***

See the linked issue (and the comment in code) for more detail.

Fixes https://github.com/vector-im/element-call/issues/464
2022-07-14 13:07:30 +01:00
Robin Townsend
7fab4ca1ba Merge branch 'main' into matroska 2022-07-13 15:54:06 -04:00
David Baker
c1e45c4a30 Missed a file 2022-07-13 16:02:17 +01:00
David Baker
5784a005dc Auto-register if displayName URL param is given
Fixes https://github.com/vector-im/element-call/issues/442
2022-07-13 14:34:15 +01:00
David Baker
a3e4d6998f Merge pull request #463 from vector-im/dbkr/create_room_ptt
Add ptt URL param to control what mode rooms are created in
2022-07-11 14:34:42 +01:00
David Baker
32907764b3 Add ptt URL param to control what mode rooms are created in 2022-07-11 13:23:03 +01:00
David Baker
cb34b1634d Merge pull request #462 from vector-im/dbkr/bypass_lobby_in_embed_mode
Bypass lobby in embedded mode
2022-07-11 12:42:15 +01:00
David Baker
5199fd2566 Prettier 2022-07-08 21:17:27 +01:00
David Baker
b31c6c6780 Bypass lobby in embedded mode 2022-07-08 20:55:18 +01:00
David Baker
aeec2c076e Merge pull request #458 from vector-im/dbkr/fix_network_waiting_after_timeout
Fix 'waiting for network' after reaching time limit
2022-07-08 19:03:55 +01:00
David Baker
8bbce188ef Merge pull request #457 from vector-im/dbkr/yarn_upgrade_20220708
yarn upgrade
2022-07-08 19:03:34 +01:00
David Baker
dbdc010764 Updgrade postcss-preset-env
as it was complaining that it didn't work with our version of postcss
2022-07-08 17:19:13 +01:00
David Baker
a81c48cc22 Fix 'waiting for network' after reaching time limit
If you spoke for the maximum amount of time and got cut off, the
next time you tried to speak you'd just get the 'waiting for network'
state. Key repeats would cause more delayed state timeouts to queue
up.
2022-07-08 15:52:32 +01:00
David Baker
6eb77b7c2f Fix types 2022-07-08 14:56:00 +01:00
David Baker
92a50fe51d yarn upgrade
Fixes https://github.com/vector-im/element-call/issues/456 as
the various react libraries had got out of sync (also we were well
overdue a dependency update).
2022-07-08 14:26:08 +01:00
David Baker
572caf6826 Merge pull request #453 from vector-im/dbkr/fix_facepile_display
Fix facepile display issues
2022-07-08 09:59:11 +01:00
David Baker
b0c8ceb302 Merge pull request #455 from vector-im/dbkr/fix_talkover
Fix talking collision not colliding properly
2022-07-08 09:51:26 +01:00
David Baker
c9ae6532a0 Bump js-sdk 2022-07-08 09:48:40 +01:00
Timo K
619e3c4852 form 2022-07-07 23:40:29 +02:00
Timo
e5cfcb601b Merge pull request #397 from toger5/ts_button 2022-07-07 22:03:28 +02:00
David Baker
2b92bf3694 Fix talking collision not colliding properly
The code was only entering the blocked state if the user was speaking,
which often won't be the case when another person starts speaking because
we'll have pressed the button but not got the ack back from the server
yet. Add the transmitblocked flag instead so we don't enter that state
again if we've already decided we've been blocked.

We were also starting with blocked = false and so resetting it when it
shouldn't have been reset.

Also requires https://github.com/matrix-org/matrix-js-sdk/pull/2502
2022-07-07 19:42:15 +01:00
David Baker
cd42d09ea9 Fix facepile display issues
Fixes https://github.com/vector-im/element-call/issues/434 and a
separate bug where the facepile would just disappear off to the left
(because we kept increasing the size even though we capped the number
of circles at 8 plus the overflow one).
2022-07-07 14:30:28 +01:00
David Baker
c56b1c8a86 Merge pull request #452 from Johennes/feature/no-empty-labels-pt2
Prevent empty device labels in audio preview
2022-07-07 14:30:12 +01:00
Johannes Marbach
e8d99e15f7 Prevent empty device labels in audio preview
Fixes: #324
Signed-off-by: Johannes Marbach <johannesm@element.io>
2022-07-07 13:32:23 +02:00
David Baker
4dcec504ca Merge pull request #449 from Johennes/feature/no-empty-labels
Prevent empty device labels
2022-07-07 12:16:18 +01:00
Johannes Marbach
1308e52e42 Enumerate devices 2022-07-07 12:10:08 +02:00
Johannes Marbach
f6d356c5ce Prettify the thing 2022-07-07 10:31:44 +02:00
Johannes Marbach
eb2de869b8 Prevent empty device labels
Fixes: #324
Signed-off-by: Johannes Marbach <johannesm@element.io>
2022-07-07 10:21:38 +02:00
David Baker
c6030d33ca Merge pull request #448 from vector-im/dbkr/country_roads
Remove the 'Take Me Home' link in embed mode
2022-07-06 22:01:54 +01:00
David Baker
655058a7e6 Remove the 'Take Me Home' link in embed mode 2022-07-06 18:27:30 +01:00
David Baker
16d4ffbe3a Merge pull request #446 from vector-im/dbkr/fix_talking_view
Fix view when another person is talking
2022-07-06 18:08:32 +01:00
David Baker
775125c8a7 Fix view when another person is talking
Fixes https://github.com/vector-im/element-call/issues/445
2022-07-06 13:44:17 +01:00
Robin
631e63a0b5 Merge pull request #444 from robintown/wt-small
Adapt walkie-talkie layout to hide controls at small sizes
2022-07-05 16:07:55 -04:00
Robin Townsend
4cb2306de0 Make button be constrained primarily by width rather than height 2022-07-05 15:49:48 -04:00
Robin Townsend
f15ee439a9 Fix page layout 2022-07-05 15:41:57 -04:00
Robin Townsend
b9a2473d19 Adapt walkie-talkie layout to hide controls at small sizes 2022-07-05 13:47:53 -04:00
Timo K
5b58223f9d fix refs 2022-07-05 17:44:09 +02:00
Timo K
f34fd0bd00 update @react-aria/button 2022-07-05 17:42:03 +02:00
Timo K
713136672a make className an optional param 2022-07-02 21:45:31 +02:00
Timo K
f1bd47be8c Merge branch 'main' into ts_button 2022-07-02 21:42:15 +02:00
Timo K
2e82960ae6 ButtonVariant ButtonSize 2022-07-02 21:20:53 +02:00
Robin Townsend
d5e638c8f7 WIP 2022-06-27 17:41:07 -04:00
Timo K
18ca92cec4 js->ts 2022-06-11 23:21:20 +02:00
Timo K
dc11814695 rename files js->ts 2022-06-11 15:23:33 +02:00
152 changed files with 10593 additions and 6359 deletions

View File

@@ -27,3 +27,4 @@
# VITE_THEME_QUINARY_CONTENT=#394049
# VITE_THEME_SYSTEM=#21262c
# VITE_THEME_BACKGROUND=#15191e
# VITE_THEME_BACKGROUND_85=#15191ed9

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules
.DS_Store
.env
dist
dist-ssr
*.local

18
.vscode/settings.json vendored
View File

@@ -2,5 +2,21 @@
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.insertSpaces": true,
"editor.tabSize": 2
"editor.tabSize": 2,
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[javascriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[javascript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
}
}

View File

@@ -27,6 +27,7 @@ git clone https://github.com/vector-im/element-call.git
cd element-call
yarn
yarn link matrix-js-sdk
cp .env.example .env
yarn dev
```

20
i18next-parser.config.js Normal file
View File

@@ -0,0 +1,20 @@
export default {
keySeparator: false,
namespaceSeparator: false,
contextSeparator: "|",
pluralSeparator: "|",
createOldCatalogs: false,
defaultNamespace: "app",
lexers: {
ts: [{
lexer: "JavascriptLexer",
functions: ["t", "translatedError"],
functionsNamespace: ["useTranslation", "withTranslation"],
}],
},
locales: ["en-GB"],
output: "public/locales/$LOCALE/$NAMESPACE.json",
input: ["src/**/*.{ts,tsx}"],
sort: true,
useKeysAsDefaultValue: true,
};

View File

@@ -1,5 +1,6 @@
{
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -10,7 +11,8 @@
"prettier:format": "prettier -w src",
"lint": "yarn lint:types && yarn lint:js",
"lint:js": "eslint --max-warnings 0 src",
"lint:types": "tsc"
"lint:types": "tsc",
"i18n": "node_modules/i18next-parser/bin/cli.js"
},
"dependencies": {
"@juggle/resize-observer": "^3.3.1",
@@ -38,14 +40,19 @@
"classnames": "^2.3.1",
"color-hash": "^2.0.1",
"events": "^3.3.0",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#9a15094374f52053ca9f833269d2b1c6c7f964d2",
"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#5a0787349d4951012eabe72f3363c17bdcda0d56",
"matrix-widget-api": "^1.0.0",
"mermaid": "^8.13.8",
"normalize.css": "^8.0.1",
"pako": "^2.0.4",
"postcss-preset-env": "^6.7.0",
"postcss-preset-env": "^7",
"re-resizable": "^6.9.0",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-i18next": "^11.18.6",
"react-json-view": "^1.21.3",
"react-router": "6",
"react-router-dom": "^5.2.0",
@@ -70,10 +77,12 @@
"eslint-plugin-matrix-org": "^0.4.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0",
"i18next-parser": "^6.6.0",
"prettier": "^2.6.2",
"sass": "^1.42.1",
"storybook-builder-vite": "^0.1.12",
"typescript": "^4.6.4",
"typescript-strict-plugin": "^2.0.1",
"vite": "^2.4.2",
"vite-plugin-html-template": "^1.1.0",
"vite-plugin-svgr": "^0.4.0"

132
public/locales/bg/app.json Normal file
View File

@@ -0,0 +1,132 @@
{
"<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>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Създайте акаунт</0> или <2>Влезте като гост</2>",
"<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</1>": "<0>Възникна грешка.</0><1>Изпращнето на debug логове ще ни помогне да открием проблема.</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>": "<0>Защо не настройте парола за да запазите акаунта си?</0><1>Ще можете да запазите името и аватара си за бъдещи разговори</1>",
"Accept camera/microphone permissions to join the call.": "Приемете разрешенията за камера/микрофон за да се присъедините в разговора.",
"Accept microphone permissions to join the call.": "Приемете разрешението за микрофона за да се присъедините в разговора.",
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Друг потребител в този разговор има проблем. За да диагностицираме този проблем по-добре ни се иска да съберем debug логове.",
"Audio": "Звук",
"Avatar": "Аватар",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "Натискайки \"Напред\" се съгласявате с нашите <2>Правила и условия</2>",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Натискайки \"Влез в разговора сега\", се съгласявате с нашите <2>Правила и условия</2>",
"Call link copied": "Връзка към разговора бе копирана",
"Call type menu": "Меню \"тип на разговора\"",
"Camera": "Камера",
"Camera {{n}}": "Камера {{n}}",
"Camera/microphone permissions needed to join the call.": "Необходими са разрешения за камера/микрофон за да се присъедините в разговора.",
"Change layout": "Промени изгледа",
"Close": "Затвори",
"Confirm password": "Потвърди паролата",
"Connection lost": "Връзката се изгуби",
"Copied!": "Копирано!",
"Copy and share this call link": "Копирай и сподели връзка към разговора",
"Copy call link and join later": "Копирай връзка към разговора и се присъедини по-късно",
"Create account": "Създай акаунт",
"Debug log": "Debug логове",
"Debug log request": "Заявка за debug логове",
"Description (optional)": "Описание (незадължително)",
"Details": "Детайли",
"Developer": "Разработчик",
"Display name": "Име/псевдоним",
"Download debug logs": "Изтеглете debug логове",
"Entering room…": "Влизане в стаята…",
"Exit full screen": "Излез от цял екран",
"Fetching group call timed out.": "Изтече времето за взимане на груповия разговор.",
"Freedom": "Свобода",
"Full screen": "Цял екран",
"Go": "Напред",
"Grid layout menu": "Меню \"решетков изглед\"",
"Having trouble? Help us fix it.": "Имате проблем? Помогнете да го поправим.",
"Home": "Начало",
"Include debug logs": "Включи debug логове",
"Incompatible versions": "Несъвместими версии",
"Incompatible versions!": "Несъвместими версии!",
"Inspector": "Инспектор",
"Invite": "Покани",
"Invite people": "Покани хора",
"Join call": "Влез в разговора",
"Join call now": "Влез в разговора сега",
"Join existing call?": "Присъединяване към съществуващ разговор?",
"Leave": "Напусни",
"Loading room…": "Напускане на стаята…",
"Loading…": "Зареждане…",
"Local volume": "Локална сила на звука",
"Logging in…": "Влизане…",
"Login": "Влез",
"Login to your account": "Влезте в акаунта си",
"Microphone": "Микрофон",
"Microphone permissions needed to join the call.": "Необходими са разрешения за микрофона за да можете да се присъедините в разговора.",
"Microphone {{n}}": "Микрофон {{n}}",
"More": "Още",
"More menu": "Мено \"още\"",
"Mute microphone": "Заглуши микрофона",
"No": "Не",
"Not now, return to home screen": "Не сега, върни се на началния екран",
"Not registered yet? <2>Create an account</2>": "Все още не сте регистрирани? <2>Създайте акаунт</2>",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Други потребители се опитват да се присъединят в разговора от несъвместими версии. Следните потребители трябва да проверят дали са презаредили браузърите си<1>{userLis}</1>",
"Password": "Парола",
"Passwords must match": "Паролите не съвпадат",
"Press and hold spacebar to talk": "Натиснете и задръжте Space за да говорите",
"Press and hold spacebar to talk over {{name}}": "Натиснете и задръжте Space за да говорите заедно с {{name}}",
"Press and hold to talk": "Натиснете и задръжте за да говорите",
"Press and hold to talk over {{name}}": "Натиснете и задръжте за да говорите заедно с {{name}}",
"Profile": "Профил",
"Recaptcha dismissed": "Recaptcha отхвърлена",
"Recaptcha not loaded": "Recaptcha не е заредена",
"Register": "Регистрация",
"Registering…": "Регистриране…",
"Release spacebar key to stop": "Отпуснете Space за да спрете",
"Release to stop": "Отпуснете за да спрете",
"Remove": "Премахни",
"Return to home screen": "Връщане на началния екран",
"Save": "Запази",
"Saving…": "Запазване…",
"Select an option": "Изберете опция",
"Send debug logs": "Изпратете debug логове",
"Sending…": "Изпращане…",
"Settings": "Настройки",
"Share screen": "Сподели екрана",
"Show call inspector": "Покажи инспектора на разговора",
"Sign in": "Влез",
"Sign out": "Излез",
"Spatial audio": "Пространствен звук",
"Speaker": "Говорител",
"Speaker {{n}}": "Говорител {{n}}",
"Spotlight": "Прожектор",
"Stop sharing screen": "Спри споделянето на екрана",
"Submit feedback": "Изпрати обратна връзка",
"Submitting feedback…": "Изпращане на обратна връзка…",
"Take me Home": "Отиди в Начало",
"Talk over speaker": "Говорете заедно с говорителя",
"Talking…": "Говорене…",
"Thanks! We'll get right on it.": "Благодарим! Веднага ще се заемем.",
"This call already exists, would you like to join?": "Този разговор вече съществува, искате ли да се присъедините?",
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "Този сайт се предпазва от ReCAPTCHA и важат <2>Политиката за поверителност</2> и <6>Условията за ползване на услугата</6> на Google.<9></9>Натискайки \"Регистрация\", се съгласявате с нашите <12>Правила и условия</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.)": "Това прави звука на говорителя да изглежда, че излиза от мястото където са позиционирани на екрана. (Експериментална функция: може да повлияе на стабилността на звука.)",
"Turn off camera": "Изключи камерата",
"Turn on camera": "Включи камерата",
"Unmute microphone": "Включи микрофона",
"User ID": "Потребителски идентификатор",
"User menu": "Потребителско меню",
"Username": "Потребителско име",
"Version: {{version}}": "Версия: {{version}}",
"Video": "Видео",
"Video call": "Видео разговор",
"Video call name": "Име на видео разговора",
"Waiting for network": "Изчакване на мрежата",
"Waiting for other participants…": "Изчакване на други участници…",
"Walkie-talkie call": "Уоки-токи разговор",
"Walkie-talkie call name": "Име на уоки-токи разговора",
"WebRTC is not supported or is being blocked in this browser.": "WebRTC не се поддържа или се блокира от браузъра.",
"Yes, join call": "Да, присъедини се",
"You can't talk at the same time": "Не можете да говорите едновременно",
"Your recent calls": "Скорошните ви разговори",
"{{count}} people connected|one": "{{count}} човек се свърза",
"{{count}} people connected|other": "{{count}} човека се звързаха",
"{{displayName}}, your call is now ended": "{{displayName}}, разговорът ви приключи",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} is presenting": "{{name}} презентира",
"{{name}} is talking…": "{{name}} говори…",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - уоки-токи разговор"
}

133
public/locales/de/app.json Normal file
View File

@@ -0,0 +1,133 @@
{
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Du hast bereits ein Konto?</0><1><0>Anmelden</0> Oder <2>Als Gast betreten</2></1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Konto erstellen</0> Oder <2>Als Gast betreten</2>",
"<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</1>": "<0>Hoppla, da ist etwas schief gelaufen.</0><1>Die Übermittlung von Debug-Protokollen wird uns helfen, das Problem zu finden.</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>": "<0>Warum vergibst Du nicht abschließend ein Passwort, um Dein Konto zu erhalten?</0><1>Du kannst Deinen Namen behalten und einen Avatar für zukünftige Anrufe festlegen.</1>",
"Accept camera/microphone permissions to join the call.": "Erlaube Zugriff auf Kamera/Mikrofon um dem Anruf beizutreten.",
"Accept microphone permissions to join the call.": "Erlaube Zugriff auf das Mikrofon um dem Anruf beizutreten.",
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Ein anderer Benutzer dieses Anrufs hat ein Problem. Um dieses besser diagnostizieren zu können, würden wir gerne ein Debug-Protokoll erstellen.",
"Audio": "Audio",
"Avatar": "Avatar",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "Mit dem Klick auf \"Los geht's\", akzeptierst Du unsere <2>Geschäftsbedingungen</2>",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Wenn Du auf \"Jetzt anrufen\" klickst, erklärst Du dich mit unserer <2>Geschäftsbedingungen</2> einverstanden",
"Call link copied": "Anruflink kopiert",
"Call type menu": "Anruftyp Menü",
"Camera": "Kamera",
"Camera {{n}}": "Kamera {{n}}",
"Camera/microphone permissions needed to join the call.": "Kamera-/Mikrofonberechtigung für die Teilnahme am Anruf erforderlich.",
"Change layout": "Layout ändern",
"Close": "Schließen",
"Confirm password": "Passwort bestätigen",
"Connection lost": "Verbindung verloren",
"Copied!": "Kopiert!",
"Copy and share this call link": "Kopiere und teile diesen Anruflink",
"Copy call link and join later": "Kopiere den Anruflink und nehme später teil",
"Create account": "Konto erstellen",
"Debug log": "Debug-Protokoll",
"Debug log request": "Debug-Log Anfrage",
"Description (optional)": "Beschreibung (wahlweise)",
"Details": "Details",
"Developer": "Entwickler",
"Display name": "Anzeigename",
"Download debug logs": "Debug-Logs herunterladen",
"Entering room…": "Betrete Raum …",
"Exit full screen": "Vollbildmodus verlassen",
"Freedom": "Freiraum",
"Full screen": "Vollbild",
"Go": "Los geht's",
"Grid layout menu": "Grid-Layout-Menü",
"Having trouble? Help us fix it.": "Hast Du Probleme? Hilf uns, es zu beheben.",
"Home": "Startseite",
"Include debug logs": "Debug-Logs hinzufügen",
"Incompatible versions": "Inkompatible Versionen",
"Incompatible versions!": "Inkompatible Versionen!",
"Inspector": "Inspektor",
"Invite": "Einladen",
"Invite people": "Personen einladen",
"Join call": "Anruf beitreten",
"Join call now": "Trete dem Anruf bei",
"Join existing call?": "An bestehendem Anruf teilnehmen?",
"Leave": "Verlassen",
"Loading room…": "Lade Raum …",
"Loading…": "Lade …",
"Local volume": "Lokale Lautstärke",
"Logging in…": "Anmelden …",
"Login": "Anmelden",
"Login to your account": "Anmeldung bei Deinem Konto",
"Microphone": "Mikrofon",
"Microphone permissions needed to join the call.": "Mikrofon Berechtigung ist erforderlich, um dem Anruf beizutreten.",
"Microphone {{n}}": "Mikrofon {{n}}",
"More": "Mehr",
"More menu": "Weiteres Menü",
"Mute microphone": "Mikrofon stummschalten",
"No": "Nein",
"Not now, return to home screen": "Nicht jetzt, zurück zum Startbildschirm",
"Not registered yet? <2>Create an account</2>": "Noch nicht registriert? <2>Konto erstellen</2>",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Andere Benutzer versuchen, diesem Aufruf von einer inkompatiblen Softwareversion aus beizutreten. Diese Benutzer sollten ihre Web-Browser Seite neu laden:<1>{userLis}</1>",
"Password": "Passwort",
"Passwords must match": "Passwörter müssen übereinstimmen",
"Press and hold spacebar to talk": "Zum Sprechen die Leertaste gedrückt halten",
"Press and hold spacebar to talk over {{name}}": "Zum Verdrängen von {{name}} und Sprechen die Leertaste gedrückt halten",
"Press and hold to talk": "Zum Sprechen gedrückt halten",
"Press and hold to talk over {{name}}": "Zum Verdrängen von {{name}} und Sprechen gedrückt halten",
"Profile": "Profil",
"Recaptcha dismissed": "Recaptcha abgelehnt",
"Recaptcha not loaded": "Recaptcha nicht geladen",
"Register": "Registrieren",
"Registering…": "Registrierung …",
"Release spacebar key to stop": "Leertaste loslassen, um zu stoppen",
"Release to stop": "Loslassen zum Stoppen",
"Remove": "Entfernen",
"Return to home screen": "Zurück zum Startbildschirm",
"Save": "Speichern",
"Saving…": "Speichere …",
"Select an option": "Wähle eine Option",
"Send debug logs": "Debug-Logs senden",
"Sending…": "Senden …",
"Settings": "Einstellungen",
"Share screen": "Bildschirm teilen",
"Show call inspector": "Anrufinspektor anzeigen",
"Sign in": "Anmelden",
"Sign out": "Abmelden",
"Spatial audio": "Räumliche Audiowiedergabe",
"Speaker": "Wiedergabegerät",
"Speaker {{n}}": "Wiedergabegerät {{n}}",
"Spotlight": "Rampenlicht",
"Stop sharing screen": "Beenden der Bildschirmfreigabe",
"Submit feedback": "Feedback senden",
"Submitting feedback…": "Feedback senden …",
"Take me Home": "Zurück zur Startseite",
"Talk over speaker": "Aktiven Sprecher verdrängen und sprechen",
"Talking…": "Sprechen …",
"Thanks! We'll get right on it.": "Vielen Dank! Wir werden uns sofort darum kümmern.",
"This call already exists, would you like to join?": "Dieser Aufruf existiert bereits, möchtest Du teilnehmen?",
"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>": "Diese Website ist durch ReCAPTCHA geschützt und es gelten die <2>Datenschutzerklärung</2> sowie die <6> Nutzungsbedingungen </6> von Google.<9></9>Indem Du auf \"Registrieren\" klickst, stimmst Du unseren <12>Geschäftsbedingungen</12> zu",
"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.)": "Dadurch wird die Audiowiedergabe eines Sprechers so wiedergegeben, als käme er von der Stelle, an der das zugehörige Videobild auf dem Bildschirm positioniert ist (Experimentelle Funktion: Dies kann die Stabilität der Audiowiedergabe beeinträchtigen).",
"Turn off camera": "Kamera ausschalten",
"Turn on camera": "Kamera einschalten",
"Unmute microphone": "Mikrofon aktivieren",
"User ID": "Benutzer ID",
"User menu": "Benutzermenü",
"Username": "Benutzername",
"Version: {{version}}": "Version: {{version}}",
"Video": "Video",
"Video call": "Videoanruf",
"Video call name": "Name des Videoanrufs",
"Waiting for network": "Warte auf Netzwerk",
"Waiting for other participants…": "Warte auf weitere Teilnehmer …",
"Walkie-talkie call": "Walkie-talkie Anruf",
"WebRTC is not supported or is being blocked in this browser.": "WebRTC wird in diesem Web-Browser nicht unterstützt oder ist blockiert.",
"Yes, join call": "Ja, Anruf beitreten",
"You can't talk at the same time": "Du kannst nicht gleichzeitig sprechen",
"Your recent calls": "Deine lezten Anrufe",
"{{count}} people connected|one": "{{count}} Teilnehmer verbunden",
"{{count}} people connected|other": "{{count}} Teilnehmer verbunden",
"{{displayName}}, your call is now ended": "{{displayName}}, Dein Anruf wurde beendet",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} is presenting": "{{name}} präsentiert",
"{{name}} is talking…": "{{name}} spricht …",
"{{roomName}} - Walkie-talkie call": "{{roomName}} Walkie-Talkie-Anruf",
"Fetching group call timed out.": "Zeitüberschreitung beim Abrufen des Gruppenanrufs.",
"Walkie-talkie call name": "Walkie-talkie Anruf Name",
"Sending debug logs…": "Sende Debug-Logs …"
}

View File

@@ -0,0 +1,133 @@
{
"{{count}} people connected|one": "{{count}} person connected",
"{{count}} people connected|other": "{{count}} people connected",
"{{displayName}}, your call is now ended": "{{displayName}}, your call is now ended",
"{{name}} is presenting": "{{name}} is presenting",
"{{name}} is talking…": "{{name}} is talking…",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Walkie-talkie call",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Create an account</0> Or <2>Access as a guest</2>",
"<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</1>": "<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</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>": "<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.",
"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>",
"Call link copied": "Call link copied",
"Call type menu": "Call type menu",
"Camera": "Camera",
"Camera {{n}}": "Camera {{n}}",
"Camera/microphone permissions needed to join the call.": "Camera/microphone permissions needed to join the call.",
"Change layout": "Change layout",
"Close": "Close",
"Confirm password": "Confirm password",
"Connection lost": "Connection lost",
"Copied!": "Copied!",
"Copy and share this call link": "Copy and share this call link",
"Copy call link and join later": "Copy call link and join later",
"Create account": "Create account",
"Debug log": "Debug log",
"Debug log request": "Debug log request",
"Description (optional)": "Description (optional)",
"Details": "Details",
"Developer": "Developer",
"Display name": "Display name",
"Download debug logs": "Download debug logs",
"Entering room…": "Entering room…",
"Exit full screen": "Exit full screen",
"Fetching group call timed out.": "Fetching group call timed out.",
"Freedom": "Freedom",
"Full screen": "Full screen",
"Go": "Go",
"Grid layout menu": "Grid layout menu",
"Having trouble? Help us fix it.": "Having trouble? Help us fix it.",
"Home": "Home",
"Include debug logs": "Include debug logs",
"Incompatible versions": "Incompatible versions",
"Incompatible versions!": "Incompatible versions!",
"Inspector": "Inspector",
"Invite": "Invite",
"Invite people": "Invite people",
"Join call": "Join call",
"Join call now": "Join call now",
"Join existing call?": "Join existing call?",
"Leave": "Leave",
"Loading room…": "Loading room…",
"Loading…": "Loading…",
"Local volume": "Local volume",
"Logging in…": "Logging in…",
"Login": "Login",
"Login to your account": "Login to your account",
"Microphone": "Microphone",
"Microphone {{n}}": "Microphone {{n}}",
"Microphone permissions needed to join the call.": "Microphone permissions needed to join the call.",
"More": "More",
"More menu": "More menu",
"Mute microphone": "Mute microphone",
"No": "No",
"Not now, return to home screen": "Not now, return to home screen",
"Not registered yet? <2>Create an account</2>": "Not registered yet? <2>Create an account</2>",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>",
"Password": "Password",
"Passwords must match": "Passwords must match",
"Press and hold spacebar to talk": "Press and hold spacebar to talk",
"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}}",
"Profile": "Profile",
"Recaptcha dismissed": "Recaptcha dismissed",
"Recaptcha not loaded": "Recaptcha not loaded",
"Register": "Register",
"Registering…": "Registering…",
"Release spacebar key to stop": "Release spacebar key to stop",
"Release to stop": "Release to stop",
"Remove": "Remove",
"Return to home screen": "Return to home screen",
"Save": "Save",
"Saving…": "Saving…",
"Select an option": "Select an option",
"Send debug logs": "Send debug logs",
"Sending debug logs…": "Sending debug logs…",
"Sending…": "Sending…",
"Settings": "Settings",
"Share screen": "Share screen",
"Show call inspector": "Show call inspector",
"Sign in": "Sign in",
"Sign out": "Sign out",
"Spatial audio": "Spatial audio",
"Speaker": "Speaker",
"Speaker {{n}}": "Speaker {{n}}",
"Spotlight": "Spotlight",
"Stop sharing screen": "Stop sharing screen",
"Submit feedback": "Submit feedback",
"Submitting feedback…": "Submitting feedback…",
"Take me Home": "Take me Home",
"Talk over speaker": "Talk over speaker",
"Talking…": "Talking…",
"Thanks! We'll get right on it.": "Thanks! We'll get right on it.",
"This call already exists, would you like to join?": "This call already exists, would you like to join?",
"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.)",
"Turn off camera": "Turn off camera",
"Turn on camera": "Turn on camera",
"Unmute microphone": "Unmute microphone",
"User ID": "User ID",
"User menu": "User menu",
"Username": "Username",
"Version: {{version}}": "Version: {{version}}",
"Video": "Video",
"Video call": "Video call",
"Video call name": "Video call name",
"Waiting for network": "Waiting for network",
"Waiting for other participants…": "Waiting for other participants…",
"Walkie-talkie call": "Walkie-talkie call",
"Walkie-talkie call name": "Walkie-talkie call name",
"WebRTC is not supported or is being blocked in this browser.": "WebRTC is not supported or is being blocked in this browser.",
"Yes, join call": "Yes, join call",
"You can't talk at the same time": "You can't talk at the same time",
"Your recent calls": "Your recent calls"
}

133
public/locales/fr/app.json Normal file
View File

@@ -0,0 +1,133 @@
{
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Créer un compte</0> Or <2>Accès invité</2>",
"<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</1>": "<0>Mince, une erreur est survenue.</0><1>Envoyer les journaux de débogage nous aidera à résoudre le problème.</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>": "<0>Pourquoi ne pas créer un mot de passe pour conserver votre compte ?</0><1>Vous pourrez garder votre nom et définir un avatar pour vos futurs appels</1>",
"Accept camera/microphone permissions to join the call.": "Autorisez laccès à votre caméra et microphone pour rejoindre lappel.",
"Accept microphone permissions to join the call.": "Autorisez laccès au microphone pour rejoindre lappel.",
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Un autre utilisateur dans cet appel a un problème. Pour nous permettre de résoudre le problème, nous aimerions récupérer un journal de débogage.",
"Audio": "Audio",
"Avatar": "Avatar",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "En cliquant sur « Commencer » vous acceptez nos <2>conditions dutilisation</2>",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "En cliquant sur « Rejoindre lappel » vous acceptez nos <2>conditions dutilisation</2>",
"Call link copied": "Lien de lappel copié",
"Call type menu": "Menu de type dappel",
"Camera": "Caméra",
"Camera {{n}}": "Caméra {{n}}",
"Camera/microphone permissions needed to join the call.": "Accès à la caméra et au microphone requis pour rejoindre lappel.",
"Change layout": "Changer la disposition",
"Close": "Fermer",
"Confirm password": "Confirmer le mot de passe",
"Connection lost": "Connexion interrompue",
"Copied!": "Copié !",
"Copy and share this call link": "Copier et partager le lien de cet appel",
"Copy call link and join later": "Copier le lien de cet appel et rejoindre plus tard",
"Create account": "Créer un compte",
"Debug log": "Journal de débogage",
"Debug log request": "Demande dun journal de débogage",
"Description (optional)": "Description (facultatif)",
"Details": "Informations",
"Developer": "Développeur",
"Display name": "Nom daffichage",
"Download debug logs": "Télécharger les journaux de débogage",
"Entering room…": "Entrée dans le salon…",
"Exit full screen": "Quitter le plein écran",
"Freedom": "Libre",
"Full screen": "Plein écran",
"Go": "Commencer",
"Grid layout menu": "Menu en grille",
"Having trouble? Help us fix it.": "Un problème ? Aidez nous à le résoudre.",
"Home": "Accueil",
"Include debug logs": "Inclure les journaux de débogage",
"Incompatible versions": "Versions incompatibles",
"Incompatible versions!": "Versions incompatibles !",
"Inspector": "Inspecteur",
"Invite people": "Inviter des gens",
"Join call": "Rejoindre lappel",
"Join call now": "Rejoindre lappel maintenant",
"Join existing call?": "Rejoindre un appel existant ?",
"Leave": "Partir",
"Loading room…": "Chargement du salon…",
"Loading…": "Chargement…",
"Local volume": "Volume local",
"Logging in…": "Connexion…",
"Login": "Connexion",
"Login to your account": "Connectez vous à votre compte",
"Microphone": "Microphone",
"Microphone permissions needed to join the call.": "Accès au microphone requis pour rejoindre lappel.",
"Microphone {{n}}": "Microphone {{n}}",
"More": "Plus",
"More menu": "Menu plus",
"Mute microphone": "Couper le micro",
"No": "Non",
"Not now, return to home screen": "Pas maintenant, retourner à laccueil",
"Not registered yet? <2>Create an account</2>": "Pas encore de compte ? <2>En créer un</2>",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Des utilisateurs essayent de rejoindre cet appel à partir de versions incompatibles. Ces utilisateurs doivent rafraîchir la page dans leur navigateur : <1>{userLis}</1>",
"Password": "Mot de passe",
"Passwords must match": "Les mots de passe doivent correspondre",
"Press and hold spacebar to talk": "Appuyez et maintenez la barre despace enfoncée pour parler",
"Press and hold spacebar to talk over {{name}}": "Appuyez et maintenez la barre despace enfoncée pour parler par dessus {{name}}",
"Press and hold to talk": "Appuyez et maintenez enfoncé pour parler",
"Press and hold to talk over {{name}}": "Appuyez et maintenez enfoncé pour parler par dessus {{name}}",
"Profile": "Profil",
"Recaptcha dismissed": "Recaptcha refusé",
"Recaptcha not loaded": "Recaptcha non chargé",
"Register": "Senregistrer",
"Registering…": "Enregistrement…",
"Release spacebar key to stop": "Relâcher la barre despace pour arrêter",
"Release to stop": "Relâcher pour arrêter",
"Remove": "Supprimer",
"Return to home screen": "Retour à laccueil",
"Save": "Enregistrer",
"Saving…": "Enregistrement…",
"Select an option": "Sélectionnez une option",
"Send debug logs": "Envoyer les journaux de débogage",
"Sending…": "Envoi…",
"Settings": "Paramètres",
"Share screen": "Partage décran",
"Show call inspector": "Afficher linspecteur dappel",
"Sign in": "Connexion",
"Sign out": "Déconnexion",
"Spatial audio": "Audio spatialisé",
"Spotlight": "Premier plan",
"Stop sharing screen": "Arrêter le partage décran",
"Submit feedback": "Envoyer des retours",
"Submitting feedback…": "Envoi des retours…",
"Take me Home": "Retouner à laccueil",
"Talk over speaker": "Parler par dessus lintervenant",
"Thanks! We'll get right on it.": "Merci ! Nous allons nous y attaquer.",
"This call already exists, would you like to join?": "Cet appel existe déjà, voulez-vous le rejoindre ?",
"{{name}} is presenting": "{{name}} est le présentateur",
"Fetching group call timed out.": "Échec de connexion à lappel de groupe dans le temps imparti.",
"{{roomName}} - Walkie-talkie call": "{{roomName}} — Appel talkie-walkie",
"{{name}} is talking…": "{{name}} est en train de parler…",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{displayName}}, your call is now ended": "{{displayName}}, votre appel est désormais terminé",
"{{count}} people connected|other": "{{count}} personnes connectées",
"{{count}} people connected|one": "{{count}} personne connectée",
"Your recent calls": "Appels récents",
"You can't talk at the same time": "Vous ne pouvez pas parler en même temps",
"Yes, join call": "Oui, rejoindre lappel",
"WebRTC is not supported or is being blocked in this browser.": "WebRTC nest pas pris en charge ou est bloqué par ce navigateur.",
"Walkie-talkie call name": "Nom de lappel talkie-walkie",
"Walkie-talkie call": "Appel talkie-walkie",
"Waiting for other participants…": "En attente dautres participants…",
"Waiting for network": "En attente du réseau",
"Video call name": "Nom de lappel vidéo",
"Video call": "Appel vidéo",
"Video": "Vidéo",
"Version: {{version}}": "Version : {{version}}",
"Username": "Nom dutilisateur",
"User menu": "Menu utilisateur",
"User ID": "Identifiant utilisateur",
"Unmute microphone": "Allumer le micro",
"Turn on camera": "Allumer la caméra",
"Turn off camera": "Couper la caméra",
"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.)": "Cela donnera limpression que le son de lintervenant provient de là où leur tuile est positionnée sur lécran. (Fonctionnalité expérimentale : ceci pourrait avoir un impact sur la stabilité du son.)",
"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>": "Ce site est protégé par ReCAPTCHA, la <2>politique de confidentialité</2> et les <6>conditions dutilisation</6> de Google sappliquent.<9></9>En cliquant sur « Senregistrer » vous acceptez également nos <12>conditions dutilisation</12>",
"Talking…": "Vous parlez…",
"Speaker {{n}}": "Intervenant {{n}}",
"Speaker": "Intervenant",
"Invite": "Inviter",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Vous avez déjà un compte ?</0><1><0>Se connecter</0> Ou <2>Accès invité</2></1>",
"Sending debug logs…": "Envoi des journaux de débogage…"
}

132
public/locales/id/app.json Normal file
View File

@@ -0,0 +1,132 @@
{
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Sudah punya akun?</0><1><0>Masuk</0> Atau <2>Akses sebagai tamu</2></1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Buat akun</0> Atau <2>Akses sebagai tamu</2>",
"<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</1>": "<0>Aduh, ada yang salah.</0><1>Mengirimkan catatan pengawakutuan akan membantu kami melacak masalahnya.</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>": "<0>Kenapa tidak selesaikan dengan mengatur sebuah kata sandi untuk menjaga akun Anda?</0><1>Anda akan dapat tetap menggunakan nama Anda dan atur sebuah avatar untuk digunakan dalam panggilan di masa mendatang</1>",
"Accept camera/microphone permissions to join the call.": "Terima izin kamera/mikrofon untuk bergabung ke panggilan.",
"Accept microphone permissions to join the call.": "Terima izin mikrofon untuk bergabung ke panggilan.",
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Pengguna yang lain di panggilan ini sedang mengalami masalah. Supaya dapat mendiagnosa masalah ini, kami ingin mengumpulkan sebuah catatan pengawakutuan.",
"Audio": "Audio",
"Avatar": "Avatar",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "Dengan mengeklik \"Bergabung\", Anda terima <2>syarat dan ketentuan</2> kami",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Dengan mengeklik \"Bergabung ke panggilan sekarang\", Anda terima <2>syarat dan ketentuan</2> kami",
"Call link copied": "Tautan panggilan disalin",
"Call type menu": "Menu jenis panggilan",
"Camera": "Kamera",
"Camera {{n}}": "Kamera {{n}}",
"Camera/microphone permissions needed to join the call.": "Izin kamera/mikrofon dibutuhkan untuk bergabung ke panggilan.",
"Change layout": "Ubah tata letak",
"Close": "Tutup",
"Confirm password": "Konfirmasi kata sandi",
"Connection lost": "Koneksi hilang",
"Copied!": "Disalin!",
"Copy and share this call link": "Salin dan bagikan tautan panggilan ini",
"Copy call link and join later": "Salin tautan panggilan dan bergabung nanti",
"Create account": "Buat akun",
"Debug log": "Catatan pengawakutuan",
"Debug log request": "Permintaan catatan pengawakutuan",
"Description (optional)": "Deskripsi (opsional)",
"Details": "Detail",
"Developer": "Pengembang",
"Display name": "Nama tampilan",
"Download debug logs": "Unduh catatan pengawakutuan",
"Entering room…": "Memasuki ruangan…",
"Exit full screen": "Keluar dari layar penuh",
"Fetching group call timed out.": "Waktu pendapatan panggilan grup habis.",
"Freedom": "Bebas",
"Full screen": "Layar penuh",
"Go": "Bergabung",
"Grid layout menu": "Menu tata letak kisi",
"Having trouble? Help us fix it.": "Mengalami masalah? Bantu kami memperbaikinya.",
"Home": "Beranda",
"Include debug logs": "Termasuk catatan pengawakutuan",
"Incompatible versions": "Versi tidak kompatibel",
"Incompatible versions!": "Versi tidak kompatibel!",
"Inspector": "Inspektur",
"Invite": "Undang",
"Invite people": "Undang orang",
"Join call": "Bergabung ke panggilan",
"Join call now": "Bergabung ke panggilan sekarang",
"Join existing call?": "Bergabung ke panggilan yang sudah ada?",
"Leave": "Keluar",
"Loading room…": "Memuat ruangan…",
"Loading…": "Memuat…",
"Local volume": "Volume lokal",
"Logging in…": "Memasuki…",
"Login": "Masuk",
"Login to your account": "Masuk ke akun Anda",
"Microphone": "Mikrofon",
"Microphone permissions needed to join the call.": "Izin mikrofon dibutuhkan untuk bergabung ke panggilan ini.",
"Microphone {{n}}": "Mikrofon {{n}}",
"More": "Lainnya",
"More menu": "Menu lainnya",
"Mute microphone": "Bisukan mikrofon",
"No": "Tidak",
"Not now, return to home screen": "Tidak sekarang, kembali ke layar beranda",
"Not registered yet? <2>Create an account</2>": "Belum terdaftar? <2>Buat sebuah akun</2>",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Pengguna lain sedang mencoba bergabung ke panggilan ini dari versi yang tidak kompatibel. Pengguna berikut seharusnya memastikan bahwa mereka telah memuat ulang peramban mereka: <1>{userLis}</1>",
"Password": "Kata sandi",
"Passwords must match": "Kata sandi harus cocok",
"Press and hold spacebar to talk": "Tekan dan tahan bilah spasi untuk berbicara",
"Press and hold spacebar to talk over {{name}}": "Tekan dan tahan bilah spasi untuk berbicara pada {{name}}",
"Press and hold to talk": "Tekan dan tahan untuk berbicara",
"Press and hold to talk over {{name}}": "Tekan dan tahan untuk berbicara pada {{name}}",
"Profile": "Profil",
"Recaptcha dismissed": "Recaptcha ditutup",
"Recaptcha not loaded": "Recaptcha tidak dimuat",
"Register": "Daftar",
"Registering…": "Mendaftarkan…",
"Release spacebar key to stop": "Lepaskan bilah spasi untuk berhenti",
"Release to stop": "Lepaskan untuk berhenti",
"Remove": "Hapus",
"Return to home screen": "Kembali ke layar beranda",
"Save": "Simpan",
"Saving…": "Menyimpan…",
"Select an option": "Pilih sebuah opsi",
"Send debug logs": "Kirim catatan pengawakutuan",
"Sending…": "Mengirimkan…",
"Settings": "Pengaturan",
"Share screen": "Bagikan layar",
"Show call inspector": "Tampilkan inspektur panggilan",
"Sign in": "Masuk",
"Sign out": "Keluar",
"Spatial audio": "Audio spasial",
"Speaker": "Pembicara",
"Speaker {{n}}": "Pembicara {{n}}",
"Spotlight": "Sorotan",
"Stop sharing screen": "Berhenti membagikan layar",
"Submit feedback": "Kirim masukan",
"Submitting feedback…": "Mengirimkan masukan…",
"Take me Home": "Bawa saya ke Beranda",
"Talk over speaker": "Bicara pada pembicara",
"Talking…": "Berbicara…",
"Thanks! We'll get right on it.": "Terima kasih! Kami akan melihatnya.",
"This call already exists, would you like to join?": "Panggilan ini sudah ada, apakah Anda ingin bergabung?",
"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>": "Situs ini dilindungi oleh ReCAPTCHA dan <2>Kebijakan Privasi</2> dan <6>Ketentuan Layanan</6> Google berlaku.<9>Dengan mengeklik \"Daftar\", Anda terima <12>syarat dan ketentuan</12> kami",
"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.)": "Ini akan membuat suara pembicara seolah-olah berasal dari tempat ubin mereka diposisikan di layar. (Fitur uji coba: ini dapat memengaruhi stabilitas audio.)",
"Turn off camera": "Matikan kamera",
"Turn on camera": "Nyalakan kamera",
"Unmute microphone": "Suarakan mikrofon",
"User ID": "ID pengguna",
"User menu": "Menu pengguna",
"Username": "Nama pengguna",
"Version: {{version}}": "Versi: {{version}}",
"Video": "Video",
"Video call": "Panggilan video",
"Video call name": "Nama panggilan video",
"Waiting for network": "Menunggu jaringan",
"Waiting for other participants…": "Menunggu peserta lain…",
"Walkie-talkie call": "Panggilan protofon",
"Walkie-talkie call name": "Nama panggilan protofon",
"WebRTC is not supported or is being blocked in this browser.": "WebRTC tidak didukung atau diblokir di peramban ini.",
"Yes, join call": "Ya, bergabung ke panggilan",
"You can't talk at the same time": "Anda tidak dapat berbicara pada waktu yang sama",
"Your recent calls": "Panggilan Anda terkini",
"{{count}} people connected|one": "{{count}} orang terhubung",
"{{count}} people connected|other": "{{count}} orang terhubung",
"{{displayName}}, your call is now ended": "{{displayName}}, panggilan Anda sekarang telah berakhir",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} is presenting": "{{name}} sedang mempresentasi",
"{{name}} is talking…": "{{name}} sedang berbicara…",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Panggilan protofon"
}

View File

@@ -0,0 +1,12 @@
{
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "",
"<0>Create an account</0> Or <2>Access as a guest</2>": "",
"<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</1>": "",
"{{count}} people connected|one": "{{count}}명 연결됨",
"{{count}} people connected|other": "{{count}}명 연결됨",
"{{displayName}}, your call is now ended": "{{displayName}}님, 전화가 종료되었습니다",
"{{names}}, {{name}}": "{{names}}님, {{name}}님",
"{{name}} is presenting": "{{name}}님이 발표 중",
"{{name}} is talking…": "{{name}}님이 말하는 중…",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - 워키토키 전화"
}

133
public/locales/ru/app.json Normal file
View File

@@ -0,0 +1,133 @@
{
"Register": "Зарегистрироваться",
"Saving…": "Сохранение…",
"Registering…": "Регистрация…",
"Logging in…": "Вход…",
"Entering room…": "Вход в комнату…",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"Waiting for other participants…": "Ожидание других участников…",
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "Эта функция балансирует звук к расположению плитки на экране. (Экспериментальная функция: может повлиять на стабильность аудио.)",
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "Этот сайт защищён ReCAPTCHA от Google, ознакомьтесь с их <2>Политикой конфиденциальности</2> и <6>Пользовательским соглашением</6>.<9></9>Нажимая \"Зарегистрироваться\", вы также принимаете наши <12>Положения и условия</12>.",
"This call already exists, would you like to join?": "Этот звонок уже существует, хотите присоединиться?",
"Thanks! We'll get right on it.": "Спасибо! Мы учтём ваш отзыв.",
"Talking…": "Говорите…",
"Submitting feedback…": "Отправка отзыва…",
"Submit feedback": "Отправить отзыв",
"Sending debug logs…": "Отправка журнала отладки…",
"Select an option": "Выберите вариант",
"Release to stop": "Отпустите, чтобы прекратить вещание",
"Release spacebar key to stop": "Чтобы прекратить вещание, отпустите [Пробел]",
"Press and hold to talk over {{name}}": "Зажмите, чтобы говорить поверх участника {{name}}",
"Press and hold spacebar to talk over {{name}}": "Чтобы говорить поверх участника {{name}}, нажмите и удерживайте [Пробел]",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Другие пользователи пытаются присоединиться с неподдерживаемых версий программы. Этим участникам надо перезагрузить браузер: <1>{userLis}</1>",
"Grid layout menu": "Меню \"Расположение сеткой\"",
"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>",
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Почему бы не задать пароль, тем самым сохранив аккаунт?</0><1>Так вы можете оставить своё имя и задать аватар для будущих звонков.</1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Создать аккаунт</0> или <2>Зайти как гость</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Уже есть аккаунт?</0><1><0>Войти с ним</0> или <2>Зайти как гость</2></1>",
"Your recent calls": "Ваши недавние звонки",
"You can't talk at the same time": "Вы не можете говорить одновременно",
"Yes, join call": "Да, присоединиться",
"WebRTC is not supported or is being blocked in this browser.": "WebRTC не поддерживается или заблокирован в этом браузере.",
"Walkie-talkie call name": "Название звонка-рации",
"Walkie-talkie call": "Звонок-рация",
"Waiting for network": "Ожидание сети",
"Video call name": "Название видео-звонка",
"Video call": "Видео-звонок",
"Video": "Видео",
"Version: {{version}}": "Версия: {{version}}",
"Username": "Имя пользователя",
"User menu": "Меню пользователя",
"User ID": "ID пользователя",
"Unmute microphone": "Включить микрофон",
"Turn on camera": "Включить камеру",
"Turn off camera": "Отключить камеру",
"Talk over speaker": "Говорить через динамик",
"Take me Home": "Перейти в Начало",
"Stop sharing screen": "Остановить показ экрана",
"Spotlight": "Внимание",
"Speaker {{n}}": "Динамик {{n}}",
"Speaker": "Динамик",
"Spatial audio": "Пространственное аудио",
"Sign out": "Выйти",
"Sign in": "Войти",
"Show call inspector": "Показать инспектор",
"Share screen": "Поделиться экраном",
"Settings": "Настройки",
"Sending…": "Отправка…",
"Local volume": "Местная громкость",
"Call type menu": "Меню \"Тип звонка\"",
"More menu": "Полное меню",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Звонок-рация",
"Include debug logs": "Приложить журнал отладки",
"Download debug logs": "Скачать журнал отладки",
"Debug log request": "Запрос журнала отладки",
"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.": "У одного из участников звонка есть неполадки. Чтобы лучше диагностировать похожие проблемы, нам нужен журнал отладки.",
"<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</1>": "<0>Ой, что-то пошло не так.</0><1>Отправив журнал отладки, вы поможете нам найти проблемный участок.</1>",
"Send debug logs": "Отправить журнал отладки",
"Save": "Сохранить",
"Return to home screen": "Вернуться в начало",
"Remove": "Удалить",
"Recaptcha not loaded": "Невозможно начать проверку",
"Recaptcha dismissed": "Проверка не пройдена",
"Profile": "Профиль",
"Press and hold to talk": "Зажмите, чтобы говорить",
"Press and hold spacebar to talk": "Чтобы говорить, нажмите и удерживайте [Пробел]",
"Passwords must match": "Пароли должны совпадать",
"Password": "Пароль",
"Not registered yet? <2>Create an account</2>": "Ещё не зарегистрированы? <2>Создайте аккаунт</2>",
"Not now, return to home screen": "Не сейчас, вернитесь в начало",
"No": "Нет",
"Mute microphone": "Отключить микрофон",
"More": "Больше",
"Microphone permissions needed to join the call.": "Нужно разрешение на доступ к микрофону для присоединения к звонку.",
"Microphone {{n}}": "Микрофон {{n}}",
"Microphone": "Микрофон",
"Login to your account": "Войдите в свой аккаунт",
"Login": "Вход",
"Loading…": "Загрузка…",
"Loading room…": "Загрузка комнаты…",
"Leave": "Покинуть",
"Join existing call?": "Присоединиться к существующему звонку?",
"Join call now": "Присоединиться сейчас",
"Join call": "Присоединиться",
"Invite people": "Пригласить участников",
"Invite": "Пригласить",
"Inspector": "Инспектор",
"Incompatible versions!": "Несовместимые версии!",
"Incompatible versions": "Несовместимые версии",
"Home": "Начало",
"Having trouble? Help us fix it.": "Есть проблема? Помогите нам её устранить.",
"Go": "Далее",
"Full screen": "Полноэкранный режим",
"Freedom": "Свобода",
"Fetching group call timed out.": "Истекло время ожидания для группового звонка.",
"Exit full screen": "Выйти из полноэкранного режима",
"Display name": "Видимое имя",
"Developer": "Разработчик",
"Details": "Подробности",
"Description (optional)": "Описание (необязательно)",
"Create account": "Создать аккаунт",
"Copy call link and join later": "Скопировать ссылку и присоединиться позже",
"Copy and share this call link": "Скопируйте и поделитесь этой ссылкой на звонок",
"Copied!": "Скопировано!",
"Connection lost": "Соединение потеряно",
"Confirm password": "Подтвердите пароль",
"Close": "Закрыть",
"Change layout": "Изменить расположение",
"Camera/microphone permissions needed to join the call.": "Нужны разрешения на доступ к камере/микрофону для присоединения к звонку.",
"Camera {{n}}": "Камера {{n}}",
"Camera": "Камера",
"Call link copied": "Ссылка на звонок скопирована",
"Avatar": "Аватар",
"Audio": "Аудио",
"Accept microphone permissions to join the call.": "Для присоединения к звонку разрешите доступ к микрофону.",
"Accept camera/microphone permissions to join the call.": "Для присоединения к звонку разрешите доступ к камере/микрофону.",
"{{name}} is talking…": "{{name}} говорит…",
"{{name}} is presenting": "{{name}} показывает",
"{{displayName}}, your call is now ended": "{{displayName}}, ваш звонок завершён",
"{{count}} people connected|other": "{{count}} подключилось",
"{{count}} people connected|one": "{{count}} подключился"
}

103
public/locales/tr/app.json Normal file
View File

@@ -0,0 +1,103 @@
{
"<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>Hesabınızı tutmak için niye bir parola açmıyorsunuz?</0><1>Böylece ileriki aramalarda adınızı ve avatarınızı kullanabileceksiniz</1>",
"Accept camera/microphone permissions to join the call.": "Aramaya katılmanız için kamera/mikrofon erişimine izin verin.",
"Accept microphone permissions to join the call.": "Aramaya katılmak için mikrofon erişim izni verin.",
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Bu aramadaki başka bir kullanıcı sorun yaşıyor. Sorunu daha iyi çözebilmemiz için hata ayıklama kütüğünü almak isteriz.",
"Audio": "Ses",
"Avatar": "Avatar",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "\"Git\"e tıklayarak,<2>hükümler ve koşullar</2>ı kabul etmiş sayılırsınız",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "\"Şimdi katıl\"a tıklayarak, <2>hükümler ve koşullar</2>ı kabul etmiş sayılırsınız",
"Call link copied": "Arama bağlantısı kopyalandı",
"Call type menu": "Arama tipi menüsü",
"Camera": "Kamera",
"Camera {{n}}": "{{n}}. kamera",
"Camera/microphone permissions needed to join the call.": "Aramaya katılmak için kamera/mikrofon izinleri gerek.",
"Change layout": "Yerleşimi değiştir",
"Close": "Kapat",
"Confirm password": "Parolayı tekrar edin",
"Connection lost": "Bağlantı koptu",
"Copied!": "Kopyalandı",
"Copy and share this call link": "Arama bağlantısını kopyala ve paylaş",
"Copy call link and join later": "Sonra katılmak üzere bağlantıyı kopyala",
"Create account": "Hesap aç",
"Debug log": "Hata ayıklama kütüğü",
"Debug log request": "Hata ayıklama kütük istemi",
"Description (optional)": "Tanım (isteğe bağlı)",
"Details": "Ayrıntı",
"Developer": "Geliştirici",
"Display name": "Ekran adı",
"Download debug logs": "Hata ayıklama kütüğünü indir",
"Entering room…": "Odaya giriliyor…",
"Exit full screen": "Tam ekranı terk et",
"Fetching group call timed out.": "Grup çağrısı zaman aşımına uğradı.",
"Freedom": "Özgürlük",
"Full screen": "Tam ekran",
"Go": "Git",
"Grid layout menu": "Izgara plan menü",
"Having trouble? Help us fix it.": "Sorun mu var? Çözmemize yardım edin.",
"Home": "Ev",
"Include debug logs": "Hata ayıklama kütüğünü dahil et",
"Incompatible versions": "Uyumsuz sürümler",
"Incompatible versions!": "Sürüm uyumsuz!",
"Inspector": "Denetçi",
"Invite people": "Kişileri davet et",
"Join call": "Aramaya katıl",
"Join call now": "Aramaya katıl",
"Join existing call?": "Mevcut aramaya katıl?",
"Leave": ık",
"Loading room…": "Oda yükleniyor…",
"Loading…": "Yükleniyor…",
"Local volume": "Yerel ses seviyesi",
"Logging in…": "Giriliyor…",
"Login": "Gir",
"Login to your account": "Hesabınıza girin",
"Microphone": "Mikrofon",
"Microphone permissions needed to join the call.": "Aramaya katılmak için mikrofon erişim izni gerek.",
"Microphone {{n}}": "{{n}}. mikrofon",
"More": "Daha",
"More menu": "Daha fazla",
"Mute microphone": "Mikrofonu kapat",
"No": "Hayır",
"Not now, return to home screen": "Şimdi değil, ev ekranına dön",
"Not registered yet? <2>Create an account</2>": "Kaydolmadınız mı? <2>Hesap açın</2>",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Başka kullanıcılar uyumsuz sürümden katılmaya çalışıyorlar. <1>{userLis}</1> tarayıcılarını mutlaka tazelemeliler.",
"Password": "Parola",
"Passwords must match": "Parolalar aynı olmalı",
"Press and hold spacebar to talk": "Konuşmak için boşluk çubuğunu basılı tutun",
"Press and hold to talk": "Konuşmak için basılı tutun",
"Recaptcha dismissed": "reCAPTCHA atlandı",
"Recaptcha not loaded": "reCAPTCHA yüklenmedi",
"Register": "Kaydol",
"Registering…": "Kaydediyor…",
"Release spacebar key to stop": "Kesmek için boşluk tuşunu bırakın",
"Release to stop": "Kesmek için bırakın",
"Remove": ıkar",
"Return to home screen": "Ev ekranına geri dön",
"Save": "Kaydet",
"Saving…": "Kaydediliyor…",
"Select an option": "Bir seçenek seç",
"Send debug logs": "Hata ayıklama kütüğünü gönder",
"Sending…": "Gönderiliyor…",
"Settings": "Ayarlar",
"Share screen": "Ekran paylaş",
"Show call inspector": "Arama denetçisini göster",
"Sign in": "Gir",
"Sign out": ık",
"Spatial audio": "Uzamsal ses",
"Stop sharing screen": "Ekran paylaşmayı terk et",
"Submit feedback": "Geri bildirim ver",
"Submitting feedback…": "Geri bildirimler gönderiliyor…",
"Take me Home": "Ev ekranına gir",
"Talking…": "Konuşuyor…",
"Thanks! We'll get right on it.": "Sağol! Bununla ilgileneceğiz.",
"This call already exists, would you like to join?": "Bu arama zaten var, katılmak ister misiniz?",
"{{count}} people connected|one": "{{count}} kişi bağlı",
"{{count}} people connected|other": "{{count}} kişi bağlı",
"{{displayName}}, your call is now ended": "Aramanız bitti, {{displayName]}!",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} is presenting": "{{name}} sunuyor",
"{{name}} is talking…": "{{name}} konuşuyor…",
"<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</1>": "<0>Hoop, bir şeyler yanlış.</0><1>Hata ayıklama kütüğünü göndermek sorunu incelememize yardımcı olur.</1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Hesap oluştur</0> yahut <2>Konuk olarak gir</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Mevcut hesabınız mı var?</0><1><0>Gir</0> yahut <2>Konuk girişi</2></1>"
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -24,7 +24,7 @@ declare global {
// TypeScript doesn't know about the experimental setSinkId method, so we
// declare it ourselves
interface MediaElement extends HTMLMediaElement {
interface MediaElement extends HTMLVideoElement {
setSinkId: (id: string) => void;
}
}

View File

@@ -1,63 +0,0 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { RoomRedirect } from "./room/RoomRedirect";
import { ClientProvider } from "./ClientContext";
import { usePageFocusStyle } from "./usePageFocusStyle";
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
const SentryRoute = Sentry.withSentryRouting(Route);
export default function App({ history }) {
usePageFocusStyle();
return (
<Router history={history}>
<ClientProvider>
<OverlayProvider>
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
</SentryRoute>
</Switch>
</OverlayProvider>
</ClientProvider>
</Router>
);
}

78
src/App.tsx Normal file
View File

@@ -0,0 +1,78 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { Suspense } from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { RoomRedirect } from "./room/RoomRedirect";
import { ClientProvider } from "./ClientContext";
import { usePageFocusStyle } from "./usePageFocusStyle";
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
import { InspectorContextProvider } from "./room/GroupCallInspector";
import { CrashView } from "./FullScreenView";
const SentryRoute = Sentry.withSentryRouting(Route);
interface AppProps {
history: History;
}
export default function App({ history }: AppProps) {
usePageFocusStyle();
const errorPage = <CrashView />;
return (
<Router history={history}>
<Suspense fallback={null}>
<ClientProvider>
<InspectorContextProvider>
<Sentry.ErrorBoundary fallback={errorPage}>
<OverlayProvider>
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
</SentryRoute>
</Switch>
</OverlayProvider>
</Sentry.ErrorBoundary>
</InspectorContextProvider>
</ClientProvider>
</Suspense>
</Router>
);
}

View File

@@ -48,11 +48,11 @@ const resolveAvatarSrc = (client: MatrixClient, src: string, size: number) =>
interface Props extends React.HTMLAttributes<HTMLDivElement> {
bgKey?: string;
src: string;
fallback: string;
src?: string;
size?: Size | number;
className: string;
className?: string;
style?: CSSProperties;
fallback: string;
}
export const Avatar: React.FC<Props> = ({

View File

@@ -26,9 +26,17 @@ import React, {
import { useHistory } from "react-router-dom";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import { ErrorView } from "./FullScreenView";
import { initClient, defaultHomeserver } from "./matrix-utils";
import {
initClient,
defaultHomeserver,
CryptoStoreIntegrityError,
} from "./matrix-utils";
import { widget } from "./widget";
import { translatedError } from "./TranslatedError";
declare global {
interface Window {
@@ -62,6 +70,7 @@ interface ClientState {
changePassword: (password: string) => Promise<void>;
logout: () => void;
setClient: (client: MatrixClient, session: Session) => void;
error?: Error;
}
const ClientContext = createContext<ClientState>(null);
@@ -71,7 +80,11 @@ type ClientProviderState = Omit<
"changePassword" | "logout" | "setClient"
> & { error?: Error };
export const ClientProvider: FC = ({ children }) => {
interface Props {
children: JSX.Element;
}
export const ClientProvider: FC<Props> = ({ children }) => {
const history = useHistory();
const [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
@@ -86,37 +99,74 @@ export const ClientProvider: FC = ({ children }) => {
});
useEffect(() => {
const restore = async (): Promise<
const init = async (): Promise<
Pick<ClientProviderState, "client" | "isPasswordlessUser">
> => {
try {
const session = loadSession();
if (widget) {
// We're inside a widget, so let's engage *matryoshka mode*
logger.log("Using a matryoshka client");
return {
client: await widget.client,
isPasswordlessUser: false,
};
} else {
// We're running as a standalone application
try {
const session = loadSession();
if (!session) return { client: undefined, isPasswordlessUser: false };
logger.log("Using a standalone client");
if (session) {
/* eslint-disable camelcase */
const { user_id, device_id, access_token, passwordlessUser } =
session;
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
try {
return {
client: await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
true
),
isPasswordlessUser: passwordlessUser,
};
} catch (err) {
if (err instanceof CryptoStoreIntegrityError) {
// We can't use this session anymore, so let's log it out
try {
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false // Don't need the crypto store just to log out
);
await client.logout(true);
} catch (err_) {
logger.warn(
"The previous session was lost, and we couldn't log it out, " +
"either"
);
}
}
throw err;
}
/* eslint-enable camelcase */
return { client, isPasswordlessUser: passwordlessUser };
} catch (err) {
clearSession();
throw err;
}
return { client: undefined, isPasswordlessUser: false };
} catch (err) {
console.error(err);
clearSession();
throw err;
}
};
restore()
init()
.then(({ client, isPasswordlessUser }) => {
setState({
client,
@@ -124,15 +174,18 @@ export const ClientProvider: FC = ({ children }) => {
isAuthenticated: Boolean(client),
isPasswordlessUser,
userName: client?.getUserIdLocalpart(),
error: undefined,
});
})
.catch(() => {
.catch((err) => {
logger.error(err);
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
error: undefined,
});
});
}, []);
@@ -162,6 +215,7 @@ export const ClientProvider: FC = ({ children }) => {
isAuthenticated: true,
isPasswordlessUser: false,
userName: client.getUserIdLocalpart(),
error: undefined,
});
},
[client]
@@ -182,6 +236,7 @@ export const ClientProvider: FC = ({ children }) => {
isAuthenticated: true,
isPasswordlessUser: session.passwordlessUser,
userName: newClient.getUserIdLocalpart(),
error: undefined,
});
} else {
clearSession();
@@ -192,19 +247,36 @@ export const ClientProvider: FC = ({ children }) => {
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
error: undefined,
});
}
},
[client]
);
const logout = useCallback(() => {
const logout = useCallback(async () => {
await client.logout(true);
await client.clearStores();
clearSession();
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: true,
userName: "",
error: undefined,
});
history.push("/");
}, [history]);
}, [history, client]);
const { t } = useTranslation();
useEffect(() => {
if (client) {
// To protect against multiple sessions writing to the same storage
// simultaneously, we send a to-device message that shuts down all other
// running instances of the app. This isn't necessary if the app is running
// in a widget though, since then it'll be mostly stateless.
if (!widget && client) {
const loadTime = Date.now();
const onToDeviceEvent = (event: MatrixEvent) => {
@@ -219,8 +291,9 @@ export const ClientProvider: FC = ({ children }) => {
setState((prev) => ({
...prev,
error: new Error(
"This application has been opened in another tab."
error: translatedError(
"This application has been opened in another tab.",
t
),
}));
}
@@ -238,7 +311,7 @@ export const ClientProvider: FC = ({ children }) => {
client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
};
}
}, [client]);
}, [client, t]);
const context = useMemo<ClientState>(
() => ({
@@ -250,6 +323,7 @@ export const ClientProvider: FC = ({ children }) => {
logout,
userName,
setClient,
error: undefined,
}),
[
loading,

View File

@@ -1,59 +0,0 @@
import React from "react";
import styles from "./Facepile.module.css";
import classNames from "classnames";
import { Avatar, sizes } from "./Avatar";
const overlapMap = {
xs: 2,
sm: 4,
md: 8,
};
export function Facepile({
className,
client,
participants,
max,
size,
...rest
}) {
const _size = sizes.get(size);
const _overlap = overlapMap[size];
return (
<div
className={classNames(styles.facepile, styles[size], className)}
title={participants.map((member) => member.name).join(", ")}
style={{ width: participants.length * (_size - _overlap) + _overlap }}
{...rest}
>
{participants.slice(0, max).map((member, i) => {
const avatarUrl = member.user?.avatarUrl;
return (
<Avatar
key={member.userId}
size={size}
src={avatarUrl}
fallback={member.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
style={{ left: i * (_size - _overlap) }}
/>
);
})}
{participants.length > max && (
<Avatar
key="additional"
size={size}
fallback={`+${participants.length - max}`}
className={styles.avatar}
style={{ left: max * (_size - _overlap) }}
/>
)}
</div>
);
}
Facepile.defaultProps = {
max: 3,
size: "xs",
};

98
src/Facepile.tsx Normal file
View File

@@ -0,0 +1,98 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { HTMLAttributes, useMemo } from "react";
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useTranslation } from "react-i18next";
import styles from "./Facepile.module.css";
import { Avatar, Size, sizes } from "./Avatar";
const overlapMap: Partial<Record<Size, number>> = {
[Size.XS]: 2,
[Size.SM]: 4,
[Size.MD]: 8,
};
interface Props extends HTMLAttributes<HTMLDivElement> {
className: string;
client: MatrixClient;
participants: RoomMember[];
max?: number;
size?: Size;
}
export function Facepile({
className,
client,
participants,
max = 3,
size = Size.XS,
...rest
}: Props) {
const { t } = useTranslation();
const _size = sizes.get(size);
const _overlap = overlapMap[size];
const title = useMemo(() => {
return participants.reduce<string | null>(
(prev, curr) =>
prev === null
? curr.name
: t("{{names}}, {{name}}", { names: prev, name: curr.name }),
null
) as string;
}, [participants, t]);
return (
<div
className={classNames(styles.facepile, styles[size], className)}
title={title}
style={{
width:
Math.min(participants.length, max + 1) * (_size - _overlap) +
_overlap,
}}
{...rest}
>
{participants.slice(0, max).map((member, i) => {
const avatarUrl = member.user?.avatarUrl;
return (
<Avatar
key={member.userId}
size={size}
src={avatarUrl}
fallback={member.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
style={{ left: i * (_size - _overlap) }}
/>
);
})}
{participants.length > max && (
<Avatar
key="additional"
size={size}
fallback={`+${participants.length - max}`}
className={styles.avatar}
style={{ left: max * (_size - _overlap) }}
/>
)}
</div>
);
}

View File

@@ -1,68 +0,0 @@
import React, { useCallback, useEffect } from "react";
import { useLocation } from "react-router-dom";
import styles from "./FullScreenView.module.css";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import classNames from "classnames";
import { LinkButton, Button } from "./button";
export function FullScreenView({ className, children }) {
return (
<div className={classNames(styles.page, className)}>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav />
</Header>
<div className={styles.container}>
<div className={styles.content}>{children}</div>
</div>
</div>
);
}
export function ErrorView({ error }) {
const location = useLocation();
useEffect(() => {
console.error(error);
}, [error]);
const onReload = useCallback(() => {
window.location = "/";
}, []);
return (
<FullScreenView>
<h1>Error</h1>
<p>{error.message}</p>
{location.pathname === "/" ? (
<Button
size="lg"
variant="default"
className={styles.homeLink}
onPress={onReload}
>
Return to home screen
</Button>
) : (
<LinkButton
size="lg"
variant="default"
className={styles.homeLink}
to="/"
>
Return to home screen
</LinkButton>
)}
</FullScreenView>
);
}
export function LoadingView() {
return (
<FullScreenView>
<h1>Loading...</h1>
</FullScreenView>
);
}

View File

@@ -36,6 +36,12 @@
margin-bottom: 0;
}
.homeLink {
/* Make the buttons the same width */
.wideButton {
width: 291px;
}
/* Fixed height to avoid content jumping around*/
.sendLogsSection {
height: 50px;
}

144
src/FullScreenView.tsx Normal file
View File

@@ -0,0 +1,144 @@
import React, { ReactNode, useCallback, useEffect } from "react";
import { useLocation } from "react-router-dom";
import classNames from "classnames";
import { Trans, useTranslation } from "react-i18next";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import { LinkButton, Button } from "./button";
import { useSubmitRageshake } from "./settings/submit-rageshake";
import { ErrorMessage } from "./input/Input";
import styles from "./FullScreenView.module.css";
import { translatedError, TranslatedError } from "./TranslatedError";
interface FullScreenViewProps {
className?: string;
children: ReactNode;
}
export function FullScreenView({ className, children }: FullScreenViewProps) {
return (
<div className={classNames(styles.page, className)}>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav />
</Header>
<div className={styles.container}>
<div className={styles.content}>{children}</div>
</div>
</div>
);
}
interface ErrorViewProps {
error: Error;
}
export function ErrorView({ error }: ErrorViewProps) {
const location = useLocation();
const { t } = useTranslation();
useEffect(() => {
console.error(error);
}, [error]);
const onReload = useCallback(() => {
window.location.href = "/";
}, []);
return (
<FullScreenView>
<h1>Error</h1>
<p>
{error instanceof TranslatedError
? error.translatedMessage
: error.message}
</p>
{location.pathname === "/" ? (
<Button
size="lg"
variant="default"
className={styles.homeLink}
onPress={onReload}
>
{t("Return to home screen")}
</Button>
) : (
<LinkButton
size="lg"
variant="default"
className={styles.homeLink}
to="/"
>
{t("Return to home screen")}
</LinkButton>
)}
</FullScreenView>
);
}
export function CrashView() {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const sendDebugLogs = useCallback(() => {
submitRageshake({
description: "**Soft Crash**",
sendLogs: true,
});
}, [submitRageshake]);
const onReload = useCallback(() => {
window.location.href = "/";
}, []);
let logsComponent: JSX.Element | null = null;
if (sent) {
logsComponent = <div>{t("Thanks! We'll get right on it.")}</div>;
} else if (sending) {
logsComponent = <div>{t("Sending…")}</div>;
} else {
logsComponent = (
<Button
size="lg"
variant="default"
onPress={sendDebugLogs}
className={styles.wideButton}
>
{t("Send debug logs")}
</Button>
);
}
return (
<FullScreenView>
<Trans>
<h1>Oops, something's gone wrong.</h1>
<p>Submitting debug logs will help us track down the problem.</p>
</Trans>
<div className={styles.sendLogsSection}>{logsComponent}</div>
{error && (
<ErrorMessage error={translatedError("Couldn't send debug logs!", t)} />
)}
<Button
size="lg"
variant="default"
className={styles.wideButton}
onPress={onReload}
>
{t("Return to home screen")}
</Button>
</FullScreenView>
);
}
export function LoadingView() {
const { t } = useTranslation();
return (
<FullScreenView>
<h1>{t("Loading")}</h1>
</FullScreenView>
);
}

View File

@@ -1,18 +1,27 @@
import classNames from "classnames";
import React, { useCallback, useRef } from "react";
import React, { HTMLAttributes, ReactNode, useCallback, useRef } from "react";
import { Link } from "react-router-dom";
import styles from "./Header.module.css";
import { ReactComponent as Logo } from "./icons/Logo.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
import { useButton } from "@react-aria/button";
import { Subtitle } from "./typography/Typography";
import { Avatar } from "./Avatar";
import { IncompatibleVersionModal } from "./IncompatibleVersionModal";
import { AriaButtonProps } from "@react-types/button";
import { Room } from "matrix-js-sdk/src/models/room";
import { useTranslation } from "react-i18next";
import styles from "./Header.module.css";
import { useModalTriggerState } from "./Modal";
import { Button } from "./button";
import { ReactComponent as Logo } from "./icons/Logo.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { Subtitle } from "./typography/Typography";
import { Avatar, Size } from "./Avatar";
import { IncompatibleVersionModal } from "./IncompatibleVersionModal";
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
export function Header({ children, className, ...rest }) {
interface HeaderProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
className?: string;
}
export function Header({ children, className, ...rest }: HeaderProps) {
return (
<header className={classNames(styles.header, className)} {...rest}>
{children}
@@ -20,7 +29,18 @@ export function Header({ children, className, ...rest }) {
);
}
export function LeftNav({ children, className, hideMobile, ...rest }) {
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
className?: string;
hideMobile?: boolean;
}
export function LeftNav({
children,
className,
hideMobile,
...rest
}: LeftNavProps) {
return (
<div
className={classNames(
@@ -36,7 +56,18 @@ export function LeftNav({ children, className, hideMobile, ...rest }) {
);
}
export function RightNav({ children, className, hideMobile, ...rest }) {
interface RightNavProps extends HTMLAttributes<HTMLElement> {
children?: ReactNode;
className?: string;
hideMobile?: boolean;
}
export function RightNav({
children,
className,
hideMobile,
...rest
}: RightNavProps) {
return (
<div
className={classNames(
@@ -52,7 +83,11 @@ export function RightNav({ children, className, hideMobile, ...rest }) {
);
}
export function HeaderLogo({ className }) {
interface HeaderLogoProps {
className?: string;
}
export function HeaderLogo({ className }: HeaderLogoProps) {
return (
<Link className={classNames(styles.headerLogo, className)} to="/">
<Logo />
@@ -60,12 +95,17 @@ export function HeaderLogo({ className }) {
);
}
export function RoomHeaderInfo({ roomName, avatarUrl }) {
interface RoomHeaderInfo {
roomName: string;
avatarUrl: string;
}
export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) {
return (
<>
<div className={styles.roomAvatar}>
<Avatar
size="md"
size={Size.MD}
src={avatarUrl}
bgKey={roomName}
fallback={roomName.slice(0, 1).toUpperCase()}
@@ -77,12 +117,18 @@ export function RoomHeaderInfo({ roomName, avatarUrl }) {
);
}
interface RoomSetupHeaderInfoProps extends AriaButtonProps<"button"> {
roomName: string;
avatarUrl: string;
isEmbedded: boolean;
}
export function RoomSetupHeaderInfo({
roomName,
avatarUrl,
isEmbedded,
...rest
}) {
}: RoomSetupHeaderInfoProps) {
const ref = useRef();
const { buttonProps } = useButton(rest, ref);
@@ -102,7 +148,16 @@ export function RoomSetupHeaderInfo({
);
}
export function VersionMismatchWarning({ users, room }) {
interface VersionMismatchWarningProps {
users: Set<string>;
room: Room;
}
export function VersionMismatchWarning({
users,
room,
}: VersionMismatchWarningProps) {
const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState();
const onDetailsClick = useCallback(() => {
@@ -113,9 +168,9 @@ export function VersionMismatchWarning({ users, room }) {
return (
<span className={styles.versionMismatchWarning}>
Incomaptible versions!
{t("Incompatible versions!")}
<Button variant="link" onClick={onDetailsClick}>
Details
{t("Details")}
</Button>
{modalState.isOpen && (
<IncompatibleVersionModal userIds={users} room={room} {...modalProps} />

View File

@@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from "matrix-js-sdk";
import React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import React, { useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Modal, ModalContent } from "./Modal";
import { Body } from "./typography/Typography";
@@ -30,17 +31,21 @@ export const IncompatibleVersionModal: React.FC<Props> = ({
room,
...rest
}) => {
const userLis = Array.from(userIds).map((u) => (
<li>{room.getMember(u).name}</li>
));
const { t } = useTranslation();
const userLis = useMemo(
() => [...userIds].map((u) => <li>{room.getMember(u)?.name ?? u}</li>),
[userIds, room]
);
return (
<Modal title="Incompatible Versions" isDismissable {...rest}>
<Modal title={t("Incompatible versions")} isDismissable {...rest}>
<ModalContent>
<Body>
Other users are trying to join this call from incompatible versions.
These users should ensure that they have refreshed their browsers:
<ul>{userLis}</ul>
<Trans>
Other users are trying to join this call from incompatible versions.
These users should ensure that they have refreshed their browsers:
<ul>{userLis}</ul>
</Trans>
</Body>
</ModalContent>
</Modal>

View File

@@ -1,5 +0,0 @@
import { IndexedDBStoreWorker } from "matrix-js-sdk/src/indexeddb-worker";
const remoteWorker = new IndexedDBStoreWorker(self.postMessage);
self.onmessage = remoteWorker.onMessage;

6
src/IndexedDBWorker.ts Normal file
View File

@@ -0,0 +1,6 @@
import { IndexedDBStoreWorker } from "matrix-js-sdk/src/indexeddb-worker";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const remoteWorker = new IndexedDBStoreWorker((self as any).postMessage);
self.onmessage = remoteWorker.onMessage;

90
src/LazyEventEmitter.ts Normal file
View File

@@ -0,0 +1,90 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import EventEmitter from "events";
type NonEmptyArray<T> = [T, ...T[]];
/**
* An event emitter that lets events pile up in a backlog until a listener is
* present, at which point any events that were missed are re-emitted.
*/
export class LazyEventEmitter extends EventEmitter {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private eventBacklogs = new Map<string | symbol, NonEmptyArray<any[]>>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public emit(type: string | symbol, ...args: any[]): boolean {
const hasListeners = super.emit(type, ...args);
if (!hasListeners) {
// The event was missed, so add it to the backlog
const backlog = this.eventBacklogs.get(type);
if (backlog) {
backlog.push(args);
} else {
// Start a new backlog
this.eventBacklogs.set(type, [args]);
}
}
return hasListeners;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public on(type: string | symbol, listener: (...args: any[]) => void): this {
super.on(type, listener);
const backlog = this.eventBacklogs.get(type);
if (backlog) {
// That was the first listener for this type, so let's send it all the
// events that have piled up
for (const args of backlog) super.emit(type, ...args);
// Backlog is now clear
this.eventBacklogs.delete(type);
}
return this;
}
public addListener(
type: string | symbol,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
listener: (...args: any[]) => void
): this {
return this.on(type, listener);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public once(type: string | symbol, listener: (...args: any[]) => void): this {
super.once(type, listener);
const backlog = this.eventBacklogs.get(type);
if (backlog) {
// That was the first listener for this type, so let's send it the first
// of the events that have piled up
super.emit(type, ...backlog[0]);
// Clear the event from the backlog
if (backlog.length === 1) {
this.eventBacklogs.delete(type);
} else {
backlog.shift();
}
}
return this;
}
}

View File

@@ -1,50 +0,0 @@
import React, { useRef } from "react";
import { useListBox, useOption } from "@react-aria/listbox";
import styles from "./ListBox.module.css";
import classNames from "classnames";
export function ListBox(props) {
const ref = useRef();
let { listBoxRef = ref, state } = props;
const { listBoxProps } = useListBox(props, state, listBoxRef);
return (
<ul
{...listBoxProps}
ref={listBoxRef}
className={classNames(styles.listBox, props.className)}
>
{[...state.collection].map((item) => (
<Option
key={item.key}
item={item}
state={state}
className={props.optionClassName}
/>
))}
</ul>
);
}
function Option({ item, state, className }) {
const ref = useRef();
const { optionProps, isSelected, isFocused, isDisabled } = useOption(
{ key: item.key },
state,
ref
);
return (
<li
{...optionProps}
ref={ref}
className={classNames(styles.option, className, {
[styles.selected]: isSelected,
[styles.focused]: isFocused,
[styles.disables]: isDisabled,
})}
>
{item.rendered}
</li>
);
}

89
src/ListBox.tsx Normal file
View File

@@ -0,0 +1,89 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useRef } from "react";
import { useListBox, useOption, AriaListBoxOptions } from "@react-aria/listbox";
import { ListState } from "@react-stately/list";
import { Node } from "@react-types/shared";
import classNames from "classnames";
import styles from "./ListBox.module.css";
interface ListBoxProps<T> extends AriaListBoxOptions<T> {
optionClassName: string;
state: ListState<T>;
className?: string;
listBoxRef?: React.MutableRefObject<HTMLUListElement>;
}
export function ListBox<T>({
state,
optionClassName,
className,
listBoxRef,
...rest
}: ListBoxProps<T>) {
const ref = useRef<HTMLUListElement>();
if (!listBoxRef) listBoxRef = ref;
const { listBoxProps } = useListBox(rest, state, listBoxRef);
return (
<ul
{...listBoxProps}
ref={listBoxRef}
className={classNames(styles.listBox, className)}
>
{[...state.collection].map((item) => (
<Option
key={item.key}
item={item}
state={state}
className={optionClassName}
/>
))}
</ul>
);
}
interface OptionProps<T> {
className: string;
state: ListState<T>;
item: Node<T>;
}
function Option<T>({ item, state, className }: OptionProps<T>) {
const ref = useRef();
const { optionProps, isSelected, isFocused, isDisabled } = useOption(
{ key: item.key },
state,
ref
);
return (
<li
{...optionProps}
ref={ref}
className={classNames(styles.option, className, {
[styles.selected]: isSelected,
[styles.focused]: isFocused,
[styles.disables]: isDisabled,
})}
>
{item.rendered}
</li>
);
}

View File

@@ -1,15 +1,30 @@
import React, { useRef, useState } from "react";
import styles from "./Menu.module.css";
import { useMenu, useMenuItem } from "@react-aria/menu";
import { useTreeState } from "@react-stately/tree";
import React, { Key, useRef, useState } from "react";
import { AriaMenuOptions, useMenu, useMenuItem } from "@react-aria/menu";
import { TreeState, useTreeState } from "@react-stately/tree";
import { mergeProps } from "@react-aria/utils";
import { useFocus } from "@react-aria/interactions";
import classNames from "classnames";
import { Node } from "@react-types/shared";
export function Menu({ className, onAction, ...rest }) {
const state = useTreeState({ ...rest, selectionMode: "none" });
import styles from "./Menu.module.css";
interface MenuProps<T> extends AriaMenuOptions<T> {
className?: String;
onClose?: () => void;
onAction: (value: Key) => void;
label?: string;
}
export function Menu<T extends object>({
className,
onAction,
onClose,
label,
...rest
}: MenuProps<T>) {
const state = useTreeState<T>({ ...rest, selectionMode: "none" });
const menuRef = useRef();
const { menuProps } = useMenu(rest, state, menuRef);
const { menuProps } = useMenu<T>(rest, state, menuRef);
return (
<ul
@@ -23,19 +38,25 @@ export function Menu({ className, onAction, ...rest }) {
item={item}
state={state}
onAction={onAction}
onClose={rest.onClose}
onClose={onClose}
/>
))}
</ul>
);
}
function MenuItem({ item, state, onAction, onClose }) {
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
onAction: (value: Key) => void;
onClose: () => void;
}
function MenuItem<T>({ item, state, onAction, onClose }: MenuItemProps<T>) {
const ref = useRef();
const { menuItemProps } = useMenuItem(
{
key: item.key,
isDisabled: item.isDisabled,
onAction,
onClose,
},

View File

@@ -1,132 +0,0 @@
import React, { useRef, useMemo } from "react";
import {
useOverlay,
usePreventScroll,
useModal,
OverlayContainer,
} from "@react-aria/overlays";
import { useOverlayTriggerState } from "@react-stately/overlays";
import { useDialog } from "@react-aria/dialog";
import { FocusScope } from "@react-aria/focus";
import { useButton } from "@react-aria/button";
import { ReactComponent as CloseIcon } from "./icons/Close.svg";
import styles from "./Modal.module.css";
import classNames from "classnames";
export function Modal(props) {
const { title, children, className, mobileFullScreen } = props;
const modalRef = useRef();
const { overlayProps, underlayProps } = useOverlay(props, modalRef);
usePreventScroll();
const { modalProps } = useModal();
const { dialogProps, titleProps } = useDialog(props, modalRef);
const closeButtonRef = useRef();
const { buttonProps: closeButtonProps } = useButton({
onPress: () => props.onClose(),
});
return (
<OverlayContainer>
<div className={styles.modalOverlay} {...underlayProps}>
<FocusScope contain restoreFocus autoFocus>
<div
{...overlayProps}
{...dialogProps}
{...modalProps}
ref={modalRef}
className={classNames(
styles.modal,
{ [styles.mobileFullScreen]: mobileFullScreen },
className
)}
>
<div className={styles.modalHeader}>
<h3 {...titleProps}>{title}</h3>
<button
{...closeButtonProps}
ref={closeButtonRef}
className={styles.closeButton}
>
<CloseIcon />
</button>
</div>
{children}
</div>
</FocusScope>
</div>
</OverlayContainer>
);
}
export function ModalContent({ children, className, ...rest }) {
return (
<div className={classNames(styles.content, className)} {...rest}>
{children}
</div>
);
}
export function useModalTriggerState() {
const modalState = useOverlayTriggerState({});
const modalProps = useMemo(
() => ({ isOpen: modalState.isOpen, onClose: modalState.close }),
[modalState]
);
return { modalState, modalProps };
}
export function useToggleModalButton(modalState, ref) {
return useButton(
{
onPress: () => modalState.toggle(),
},
ref
);
}
export function useOpenModalButton(modalState, ref) {
return useButton(
{
onPress: () => modalState.open(),
},
ref
);
}
export function useCloseModalButton(modalState, ref) {
return useButton(
{
onPress: () => modalState.close(),
},
ref
);
}
export function ModalTrigger({ children }) {
const { modalState, modalProps } = useModalState();
const buttonRef = useRef();
const { buttonProps } = useToggleModalButton(modalState, buttonRef);
if (
!Array.isArray(children) ||
children.length > 2 ||
typeof children[1] !== "function"
) {
throw new Error(
"ModalTrigger must have two props. The first being a button and the second being a render prop."
);
}
const [modalTrigger, modal] = children;
return (
<>
<modalTrigger.type
{...modalTrigger.props}
{...buttonProps}
ref={buttonRef}
/>
{modalState.isOpen && modal(modalProps)}
</>
);
}

View File

@@ -28,6 +28,7 @@
}
.modalHeader h3 {
font-weight: 600;
font-size: 24px;
margin: 0;
}

204
src/Modal.tsx Normal file
View File

@@ -0,0 +1,204 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/* eslint-disable jsx-a11y/no-autofocus */
import React, { useRef, useMemo, ReactNode } from "react";
import {
useOverlay,
usePreventScroll,
useModal,
OverlayContainer,
OverlayProps,
} from "@react-aria/overlays";
import {
OverlayTriggerState,
useOverlayTriggerState,
} from "@react-stately/overlays";
import { useDialog } from "@react-aria/dialog";
import { FocusScope } from "@react-aria/focus";
import { ButtonAria, useButton } from "@react-aria/button";
import classNames from "classnames";
import { AriaDialogProps } from "@react-types/dialog";
import { useTranslation } from "react-i18next";
import { ReactComponent as CloseIcon } from "./icons/Close.svg";
import styles from "./Modal.module.css";
export interface ModalProps extends OverlayProps, AriaDialogProps {
title: string;
children: ReactNode;
className?: string;
mobileFullScreen?: boolean;
onClose?: () => void;
}
export function Modal({
title,
children,
className,
mobileFullScreen,
onClose,
...rest
}: ModalProps) {
const { t } = useTranslation();
const modalRef = useRef();
const { overlayProps, underlayProps } = useOverlay(
{ ...rest, onClose },
modalRef
);
usePreventScroll();
const { modalProps } = useModal();
const { dialogProps, titleProps } = useDialog(rest, modalRef);
const closeButtonRef = useRef();
const { buttonProps: closeButtonProps } = useButton(
{
onPress: () => onClose(),
},
closeButtonRef
);
return (
<OverlayContainer>
<div className={styles.modalOverlay} {...underlayProps}>
<FocusScope contain restoreFocus autoFocus>
<div
{...overlayProps}
{...dialogProps}
{...modalProps}
ref={modalRef}
className={classNames(
styles.modal,
{ [styles.mobileFullScreen]: mobileFullScreen },
className
)}
>
<div className={styles.modalHeader}>
<h3 {...titleProps}>{title}</h3>
<button
{...closeButtonProps}
ref={closeButtonRef}
className={styles.closeButton}
title={t("Close")}
>
<CloseIcon />
</button>
</div>
{children}
</div>
</FocusScope>
</div>
</OverlayContainer>
);
}
interface ModalContentProps {
children: ReactNode;
className?: string;
}
export function ModalContent({
children,
className,
...rest
}: ModalContentProps) {
return (
<div className={classNames(styles.content, className)} {...rest}>
{children}
</div>
);
}
export function useModalTriggerState(): {
modalState: OverlayTriggerState;
modalProps: { isOpen: boolean; onClose: () => void };
} {
const modalState = useOverlayTriggerState({});
const modalProps = useMemo(
() => ({ isOpen: modalState.isOpen, onClose: modalState.close }),
[modalState]
);
return { modalState, modalProps };
}
export function useToggleModalButton(
modalState: OverlayTriggerState,
ref: React.RefObject<HTMLButtonElement>
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
return useButton(
{
onPress: () => modalState.toggle(),
},
ref
);
}
export function useOpenModalButton(
modalState: OverlayTriggerState,
ref: React.RefObject<HTMLButtonElement>
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
return useButton(
{
onPress: () => modalState.open(),
},
ref
);
}
export function useCloseModalButton(
modalState: OverlayTriggerState,
ref: React.RefObject<HTMLButtonElement>
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
return useButton(
{
onPress: () => modalState.close(),
},
ref
);
}
interface ModalTriggerProps {
children: ReactNode;
}
export function ModalTrigger({ children }: ModalTriggerProps) {
const { modalState, modalProps } = useModalTriggerState();
const buttonRef = useRef();
const { buttonProps } = useToggleModalButton(modalState, buttonRef);
if (
!Array.isArray(children) ||
children.length > 2 ||
typeof children[1] !== "function"
) {
throw new Error(
"ModalTrigger must have two props. The first being a button and the second being a render prop."
);
}
const [modalTrigger, modal] = children;
return (
<>
<modalTrigger.type
{...modalTrigger.props}
{...buttonProps}
ref={buttonRef}
/>
{modalState.isOpen && modal(modalProps)}
</>
);
}

View File

@@ -1,41 +0,0 @@
import React, { useCallback, useState } from "react";
import { SequenceDiagramViewer } from "./room/GroupCallInspector";
import { FieldRow, InputField } from "./input/Input";
import { usePageTitle } from "./usePageTitle";
export function SequenceDiagramViewerPage() {
usePageTitle("Inspector");
const [debugLog, setDebugLog] = useState();
const [selectedUserId, setSelectedUserId] = useState();
const onChangeDebugLog = useCallback((e) => {
if (e.target.files && e.target.files.length > 0) {
e.target.files[0].text().then((text) => {
setDebugLog(JSON.parse(text));
});
}
}, []);
return (
<div style={{ marginTop: 20 }}>
<FieldRow>
<InputField
type="file"
id="debugLog"
name="debugLog"
label="Debug Log"
onChange={onChangeDebugLog}
/>
</FieldRow>
{debugLog && (
<SequenceDiagramViewer
localUserId={debugLog.localUserId}
selectedUserId={selectedUserId}
onSelectUserId={setSelectedUserId}
remoteUserIds={debugLog.remoteUserIds}
events={debugLog.eventsByUserId[selectedUserId]}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
SequenceDiagramViewer,
SequenceDiagramMatrixEvent,
} from "./room/GroupCallInspector";
import { FieldRow, InputField } from "./input/Input";
import { usePageTitle } from "./usePageTitle";
interface DebugLog {
localUserId: string;
eventsByUserId: { [userId: string]: SequenceDiagramMatrixEvent[] };
remoteUserIds: string[];
}
export function SequenceDiagramViewerPage() {
const { t } = useTranslation();
usePageTitle(t("Inspector"));
const [debugLog, setDebugLog] = useState<DebugLog>();
const [selectedUserId, setSelectedUserId] = useState<string>();
const onChangeDebugLog = useCallback((e) => {
if (e.target.files && e.target.files.length > 0) {
e.target.files[0].text().then((text: string) => {
setDebugLog(JSON.parse(text));
});
}
}, []);
return (
<div style={{ marginTop: 20 }}>
<FieldRow>
<InputField
type="file"
id="debugLog"
name="debugLog"
label={t("Debug log")}
onChange={onChangeDebugLog}
/>
</FieldRow>
{debugLog && (
<SequenceDiagramViewer
localUserId={debugLog.localUserId}
selectedUserId={selectedUserId}
onSelectUserId={setSelectedUserId}
remoteUserIds={debugLog.remoteUserIds}
events={debugLog.eventsByUserId[selectedUserId]}
/>
)}
</div>
);
}

View File

@@ -1,76 +0,0 @@
import React, { forwardRef, useRef } from "react";
import { useTooltipTriggerState } from "@react-stately/tooltip";
import { FocusableProvider } from "@react-aria/focus";
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import styles from "./Tooltip.module.css";
import classNames from "classnames";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
export const Tooltip = forwardRef(
({ position, state, className, ...props }, ref) => {
let { tooltipProps } = useTooltip(props, state);
return (
<div
className={classNames(styles.tooltip, className)}
{...mergeProps(props, tooltipProps)}
ref={ref}
>
{props.children}
</div>
);
}
);
export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => {
const tooltipState = useTooltipTriggerState(rest);
const triggerRef = useObjectRef(ref);
const overlayRef = useRef();
const { triggerProps, tooltipProps } = useTooltipTrigger(
rest,
tooltipState,
triggerRef
);
const { overlayProps } = useOverlayPosition({
placement: rest.placement || "top",
targetRef: triggerRef,
overlayRef,
isOpen: tooltipState.isOpen,
offset: 5,
});
if (
!Array.isArray(children) ||
children.length > 2 ||
typeof children[1] !== "function"
) {
throw new Error(
"TooltipTrigger must have two props. The first being a button and the second being a render prop."
);
}
const [tooltipTrigger, tooltip] = children;
return (
<FocusableProvider ref={triggerRef} {...triggerProps}>
{<tooltipTrigger.type {...mergeProps(tooltipTrigger.props, rest)} />}
{tooltipState.isOpen && (
<OverlayContainer>
<Tooltip
state={tooltipState}
{...mergeProps(tooltipProps, overlayProps)}
ref={overlayRef}
>
{tooltip()}
</Tooltip>
</OverlayContainer>
)}
</FocusableProvider>
);
});
TooltipTrigger.defaultProps = {
delay: 250,
};

View File

@@ -3,10 +3,12 @@
flex-direction: row;
justify-content: center;
align-items: center;
padding: 8px 10px;
padding: 10px;
color: var(--primary-content);
border-radius: 8px;
max-width: 135px;
width: max-content;
font-size: 12px;
font-weight: 500;
text-align: center;
}

114
src/Tooltip.tsx Normal file
View File

@@ -0,0 +1,114 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
ForwardedRef,
forwardRef,
ReactElement,
ReactNode,
useRef,
} from "react";
import {
TooltipTriggerState,
useTooltipTriggerState,
} from "@react-stately/tooltip";
import { FocusableProvider } from "@react-aria/focus";
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import classNames from "classnames";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
import { Placement } from "@react-types/overlays";
import styles from "./Tooltip.module.css";
interface TooltipProps {
className?: string;
state: TooltipTriggerState;
children: ReactNode;
}
export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
(
{ state, className, children, ...rest }: TooltipProps,
ref: ForwardedRef<HTMLDivElement>
) => {
const { tooltipProps } = useTooltip(rest, state);
return (
<div
className={classNames(styles.tooltip, className)}
{...mergeProps(rest, tooltipProps)}
ref={ref}
>
{children}
</div>
);
}
);
interface TooltipTriggerProps {
children: ReactElement;
placement?: Placement;
delay?: number;
tooltip: () => string;
}
export const TooltipTrigger = forwardRef<HTMLElement, TooltipTriggerProps>(
(
{ children, placement, tooltip, ...rest }: TooltipTriggerProps,
ref: ForwardedRef<HTMLElement>
) => {
const tooltipTriggerProps = { delay: 250, ...rest };
const tooltipState = useTooltipTriggerState(tooltipTriggerProps);
const triggerRef = useObjectRef<HTMLElement>(ref);
const overlayRef = useRef();
const { triggerProps, tooltipProps } = useTooltipTrigger(
tooltipTriggerProps,
tooltipState,
triggerRef
);
const { overlayProps } = useOverlayPosition({
placement: placement || "top",
targetRef: triggerRef,
overlayRef,
isOpen: tooltipState.isOpen,
offset: 12,
});
return (
<FocusableProvider ref={triggerRef} {...triggerProps}>
<children.type
{...mergeProps<typeof children.props | typeof rest>(
children.props,
rest
)}
/>
{tooltipState.isOpen && (
<OverlayContainer>
<Tooltip
state={tooltipState}
ref={overlayRef}
{...mergeProps(tooltipProps, overlayProps)}
>
{tooltip()}
</Tooltip>
</OverlayContainer>
)}
</FocusableProvider>
);
}
);

41
src/TranslatedError.ts Normal file
View File

@@ -0,0 +1,41 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import i18n from "i18next";
/**
* An error with messages in both English and the user's preferred language.
*/
// Abstract to force consumers to use the function below rather than calling the
// constructor directly
export abstract class TranslatedError extends Error {
/**
* The error message in the user's preferred language.
*/
public readonly translatedMessage: string;
public constructor(messageKey: string, translationFn: typeof i18n.t) {
super(translationFn(messageKey, { lng: "en-GB" }));
this.translatedMessage = translationFn(messageKey);
}
}
class TranslatedErrorImpl extends TranslatedError {}
// i18next-parser can't detect calls to a constructor, so we expose a bare
// function instead
export const translatedError = (messageKey: string, t: typeof i18n.t) =>
new TranslatedErrorImpl(messageKey, t);

109
src/UrlParams.ts Normal file
View File

@@ -0,0 +1,109 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
export interface UrlParams {
roomAlias: string | null;
roomId: string | null;
viaServers: string[];
// Whether the app is running in embedded mode, and should keep the user
// confined to the current room
isEmbedded: boolean;
// Whether the app should pause before joining the call until it sees an
// io.element.join widget action, allowing it to be preloaded
preload: boolean;
// Whether to hide the room header when in a call
hideHeader: boolean;
// Whether to hide the screen-sharing button
hideScreensharing: boolean;
// Whether to start a walkie-talkie call instead of a video call
isPtt: boolean;
// Whether to use end-to-end encryption
e2eEnabled: boolean;
// The user's ID (only used in matryoshka mode)
userId: string | null;
// The display name to use for auto-registration
displayName: string | null;
// The device's ID (only used in matryoshka mode)
deviceId: string | null;
// The base URL of the homeserver to use for media lookups in matryoshka mode
baseUrl: string | null;
// The BCP 47 code of the language the app should use
lang: string | null;
}
/**
* Gets the app parameters for the current URL.
* @param query The URL query string
* @param fragment The URL fragment string
* @returns The app parameters encoded in the URL
*/
export const getUrlParams = (
query: string = window.location.search,
fragment: string = window.location.hash
): UrlParams => {
const fragmentQueryStart = fragment.indexOf("?");
const fragmentParams = new URLSearchParams(
fragmentQueryStart === -1 ? "" : fragment.substring(fragmentQueryStart)
);
const queryParams = new URLSearchParams(query);
// Normally, URL params should be encoded in the fragment so as to avoid
// leaking them to the server. However, we also check the normal query
// string for backwards compatibility with versions that only used that.
const hasParam = (name: string): boolean =>
fragmentParams.has(name) || queryParams.has(name);
const getParam = (name: string): string | null =>
fragmentParams.get(name) ?? queryParams.get(name);
const getAllParams = (name: string): string[] => [
...fragmentParams.getAll(name),
...queryParams.getAll(name),
];
// The part of the fragment before the ?
const fragmentRoute =
fragmentQueryStart === -1
? fragment
: fragment.substring(0, fragmentQueryStart);
return {
roomAlias: fragmentRoute.length > 1 ? fragmentRoute : null,
roomId: getParam("roomId"),
viaServers: getAllParams("via"),
isEmbedded: hasParam("embed"),
preload: hasParam("preload"),
hideHeader: hasParam("hideHeader"),
hideScreensharing: hasParam("hideScreensharing"),
isPtt: hasParam("ptt"),
e2eEnabled: getParam("enableE2e") !== "false", // Defaults to true
userId: getParam("userId"),
displayName: getParam("displayName"),
deviceId: getParam("deviceId"),
baseUrl: getParam("baseUrl"),
lang: getParam("lang"),
};
};
/**
* Hook to simplify use of getUrlParams.
* @returns The app parameters for the current URL
*/
export const useUrlParams = (): UrlParams => {
const { hash, search } = useLocation();
return useMemo(() => getUrlParams(search, hash), [search, hash]);
};

View File

@@ -1,16 +1,27 @@
import React, { useMemo } from "react";
import React, { useCallback, useMemo } from "react";
import { Item } from "@react-stately/collections";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button, LinkButton } from "./button";
import { PopoverMenuTrigger } from "./popover/PopoverMenu";
import { Menu } from "./Menu";
import { Tooltip, TooltipTrigger } from "./Tooltip";
import { Avatar } from "./Avatar";
import { TooltipTrigger } from "./Tooltip";
import { Avatar, Size } from "./Avatar";
import { ReactComponent as UserIcon } from "./icons/User.svg";
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
import styles from "./UserMenu.module.css";
import { useLocation } from "react-router-dom";
import { Body } from "./typography/Typography";
import styles from "./UserMenu.module.css";
interface UserMenuProps {
preventNavigation: boolean;
isAuthenticated: boolean;
isPasswordlessUser: boolean;
displayName: string;
avatarUrl: string;
onAction: (value: string) => void;
}
export function UserMenu({
preventNavigation,
@@ -19,7 +30,8 @@ export function UserMenu({
displayName,
avatarUrl,
onAction,
}) {
}: UserMenuProps) {
const { t } = useTranslation();
const location = useLocation();
const items = useMemo(() => {
@@ -35,7 +47,7 @@ export function UserMenu({
if (isPasswordlessUser && !preventNavigation) {
arr.push({
key: "login",
label: "Sign In",
label: t("Sign in"),
icon: LoginIcon,
});
}
@@ -43,14 +55,16 @@ export function UserMenu({
if (!isPasswordlessUser && !preventNavigation) {
arr.push({
key: "logout",
label: "Sign Out",
label: t("Sign out"),
icon: LogoutIcon,
});
}
}
return arr;
}, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation]);
}, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation, t]);
const tooltip = useCallback(() => t("Profile"), [t]);
if (!isAuthenticated) {
return (
@@ -62,11 +76,11 @@ export function UserMenu({
return (
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger placement="bottom left">
<TooltipTrigger tooltip={tooltip} placement="bottom left">
<Button variant="icon" className={styles.userButton}>
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
<Avatar
size="sm"
size={Size.SM}
className={styles.avatar}
src={avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
@@ -75,12 +89,11 @@ export function UserMenu({
<UserIcon />
)}
</Button>
{() => "Profile"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="User menu" onAction={onAction}>
<Menu {...props} label={t("User menu")} onAction={onAction}>
{items.map(({ key, icon: Icon, label }) => (
<Item key={key} textValue={label} className={styles.menuItem}>
<Item key={key} textValue={label}>
<Icon width={24} height={24} className={styles.menuIcon} />
<Body overflowEllipsis>{label}</Body>
</Item>

View File

@@ -1,12 +1,17 @@
import React, { useCallback } from "react";
import { useHistory, useLocation } from "react-router-dom";
import { useClient } from "./ClientContext";
import { useProfile } from "./profile/useProfile";
import { useModalTriggerState } from "./Modal";
import { ProfileModal } from "./profile/ProfileModal";
import { UserMenu } from "./UserMenu";
export function UserMenuContainer({ preventNavigation }) {
interface Props {
preventNavigation?: boolean;
}
export function UserMenuContainer({ preventNavigation = false }: Props) {
const location = useLocation();
const history = useHistory();
const { isAuthenticated, isPasswordlessUser, logout, userName, client } =
@@ -15,7 +20,7 @@ export function UserMenuContainer({ preventNavigation }) {
const { modalState, modalProps } = useModalTriggerState();
const onAction = useCallback(
(value) => {
(value: string) => {
switch (value) {
case "user":
modalState.open();

View File

@@ -23,6 +23,7 @@ import React, {
useMemo,
} from "react";
import { useHistory, useLocation, Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { useClient } from "../ClientContext";
@@ -34,7 +35,8 @@ import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle";
export const LoginPage: FC = () => {
usePageTitle("Login");
const { t } = useTranslation();
usePageTitle(t("Login"));
const { setClient } = useClient();
const login = useInteractiveLogin();
@@ -93,8 +95,8 @@ export const LoginPage: FC = () => {
<InputField
type="text"
ref={usernameRef}
placeholder="Username"
label="Username"
placeholder={t("Username")}
label={t("Username")}
autoCorrect="off"
autoCapitalize="none"
prefix="@"
@@ -105,18 +107,18 @@ export const LoginPage: FC = () => {
<InputField
type="password"
ref={passwordRef}
placeholder="Password"
label="Password"
placeholder={t("Password")}
label={t("Password")}
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Login"}
{loading ? t("Logging in…") : t("Login")}
</Button>
</FieldRow>
</form>
@@ -124,9 +126,11 @@ export const LoginPage: FC = () => {
<div className={styles.authLinks}>
<p>Not registered yet?</p>
<p>
<Link to="/register">Create an account</Link>
{" Or "}
<Link to="/">Access as a guest</Link>
<Trans>
<Link to="/register">Create an account</Link>
{" Or "}
<Link to="/">Access as a guest</Link>
</Trans>
</p>
</div>
</div>

View File

@@ -15,6 +15,7 @@ limitations under the License.
*/
import React, {
ChangeEvent,
FC,
FormEvent,
useCallback,
@@ -25,6 +26,7 @@ import React, {
import { useHistory, useLocation } from "react-router-dom";
import { captureException } from "@sentry/react";
import { sleep } from "matrix-js-sdk/src/utils";
import { Trans, useTranslation } from "react-i18next";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
@@ -39,7 +41,8 @@ import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle";
export const RegisterPage: FC = () => {
usePageTitle("Register");
const { t } = useTranslation();
usePageTitle(t("Register"));
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
useClient();
@@ -100,7 +103,7 @@ export const RegisterPage: FC = () => {
submit()
.then(() => {
if (location.state?.from) {
history.push(location.state.from);
history.push(location.state?.from);
} else {
history.push("/");
}
@@ -125,11 +128,11 @@ export const RegisterPage: FC = () => {
useEffect(() => {
if (password && passwordConfirmation && password !== passwordConfirmation) {
confirmPasswordRef.current?.setCustomValidity("Passwords must match");
confirmPasswordRef.current?.setCustomValidity(t("Passwords must match"));
} else {
confirmPasswordRef.current?.setCustomValidity("");
}
}, [password, passwordConfirmation]);
}, [password, passwordConfirmation, t]);
useEffect(() => {
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
@@ -153,8 +156,8 @@ export const RegisterPage: FC = () => {
<InputField
type="text"
name="userName"
placeholder="Username"
label="Username"
placeholder={t("Username")}
label={t("Username")}
autoCorrect="off"
autoCapitalize="none"
prefix="@"
@@ -166,10 +169,12 @@ export const RegisterPage: FC = () => {
required
name="password"
type="password"
onChange={(e) => setPassword(e.target.value)}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setPassword(e.target.value)
}
value={password}
placeholder="Password"
label="Password"
placeholder={t("Password")}
label={t("Password")}
/>
</FieldRow>
<FieldRow>
@@ -177,47 +182,53 @@ export const RegisterPage: FC = () => {
required
type="password"
name="passwordConfirmation"
onChange={(e) => setPasswordConfirmation(e.target.value)}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setPasswordConfirmation(e.target.value)
}
value={passwordConfirmation}
placeholder="Confirm Password"
label="Confirm Password"
placeholder={t("Confirm password")}
label={t("Confirm password")}
ref={confirmPasswordRef}
/>
</FieldRow>
<Caption>
This site is protected by ReCAPTCHA and the Google{" "}
<Link href="https://www.google.com/policies/privacy/">
Privacy Policy
</Link>{" "}
and{" "}
<Link href="https://policies.google.com/terms">
Terms of Service
</Link>{" "}
apply.
<br />
By clicking "Register", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
<Trans>
This site is protected by ReCAPTCHA and the Google{" "}
<Link href="https://www.google.com/policies/privacy/">
Privacy Policy
</Link>{" "}
and{" "}
<Link href="https://policies.google.com/terms">
Terms of Service
</Link>{" "}
apply.
<br />
By clicking "Register", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Trans>
</Caption>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={registering}>
{registering ? "Registering..." : "Register"}
{registering ? t("Registering…") : t("Register")}
</Button>
</FieldRow>
<div id={recaptchaId} />
</form>
</div>
<div className={styles.authLinks}>
<p>Already have an account?</p>
<p>
<Link to="/login">Log in</Link>
{" Or "}
<Link to="/">Access as a guest</Link>
</p>
<Trans>
<p>Already have an account?</p>
<p>
<Link to="/login">Log in</Link>
{" Or "}
<Link to="/">Access as a guest</Link>
</p>
</Trans>
</div>
</div>
</div>

View File

@@ -19,7 +19,6 @@ import {
adjectives,
colors,
animals,
Config,
} from "unique-names-generator";
const elements = [
@@ -143,12 +142,11 @@ const elements = [
"oganesson",
];
export function generateRandomName(config: Config): string {
export function generateRandomName(): string {
return uniqueNamesGenerator({
dictionaries: [colors, adjectives, animals, elements],
style: "lowerCase",
length: 3,
separator: "-",
...config,
});
}

View File

@@ -29,7 +29,7 @@ export const useInteractiveLogin = () =>
password: string
) => Promise<[MatrixClient, Session]>
>(async (homeserver: string, username: string, password: string) => {
const authClient = createClient(homeserver);
const authClient = createClient({ baseUrl: homeserver });
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient,
@@ -57,12 +57,15 @@ export const useInteractiveLogin = () =>
passwordlessUser: false,
};
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false
);
/* eslint-enable camelcase */
return [client, session];

View File

@@ -37,7 +37,7 @@ export const useInteractiveRegistration = (): [
const authClient = useRef<MatrixClient>();
if (!authClient.current) {
authClient.current = createClient(defaultHomeserver);
authClient.current = createClient({ baseUrl: defaultHomeserver });
}
useEffect(() => {
@@ -90,12 +90,15 @@ export const useInteractiveRegistration = (): [
const { user_id, access_token, device_id } =
(await interactiveAuth.attemptAuth()) as any;
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false
);
await client.setDisplayName(displayName);

View File

@@ -16,6 +16,9 @@ limitations under the License.
import { useEffect, useCallback, useRef, useState } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
import { translatedError } from "../TranslatedError";
declare global {
interface Window {
@@ -32,6 +35,7 @@ interface RecaptchaPromiseRef {
}
export const useRecaptcha = (sitekey: string) => {
const { t } = useTranslation();
const [recaptchaId] = useState(() => randomString(16));
const promiseRef = useRef<RecaptchaPromiseRef>();
@@ -71,14 +75,14 @@ export const useRecaptcha = (sitekey: string) => {
if (!window.grecaptcha) {
console.log("Recaptcha not loaded");
return Promise.reject(new Error("Recaptcha not loaded"));
return Promise.reject(translatedError("Recaptcha not loaded", t));
}
return new Promise((resolve, reject) => {
const observer = new MutationObserver((mutationsList) => {
for (const item of mutationsList) {
if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
reject(new Error("Recaptcha dismissed"));
reject(translatedError("Recaptcha dismissed", t));
observer.disconnect();
return;
}
@@ -108,7 +112,7 @@ export const useRecaptcha = (sitekey: string) => {
});
}
});
}, [sitekey]);
}, [sitekey, t]);
const reset = useCallback(() => {
window.grecaptcha?.reset();

View File

@@ -0,0 +1,59 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useClient } from "../ClientContext";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { generateRandomName } from "../auth/generateRandomName";
import { useRecaptcha } from "../auth/useRecaptcha";
export interface UseRegisterPasswordlessUserType {
privacyPolicyUrl: string;
registerPasswordlessUser: (displayName: string) => Promise<void>;
recaptchaId: string;
}
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
const { setClient } = useClient();
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const registerPasswordlessUser = useCallback(
async (displayName: string) => {
try {
const recaptchaResponse = await execute();
const userName = generateRandomName();
const [client, session] = await register(
userName,
randomString(16),
displayName,
recaptchaResponse,
true
);
setClient(client, session);
} catch (e) {
reset();
throw e;
}
},
[execute, reset, register, setClient]
);
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
}

View File

@@ -1,172 +0,0 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef } from "react";
import classNames from "classnames";
import styles from "./Button.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as MuteMicIcon } from "../icons/MuteMic.svg";
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { TooltipTrigger } from "../Tooltip";
export const variantToClassName = {
default: [styles.button],
toolbar: [styles.toolbarButton],
toolbarSecondary: [styles.toolbarButtonSecondary],
icon: [styles.iconButton],
secondary: [styles.secondary],
copy: [styles.copyButton],
secondaryCopy: [styles.secondaryCopy, styles.copyButton],
iconCopy: [styles.iconCopyButton],
secondaryHangup: [styles.secondaryHangup],
dropdown: [styles.dropdownButton],
link: [styles.linkButton],
};
export const sizeToClassName = {
lg: [styles.lg],
};
export const Button = forwardRef(
(
{
variant = "default",
size,
on,
off,
iconStyle,
className,
children,
onPress,
onPressStart,
...rest
},
ref
) => {
const buttonRef = useObjectRef(ref);
const { buttonProps } = useButton(
{ onPress, onPressStart, ...rest },
buttonRef
);
// TODO: react-aria's useButton hook prevents form submission via keyboard
// Remove the hack below after this is merged https://github.com/adobe/react-spectrum/pull/904
let filteredButtonProps = buttonProps;
if (rest.type === "submit" && !rest.onPress) {
const { onKeyDown, onKeyUp, ...filtered } = buttonProps;
filteredButtonProps = filtered;
}
return (
<button
className={classNames(
variantToClassName[variant],
sizeToClassName[size],
styles[iconStyle],
className,
{
[styles.on]: on,
[styles.off]: off,
}
)}
{...mergeProps(rest, filteredButtonProps)}
ref={buttonRef}
>
{children}
{variant === "dropdown" && <ArrowDownIcon />}
</button>
);
}
);
export function MicButton({ muted, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <MuteMicIcon /> : <MicIcon />}
</Button>
{() => (muted ? "Unmute microphone" : "Mute microphone")}
</TooltipTrigger>
);
}
export function VideoButton({ muted, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <DisableVideoIcon /> : <VideoIcon />}
</Button>
{() => (muted ? "Turn on camera" : "Turn off camera")}
</TooltipTrigger>
);
}
export function ScreenshareButton({ enabled, className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbarSecondary" {...rest} on={enabled}>
<ScreenshareIcon />
</Button>
{() => (enabled ? "Stop sharing screen" : "Share screen")}
</TooltipTrigger>
);
}
export function HangupButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button
variant="toolbar"
className={classNames(styles.hangupButton, className)}
{...rest}
>
<HangupIcon />
</Button>
{() => "Leave"}
</TooltipTrigger>
);
}
export function SettingsButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
<SettingsIcon />
</Button>
{() => "Settings"}
</TooltipTrigger>
);
}
export function InviteButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
<AddUserIcon />
</Button>
{() => "Invite"}
</TooltipTrigger>
);
}

307
src/button/Button.tsx Normal file
View File

@@ -0,0 +1,307 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, useCallback } from "react";
import { PressEvent } from "@react-types/shared";
import classNames from "classnames";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { useTranslation } from "react-i18next";
import styles from "./Button.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as MuteMicIcon } from "../icons/MuteMic.svg";
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
import { ReactComponent as Fullscreen } from "../icons/Fullscreen.svg";
import { ReactComponent as FullscreenExit } from "../icons/FullscreenExit.svg";
import { TooltipTrigger } from "../Tooltip";
import { VolumeIcon } from "./VolumeIcon";
export type ButtonVariant =
| "default"
| "toolbar"
| "toolbarSecondary"
| "icon"
| "secondary"
| "copy"
| "secondaryCopy"
| "iconCopy"
| "secondaryHangup"
| "dropdown"
| "link";
export const variantToClassName = {
default: [styles.button],
toolbar: [styles.toolbarButton],
toolbarSecondary: [styles.toolbarButtonSecondary],
icon: [styles.iconButton],
secondary: [styles.secondary],
copy: [styles.copyButton],
secondaryCopy: [styles.secondaryCopy, styles.copyButton],
iconCopy: [styles.iconCopyButton],
secondaryHangup: [styles.secondaryHangup],
dropdown: [styles.dropdownButton],
link: [styles.linkButton],
};
export type ButtonSize = "lg";
export const sizeToClassName: { lg: string[] } = {
lg: [styles.lg],
};
interface Props {
variant: ButtonVariant;
size: ButtonSize;
on: () => void;
off: () => void;
iconStyle: string;
className: string;
children: Element[];
onPress: (e: PressEvent) => void;
onPressStart: (e: PressEvent) => void;
// TODO: add all props for <Button>
[index: string]: unknown;
}
export const Button = forwardRef<HTMLButtonElement, Props>(
(
{
variant = "default",
size,
on,
off,
iconStyle,
className,
children,
onPress,
onPressStart,
...rest
},
ref
) => {
const buttonRef = useObjectRef<HTMLButtonElement>(ref);
const { buttonProps } = useButton(
{ onPress, onPressStart, ...rest },
buttonRef
);
// TODO: react-aria's useButton hook prevents form submission via keyboard
// Remove the hack below after this is merged https://github.com/adobe/react-spectrum/pull/904
let filteredButtonProps = buttonProps;
if (rest.type === "submit" && !rest.onPress) {
const { ...filtered } = buttonProps;
filteredButtonProps = filtered;
}
return (
<button
className={classNames(
variantToClassName[variant],
sizeToClassName[size],
styles[iconStyle],
className,
{
[styles.on]: on,
[styles.off]: off,
}
)}
{...mergeProps(rest, filteredButtonProps)}
ref={buttonRef}
>
<>
{children}
{variant === "dropdown" && <ArrowDownIcon />}
</>
</button>
);
}
);
export function MicButton({
muted,
...rest
}: {
muted: boolean;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
const { t } = useTranslation();
return (
<TooltipTrigger
tooltip={() => (muted ? t("Unmute microphone") : t("Mute microphone"))}
>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <MuteMicIcon /> : <MicIcon />}
</Button>
</TooltipTrigger>
);
}
export function VideoButton({
muted,
...rest
}: {
muted: boolean;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
const { t } = useTranslation();
return (
<TooltipTrigger
tooltip={() => (muted ? t("Turn on camera") : t("Turn off camera"))}
>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <DisableVideoIcon /> : <VideoIcon />}
</Button>
</TooltipTrigger>
);
}
export function ScreenshareButton({
enabled,
className,
...rest
}: {
enabled: boolean;
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
const { t } = useTranslation();
return (
<TooltipTrigger
tooltip={() => (enabled ? t("Stop sharing screen") : t("Share screen"))}
>
<Button variant="toolbarSecondary" {...rest} on={enabled}>
<ScreenshareIcon />
</Button>
</TooltipTrigger>
);
}
export function HangupButton({
className,
...rest
}: {
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Leave"), [t]);
return (
<TooltipTrigger tooltip={tooltip}>
<Button
variant="toolbar"
className={classNames(styles.hangupButton, className)}
{...rest}
>
<HangupIcon />
</Button>
</TooltipTrigger>
);
}
export function SettingsButton({
className,
...rest
}: {
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Settings"), [t]);
return (
<TooltipTrigger tooltip={tooltip}>
<Button variant="toolbar" {...rest}>
<SettingsIcon />
</Button>
</TooltipTrigger>
);
}
export function InviteButton({
className,
...rest
}: {
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Invite"), [t]);
return (
<TooltipTrigger tooltip={tooltip}>
<Button variant="toolbar" {...rest}>
<AddUserIcon />
</Button>
</TooltipTrigger>
);
}
interface AudioButtonProps extends Omit<Props, "variant"> {
/**
* A number between 0 and 1
*/
volume: number;
}
export function AudioButton({ volume, ...rest }: AudioButtonProps) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Local volume"), [t]);
return (
<TooltipTrigger tooltip={tooltip}>
<Button variant="icon" {...rest}>
<VolumeIcon volume={volume} />
</Button>
</TooltipTrigger>
);
}
interface FullscreenButtonProps extends Omit<Props, "variant"> {
fullscreen?: boolean;
}
export function FullscreenButton({
fullscreen,
...rest
}: FullscreenButtonProps) {
const { t } = useTranslation();
const tooltip = useCallback(() => {
return fullscreen ? t("Exit full screen") : t("Full screen");
}, [fullscreen, t]);
return (
<TooltipTrigger tooltip={tooltip}>
<Button variant="icon" {...rest}>
{fullscreen ? <FullscreenExit /> : <Fullscreen />}
</Button>
</TooltipTrigger>
);
}

View File

@@ -15,11 +15,20 @@ limitations under the License.
*/
import React from "react";
import { useTranslation } from "react-i18next";
import useClipboard from "react-use-clipboard";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import { ReactComponent as CopyIcon } from "../icons/Copy.svg";
import { Button } from "./Button";
import { Button, ButtonVariant } from "./Button";
interface Props {
value: string;
children?: JSX.Element | string;
className?: string;
variant?: ButtonVariant;
copiedMessage?: string;
}
export function CopyButton({
value,
children,
@@ -27,7 +36,8 @@ export function CopyButton({
variant,
copiedMessage,
...rest
}) {
}: Props) {
const { t } = useTranslation();
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
return (
@@ -41,7 +51,7 @@ export function CopyButton({
>
{isCopied ? (
<>
{variant !== "icon" && <span>{copiedMessage || "Copied!"}</span>}
{variant !== "icon" && <span>{copiedMessage || t("Copied!")}</span>}
<CheckIcon />
</>
) : (

View File

@@ -14,12 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { HTMLAttributes } from "react";
import { Link } from "react-router-dom";
import classNames from "classnames";
import { variantToClassName, sizeToClassName } from "./Button";
import * as H from "history";
export function LinkButton({ className, variant, size, children, ...rest }) {
import {
variantToClassName,
sizeToClassName,
ButtonVariant,
ButtonSize,
} from "./Button";
interface Props extends HTMLAttributes<HTMLAnchorElement> {
children: JSX.Element | string;
to: H.LocationDescriptor | ((location: H.Location) => H.LocationDescriptor);
size?: ButtonSize;
variant?: ButtonVariant;
className?: string;
}
export function LinkButton({
children,
to,
size,
variant,
className,
...rest
}: Props) {
return (
<Link
className={classNames(
@@ -27,6 +49,7 @@ export function LinkButton({ className, variant, size, children, ...rest }) {
sizeToClassName[size],
className
)}
to={to}
{...rest}
>
{children}

35
src/button/VolumeIcon.tsx Normal file
View File

@@ -0,0 +1,35 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { ReactComponent as AudioMuted } from "../icons/AudioMuted.svg";
import { ReactComponent as AudioLow } from "../icons/AudioLow.svg";
import { ReactComponent as Audio } from "../icons/Audio.svg";
interface Props {
/**
* Number between 0 and 1
*/
volume: number;
}
export function VolumeIcon({ volume }: Props) {
if (volume <= 0) return <AudioMuted />;
if (volume <= 0.5) return <AudioLow />;
return <Audio />;
}

40
src/form/Form.tsx Normal file
View File

@@ -0,0 +1,40 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from "classnames";
import React, { FormEventHandler, forwardRef } from "react";
import styles from "./Form.module.css";
interface FormProps {
className: string;
onSubmit: FormEventHandler<HTMLFormElement>;
children: JSX.Element[];
}
export const Form = forwardRef<HTMLFormElement, FormProps>(
({ children, className, onSubmit }, ref) => {
return (
<form
onSubmit={onSubmit}
className={classNames(styles.form, className)}
ref={ref}
>
{children}
</form>
);
}
);

View File

@@ -16,14 +16,23 @@ limitations under the License.
import React from "react";
import { Link } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { CopyButton } from "../button";
import { Facepile } from "../Facepile";
import { Avatar } from "../Avatar";
import { Avatar, Size } from "../Avatar";
import styles from "./CallList.module.css";
import { getRoomUrl } from "../matrix-utils";
import { Body, Caption } from "../typography/Typography";
import { GroupCallRoom } from "./useGroupCallRooms";
export function CallList({ rooms, client, disableFacepile }) {
interface CallListProps {
rooms: GroupCallRoom[];
client: MatrixClient;
disableFacepile?: boolean;
}
export function CallList({ rooms, client, disableFacepile }: CallListProps) {
return (
<>
<div className={styles.callList}>
@@ -48,7 +57,14 @@ export function CallList({ rooms, client, disableFacepile }) {
</>
);
}
interface CallTileProps {
name: string;
avatarUrl: string;
roomId: string;
participants: RoomMember[];
client: MatrixClient;
disableFacepile?: boolean;
}
function CallTile({
name,
avatarUrl,
@@ -56,12 +72,12 @@ function CallTile({
participants,
client,
disableFacepile,
}) {
}: CallTileProps) {
return (
<div className={styles.callTile}>
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
<Avatar
size="lg"
size={Size.LG}
bgKey={name}
src={avatarUrl}
fallback={name.slice(0, 1).toUpperCase()}

View File

@@ -16,6 +16,7 @@ limitations under the License.
import React, { FC } from "react";
import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";
import { Headline } from "../typography/Typography";
import { Button } from "../button";
@@ -39,25 +40,29 @@ interface Props {
}
export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
const { t } = useTranslation();
return (
<PopoverMenuTrigger placement="bottom">
<Button variant="dropdown" className={commonStyles.headline}>
<Headline className={styles.label}>
{callType === CallType.Video ? "Video call" : "Walkie-talkie call"}
{callType === CallType.Video
? t("Video call")
: t("Walkie-talkie call")}
</Headline>
</Button>
{(props) => (
<Menu {...props} label="Call type menu" onAction={setCallType}>
<Item key={CallType.Video} textValue="Video call">
{(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label={t("Call type menu")} onAction={setCallType}>
<Item key={CallType.Video} textValue={t("Video call")}>
<VideoIcon />
<span>Video call</span>
<span>{t("Video call")}</span>
{callType === CallType.Video && (
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item>
<Item key={CallType.Radio} textValue="Walkie-talkie call">
<Item key={CallType.Radio} textValue={t("Walkie-talkie call")}>
<MicIcon />
<span>Walkie-talkie call</span>
<span>{t("Walkie-talkie call")}</span>
{callType === CallType.Radio && (
<CheckIcon className={menuStyles.checkIcon} />
)}

View File

@@ -15,6 +15,8 @@ limitations under the License.
*/
import React from "react";
import { useTranslation } from "react-i18next";
import { useClient } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView";
@@ -22,7 +24,8 @@ import { RegisteredView } from "./RegisteredView";
import { usePageTitle } from "../usePageTitle";
export function HomePage() {
usePageTitle("Home");
const { t } = useTranslation();
usePageTitle(t("Home"));
const { isAuthenticated, isPasswordlessUser, loading, error, client } =
useClient();

View File

@@ -15,19 +15,30 @@ limitations under the License.
*/
import React from "react";
import { PressEvent } from "@react-types/shared";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent } from "../Modal";
import { Button } from "../button";
import { FieldRow } from "../input/Input";
import styles from "./JoinExistingCallModal.module.css";
export function JoinExistingCallModal({ onJoin, ...rest }) {
interface Props {
onJoin: (e: PressEvent) => void;
onClose: (e: PressEvent) => void;
// TODO: add used parameters for <Modal>
[index: string]: unknown;
}
export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) {
const { t } = useTranslation();
return (
<Modal title="Join existing call?" isDismissable {...rest}>
<Modal title={t("Join existing call?")} isDismissable {...rest}>
<ModalContent>
<p>This call already exists, would you like to join?</p>
<p>{t("This call already exists, would you like to join?")}</p>
<FieldRow rightAlign className={styles.buttons}>
<Button onPress={rest.onClose}>No</Button>
<Button onPress={onJoin}>Yes, join call</Button>
<Button onPress={onClose}>{t("No")}</Button>
<Button onPress={onJoin}>{t("Yes, join call")}</Button>
</FieldRow>
</ModalContent>
</Modal>

View File

@@ -14,7 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState, useCallback } from "react";
import React, {
useState,
useCallback,
FormEvent,
FormEventHandler,
} from "react";
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 { useGroupCallRooms } from "./useGroupCallRooms";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
@@ -26,28 +35,36 @@ import { CallList } from "./CallList";
import { UserMenuContainer } from "../UserMenuContainer";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useHistory } from "react-router-dom";
import { Title } from "../typography/Typography";
import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
export function RegisteredView({ client }) {
interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
}
export function RegisteredView({ client, isPasswordlessUser }: Props) {
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [error, setError] = useState<Error>();
const history = useHistory();
const onSubmit = useCallback(
(e) => {
const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState();
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(e: FormEvent) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("callName");
const data = new FormData(e.target as HTMLFormElement);
const roomNameData = data.get("callName");
const roomName = typeof roomNameData === "string" ? roomNameData : "";
const ptt = callType === CallType.Radio;
async function submit() {
setError(undefined);
setLoading(true);
const roomIdOrAlias = await createRoom(client, roomName, ptt);
const [roomIdOrAlias] = await createRoom(client, roomName, ptt);
if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`);
@@ -64,23 +81,23 @@ export function RegisteredView({ client }) {
console.error(error);
setLoading(false);
setError(error);
reset();
}
});
},
[client, callType]
[client, history, modalState, callType]
);
const recentRooms = useGroupCallRooms(client);
const { modalState, modalProps } = useModalTriggerState();
const [existingRoomId, setExistingRoomId] = useState();
const [existingRoomId, setExistingRoomId] = useState<string>();
const onJoinExistingRoom = useCallback(() => {
history.push(`/${existingRoomId}`);
}, [history, existingRoomId]);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
callType === CallType.Video
? t("Video call name")
: t("Walkie-talkie call name");
return (
<>
@@ -114,19 +131,19 @@ export function RegisteredView({ client }) {
className={styles.button}
disabled={loading}
>
{loading ? "Loading..." : "Go"}
{loading ? t("Loading…") : t("Go")}
</Button>
</FieldRow>
{error && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{error.message}</ErrorMessage>
<ErrorMessage error={error} />
</FieldRow>
)}
</Form>
{recentRooms.length > 0 && (
<>
<Title className={styles.recentCallsTitle}>
Your recent Calls
{t("Your recent calls")}
</Title>
<CallList rooms={recentRooms} client={client} disableFacepile />
</>

View File

@@ -14,45 +14,48 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react";
import React, { FC, useCallback, useState, FormEventHandler } from "react";
import { useHistory } from "react-router-dom";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { Trans, useTranslation } from "react-i18next";
import { useClient } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { UserMenuContainer } from "../UserMenuContainer";
import { useHistory } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useRecaptcha } from "../auth/useRecaptcha";
import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Body, Caption, Link } from "../typography/Typography";
import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName";
export function UnauthenticatedView() {
export const UnauthenticatedView: FC = () => {
const { setClient } = useClient();
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [error, setError] = useState<Error>();
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const { modalState, modalProps } = useModalTriggerState();
const [onFinished, setOnFinished] = useState();
const [onFinished, setOnFinished] = useState<() => void>();
const history = useHistory();
const { t } = useTranslation();
const onSubmit = useCallback(
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("callName");
const displayName = data.get("displayName");
const data = new FormData(e.target as HTMLFormElement);
const roomName = data.get("callName") as string;
const displayName = data.get("displayName") as string;
const ptt = callType === CallType.Radio;
async function submit() {
@@ -68,12 +71,12 @@ export function UnauthenticatedView() {
true
);
let roomIdOrAlias;
let roomIdOrAlias: string;
try {
roomIdOrAlias = await createRoom(client, roomName, ptt);
[roomIdOrAlias] = await createRoom(client, roomName, ptt);
} catch (error) {
if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => () => {
setOnFinished(() => {
setClient(client, session);
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
const [, serverName] = client.getUserId().split(":");
@@ -100,11 +103,13 @@ export function UnauthenticatedView() {
reset();
});
},
[register, reset, execute, history, callType]
[register, reset, execute, history, callType, modalState, setClient]
);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
callType === CallType.Video
? t("Video call name")
: t("Walkie-talkie call name");
return (
<>
@@ -136,24 +141,26 @@ export function UnauthenticatedView() {
<InputField
id="displayName"
name="displayName"
label="Display Name"
placeholder="Display Name"
label={t("Display name")}
placeholder={t("Display name")}
type="text"
required
autoComplete="off"
/>
</FieldRow>
<Caption>
By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
<Trans>
By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Trans>
</Caption>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
<ErrorMessage error={error} />
</FieldRow>
)}
<Button type="submit" size="lg" disabled={loading}>
{loading ? "Loading..." : "Go"}
{loading ? t("Loading…") : t("Go")}
</Button>
<div id={recaptchaId} />
</Form>
@@ -161,14 +168,16 @@ export function UnauthenticatedView() {
<footer className={styles.footer}>
<Body className={styles.mobileLoginLink}>
<Link color="primary" to="/login">
Login to your account
{t("Login to your account")}
</Link>
</Body>
<Body>
Not registered yet?{" "}
<Link color="primary" to="/register">
Create an account
</Link>
<Trans>
Not registered yet?{" "}
<Link color="primary" to="/register">
Create an account
</Link>
</Trans>
</Body>
</footer>
</div>
@@ -177,4 +186,4 @@ export function UnauthenticatedView() {
)}
</>
);
}
};

View File

@@ -14,11 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
import { useState, useEffect } from "react";
const tsCache = {};
export interface GroupCallRoom {
roomId: string;
roomName: string;
avatarUrl: string;
room: Room;
groupCall: GroupCall;
participants: RoomMember[];
}
const tsCache: { [index: string]: number } = {};
function getLastTs(client, r) {
function getLastTs(client: MatrixClient, r: Room) {
if (tsCache[r.roomId]) {
return tsCache[r.roomId];
}
@@ -59,13 +72,13 @@ function getLastTs(client, r) {
return ts;
}
function sortRooms(client, rooms) {
function sortRooms(client: MatrixClient, rooms: Room[]): Room[] {
return rooms.sort((a, b) => {
return getLastTs(client, b) - getLastTs(client, a);
});
}
export function useGroupCallRooms(client) {
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
const [rooms, setRooms] = useState([]);
useEffect(() => {
@@ -90,12 +103,15 @@ export function useGroupCallRooms(client) {
updateRooms();
client.on("GroupCall.incoming", updateRooms);
client.on("GroupCall.participants", updateRooms);
client.on(GroupCallEventHandlerEvent.Incoming, updateRooms);
client.on(GroupCallEventHandlerEvent.Participants, updateRooms);
return () => {
client.removeListener("GroupCall.incoming", updateRooms);
client.removeListener("GroupCall.participants", updateRooms);
client.removeListener(GroupCallEventHandlerEvent.Incoming, updateRooms);
client.removeListener(
GroupCallEventHandlerEvent.Participants,
updateRooms
);
};
}, [client]);

View File

@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.97991 1.48403L4 4.80062L1 4.80062C0.447715 4.80062 0 5.24834 0 5.80062V10.2006C0 10.7529 0.447714 11.2006 0.999999 11.2006L4 11.2006L7.97991 14.5172C8.30557 14.7886 8.8 14.557 8.8 14.1331V1.86814C8.8 1.44422 8.30557 1.21265 7.97991 1.48403Z" fill="white"/>
<path d="M14.1258 2.79107C13.8998 2.50044 13.4809 2.44808 13.1903 2.67413C12.9 2.89992 12.8475 3.3181 13.0726 3.6087L13.0731 3.60935L13.0738 3.61021L13.0829 3.62231C13.0917 3.63418 13.1059 3.65355 13.1248 3.68011C13.1625 3.73326 13.2187 3.81496 13.2872 3.92256C13.4243 4.13812 13.6097 4.45554 13.7955 4.85371C14.169 5.65407 14.5329 6.75597 14.5329 8.00036C14.5329 9.24475 14.169 10.3466 13.7955 11.147C13.6097 11.5452 13.4243 11.8626 13.2872 12.0782C13.2187 12.1858 13.1625 12.2675 13.1248 12.3206C13.1059 12.3472 13.0917 12.3665 13.0829 12.3784L13.0738 12.3905L13.0731 12.3914L13.0725 12.3921C12.8475 12.6827 12.9 13.1008 13.1903 13.3266C13.4809 13.5526 13.8998 13.5003 14.1258 13.2097L13.629 12.8232C14.1258 13.2096 14.1258 13.2097 14.1258 13.2097L14.1272 13.2079L14.1291 13.2055L14.1346 13.1982L14.1523 13.1748C14.1669 13.1552 14.187 13.1277 14.2119 13.0926C14.2617 13.0225 14.3305 12.9221 14.4121 12.794C14.5749 12.5381 14.7895 12.1698 15.0037 11.7109C15.4302 10.7969 15.8663 9.49883 15.8663 8.00036C15.8663 6.50189 15.4302 5.20379 15.0037 4.28987C14.7895 3.83089 14.5749 3.4626 14.4121 3.20673C14.3305 3.07862 14.2617 2.97818 14.2119 2.90811C14.187 2.87306 14.1669 2.84556 14.1523 2.82596L14.1346 2.80249L14.1291 2.79525L14.1272 2.79278L14.1264 2.79183C14.1264 2.79183 14.1258 2.79107 13.5996 3.20036L14.1258 2.79107Z" fill="white"/>
<path d="M11.7264 5.19121C11.5004 4.90058 11.0815 4.84823 10.7909 5.07427C10.501 5.29973 10.4482 5.71698 10.6722 6.00752L10.6745 6.01057C10.6775 6.01457 10.6831 6.02223 10.691 6.03338C10.7069 6.05572 10.7318 6.09189 10.7628 6.14057C10.8249 6.23827 10.9103 6.38426 10.9961 6.56815C11.1696 6.93993 11.3335 7.44183 11.3335 8.00051C11.3335 8.55918 11.1696 9.06108 10.9961 9.43287C10.9103 9.61675 10.8249 9.76275 10.7628 9.86045C10.7318 9.90912 10.7069 9.94529 10.691 9.96763C10.6831 9.97879 10.6775 9.98645 10.6745 9.99044L10.6722 9.9935C10.4482 10.284 10.501 10.7013 10.7909 10.9267C11.0815 11.1528 11.5004 11.1004 11.7264 10.8098L11.2002 10.4005C11.7264 10.8098 11.7264 10.8098 11.7264 10.8098L11.7276 10.8083L11.7291 10.8064L11.7329 10.8014L11.7439 10.7868C11.7526 10.7751 11.7642 10.7593 11.7781 10.7396C11.806 10.7004 11.8436 10.6455 11.8876 10.5763C11.9755 10.4383 12.0901 10.2414 12.2043 9.99672C12.4308 9.51136 12.6669 8.81326 12.6669 8.00051C12.6669 7.18775 12.4308 6.48965 12.2043 6.0043C12.0901 5.75961 11.9755 5.56275 11.8876 5.42473C11.8436 5.35555 11.806 5.30065 11.7781 5.26138C11.7642 5.24173 11.7526 5.22596 11.7439 5.21422L11.7329 5.19964L11.7291 5.19465L11.7276 5.19274L11.727 5.19193C11.727 5.19193 11.7264 5.19121 11.2002 5.60051L11.7264 5.19121Z" fill="white"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9699 2.22605L6 7.20093L1.5 7.20093C0.671573 7.20093 0 7.8725 0 8.70093V15.3009C0 16.1294 0.671571 16.8009 1.5 16.8009L6 16.8009L11.9699 21.7758C12.4584 22.1829 13.2 21.8355 13.2 21.1996V2.80221C13.2 2.16634 12.4584 1.81897 11.9699 2.22605Z" fill="white"/>
<path d="M21.1888 4.1866C20.8497 3.75065 20.2214 3.67212 19.7855 4.01119C19.35 4.34988 19.2712 4.97715 19.6089 5.41304L19.6097 5.41402L19.6107 5.41531L19.6243 5.43347C19.6376 5.45126 19.6589 5.48033 19.6872 5.52017C19.7438 5.59988 19.828 5.72244 19.9308 5.88385C20.1365 6.20718 20.4145 6.68332 20.6932 7.28057C21.2535 8.48111 21.7994 10.134 21.7994 12.0005C21.7994 13.8671 21.2535 15.52 20.6932 16.7205C20.4145 17.3178 20.1365 17.7939 19.9308 18.1172C19.828 18.2786 19.7438 18.4012 19.6872 18.4809C19.6589 18.5208 19.6376 18.5498 19.6243 18.5676L19.6107 18.5858L19.6097 18.5871L19.6088 18.5882C19.2712 19.0241 19.3501 19.6512 19.7855 19.9899C20.2214 20.329 20.8497 20.2504 21.1888 19.8145L20.4435 19.2348C21.1888 19.8145 21.1888 19.8145 21.1888 19.8145L21.1908 19.8119L21.1936 19.8082L21.2019 19.7974L21.2284 19.7621C21.2503 19.7327 21.2805 19.6915 21.3179 19.6389C21.3925 19.5338 21.4958 19.3832 21.6181 19.191C21.8623 18.8072 22.1843 18.2547 22.5056 17.5663C23.1453 16.1954 23.7994 14.2482 23.7994 12.0005C23.7994 9.75284 23.1453 7.80569 22.5056 6.4348C22.1843 5.74634 21.8623 5.1939 21.6181 4.81009C21.4958 4.61793 21.3925 4.46727 21.3179 4.36217C21.2805 4.30959 21.2503 4.26835 21.2284 4.23893L21.2019 4.20373L21.1936 4.19288L21.1908 4.18917L21.1897 4.18774C21.1897 4.18774 21.1888 4.1866 20.3994 4.80054L21.1888 4.1866Z" fill="white"/>
<path d="M17.5896 7.78682C17.2506 7.35087 16.6223 7.27234 16.1864 7.61141C15.7515 7.94959 15.6723 8.57548 16.0083 9.01128L16.0117 9.01586C16.0162 9.02185 16.0246 9.03334 16.0365 9.05007C16.0603 9.08359 16.0977 9.13784 16.1441 9.21085C16.2374 9.3574 16.3654 9.57639 16.4941 9.85222C16.7544 10.4099 17.0003 11.1627 17.0003 12.0008C17.0003 12.8388 16.7544 13.5916 16.4941 14.1493C16.3654 14.4251 16.2374 14.6441 16.1441 14.7907C16.0977 14.8637 16.0603 14.9179 16.0365 14.9514C16.0246 14.9682 16.0162 14.9797 16.0117 14.9857L16.0083 14.9903C15.6723 15.4261 15.7515 16.0519 16.1864 16.3901C16.6223 16.7292 17.2506 16.6506 17.5896 16.2147L16.8003 15.6008C17.5896 16.2147 17.5896 16.2147 17.5896 16.2147L17.5914 16.2124L17.5936 16.2095L17.5994 16.2021L17.6158 16.1802C17.6289 16.1626 17.6463 16.1389 17.6672 16.1094C17.709 16.0505 17.7654 15.9682 17.8315 15.8644C17.9632 15.6574 18.1352 15.3621 18.3065 14.9951C18.6462 14.267 19.0003 13.2199 19.0003 12.0008C19.0003 10.7816 18.6462 9.73448 18.3065 9.00645C18.1352 8.63942 17.9632 8.34412 17.8315 8.1371C17.7654 8.03333 17.709 7.95097 17.6672 7.89207C17.6463 7.8626 17.6289 7.83893 17.6158 7.82132L17.5994 7.79946L17.5936 7.79198L17.5914 7.78911L17.5905 7.78789C17.5905 7.78789 17.5896 7.78682 16.8003 8.40076L17.5896 7.78682Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

4
src/icons/AudioLow.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9699 2.22605L6 7.20093L1.5 7.20093C0.671573 7.20093 0 7.8725 0 8.70093V15.3009C0 16.1294 0.671571 16.8009 1.5 16.8009L6 16.8009L11.9699 21.7758C12.4584 22.1829 13.2 21.8355 13.2 21.1996V2.80221C13.2 2.16634 12.4584 1.81897 11.9699 2.22605Z" fill="white"/>
<path d="M17.5896 7.78682C17.2506 7.35087 16.6223 7.27234 16.1864 7.61141C15.7515 7.94959 15.6723 8.57548 16.0083 9.01128L16.0117 9.01586C16.0162 9.02185 16.0246 9.03334 16.0365 9.05007C16.0603 9.08359 16.0977 9.13784 16.1441 9.21085C16.2374 9.3574 16.3654 9.57639 16.4941 9.85222C16.7544 10.4099 17.0003 11.1627 17.0003 12.0008C17.0003 12.8388 16.7544 13.5916 16.4941 14.1493C16.3654 14.4251 16.2374 14.6441 16.1441 14.7907C16.0977 14.8637 16.0603 14.9179 16.0365 14.9514C16.0246 14.9682 16.0162 14.9797 16.0117 14.9857L16.0083 14.9903C15.6723 15.4261 15.7515 16.0519 16.1864 16.3901C16.6223 16.7292 17.2506 16.6506 17.5896 16.2147L16.8003 15.6008C17.5896 16.2147 17.5896 16.2147 17.5896 16.2147L17.5914 16.2124L17.5936 16.2095L17.5994 16.2021L17.6158 16.1802C17.6289 16.1626 17.6463 16.1389 17.6672 16.1094C17.709 16.0505 17.7654 15.9682 17.8315 15.8644C17.9632 15.6574 18.1352 15.3621 18.3065 14.9951C18.6462 14.267 19.0003 13.2199 19.0003 12.0008C19.0003 10.7816 18.6462 9.73448 18.3065 9.00645C18.1352 8.63942 17.9632 8.34412 17.8315 8.1371C17.7654 8.03333 17.709 7.95097 17.6672 7.89207C17.6463 7.8626 17.6289 7.83893 17.6158 7.82132L17.5994 7.79946L17.5936 7.79198L17.5914 7.78911L17.5905 7.78789C17.5905 7.78789 17.5896 7.78682 16.8003 8.40076L17.5896 7.78682Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

3
src/icons/AudioMuted.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.63174 0.583224C2.15798 0.109466 1.38987 0.109466 0.91611 0.583224C0.442351 1.05698 0.442351 1.8251 0.91611 2.29885L5.3958 6.77855H5.37083L15.3629 16.7706V16.7456L20.7144 22.0972C21.1882 22.5709 21.9563 22.5709 22.4301 22.0972C22.9038 21.6234 22.9038 20.8553 22.4301 20.3816L2.63174 0.583224ZM15.3629 3.23319V9.88521L10.2275 4.74987L13.2404 2.2391C14.0833 1.53675 15.3629 2.13608 15.3629 3.23319ZM4.07191 16.8718H7.7929V16.872L13.2404 21.4116C14.0833 22.114 15.3629 21.5146 15.3629 20.4175V20.2018L2.4839 7.32287C1.87536 7.79641 1.48389 8.53577 1.48389 9.36657V14.2838C1.48389 15.7131 2.64258 16.8718 4.07191 16.8718Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

3
src/icons/Fullscreen.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 8.59V4C21 3.45 20.55 3 20 3H15.41C14.52 3 14.07 4.08 14.7 4.71L16.29 6.3L6.29 16.3L4.7 14.71C4.08 14.08 3 14.52 3 15.41V20C3 20.55 3.45 21 4 21H8.59C9.48 21 9.93 19.92 9.3 19.29L7.71 17.7L17.71 7.7L19.3 9.29C19.92 9.92 21 9.48 21 8.59Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.29 4.12L16.7 8.71L18.29 10.3C18.92 10.93 18.47 12.01 17.58 12.01H13C12.45 12.01 12 11.56 12 11.01V6.41C12 5.52 13.08 5.07 13.71 5.7L15.3 7.29L19.89 2.7C20.28 2.31 20.91 2.31 21.3 2.7C21.68 3.1 21.68 3.73 21.29 4.12ZM4.11997 21.29L8.70997 16.7L10.3 18.29C10.93 18.92 12.01 18.47 12.01 17.58V13C12.01 12.45 11.56 12 11.01 12H6.40997C5.51997 12 5.06997 13.08 5.69997 13.71L7.28997 15.3L2.69997 19.89C2.30997 20.28 2.30997 20.91 2.69997 21.3C3.09997 21.68 3.72997 21.68 4.11997 21.29Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 613 B

View File

@@ -38,6 +38,7 @@ limitations under the License.
--quinary-content: #394049;
--system: #21262c;
--background: #15191e;
--background-85: rgba(23, 25, 28, 0.85);
--bgColor3: #444; /* This isn't found anywhere in the designs or Compound */
}

View File

@@ -15,42 +15,55 @@ limitations under the License.
*/
import { useObjectRef } from "@react-aria/utils";
import React, { useEffect } from "react";
import React, { AllHTMLAttributes, useEffect } from "react";
import { useCallback } from "react";
import { useState } from "react";
import { forwardRef } from "react";
import { Avatar } from "../Avatar";
import { Button } from "../button";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { Avatar, Size } from "../Avatar";
import { Button } from "../button";
import { ReactComponent as EditIcon } from "../icons/Edit.svg";
import styles from "./AvatarInputField.module.css";
export const AvatarInputField = forwardRef(
interface Props extends AllHTMLAttributes<HTMLInputElement> {
id: string;
label: string;
avatarUrl: string;
displayName: string;
onRemoveAvatar: () => void;
}
export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, className, avatarUrl, displayName, onRemoveAvatar, ...rest },
ref
) => {
const { t } = useTranslation();
const [removed, setRemoved] = useState(false);
const [objUrl, setObjUrl] = useState(null);
const [objUrl, setObjUrl] = useState<string>(null);
const fileInputRef = useObjectRef(ref);
useEffect(() => {
const onChange = (e) => {
if (e.target.files.length > 0) {
setObjUrl(URL.createObjectURL(e.target.files[0]));
const currentInput = fileInputRef.current;
const onChange = (e: Event) => {
const inputEvent = e as unknown as React.ChangeEvent<HTMLInputElement>;
if (inputEvent.target.files.length > 0) {
setObjUrl(URL.createObjectURL(inputEvent.target.files[0]));
setRemoved(false);
} else {
setObjUrl(null);
}
};
fileInputRef.current.addEventListener("change", onChange);
currentInput.addEventListener("change", onChange);
return () => {
if (fileInputRef.current) {
fileInputRef.current.removeEventListener("change", onChange);
}
currentInput?.removeEventListener("change", onChange);
};
});
@@ -63,7 +76,7 @@ export const AvatarInputField = forwardRef(
<div className={classNames(styles.avatarInputField, className)}>
<div className={styles.avatarContainer}>
<Avatar
size="xl"
size={Size.XL}
src={removed ? null : objUrl || avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
@@ -87,7 +100,7 @@ export const AvatarInputField = forwardRef(
variant="icon"
onPress={onPressRemoveAvatar}
>
Remove
{t("Remove")}
</Button>
)}
</div>

View File

@@ -14,12 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef } from "react";
import React, { ChangeEvent, FC, forwardRef, ReactNode } from "react";
import classNames from "classnames";
import styles from "./Input.module.css";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import { TranslatedError } from "../TranslatedError";
export function FieldRow({ children, rightAlign, className, ...rest }) {
interface FieldRowProps {
children: ReactNode;
rightAlign?: boolean;
className?: string;
}
export function FieldRow({
children,
rightAlign,
className,
}: FieldRowProps): JSX.Element {
return (
<div
className={classNames(
@@ -33,11 +45,42 @@ export function FieldRow({ children, rightAlign, className, ...rest }) {
);
}
export function Field({ children, className, ...rest }) {
interface FieldProps {
children: ReactNode;
className?: string;
}
export function Field({ children, className }: FieldProps): JSX.Element {
return <div className={classNames(styles.field, className)}>{children}</div>;
}
export const InputField = forwardRef(
interface InputFieldProps {
label: string;
type: string;
prefix?: string;
suffix?: string;
id?: string;
checked?: boolean;
className?: string;
description?: string;
disabled?: boolean;
required?: boolean;
// this is a hack. Those variables should be part of `HTMLAttributes<HTMLInputElement> | HTMLAttributes<HTMLTextAreaElement>`
// but extending from this union type does not work
name?: string;
autoComplete?: string;
autoCorrect?: string;
autoCapitalize?: string;
value?: string;
placeholder?: string;
defaultChecked?: boolean;
onChange?: (event: ChangeEvent) => void;
}
export const InputField = forwardRef<
HTMLInputElement | HTMLTextAreaElement,
InputFieldProps
>(
(
{
id,
@@ -68,19 +111,18 @@ export const InputField = forwardRef(
{type === "textarea" ? (
<textarea
id={id}
{...rest}
ref={ref}
type={type}
ref={ref as React.ForwardedRef<HTMLTextAreaElement>}
disabled={disabled}
{...rest}
/>
) : (
<input
id={id}
{...rest}
ref={ref}
ref={ref as React.ForwardedRef<HTMLInputElement>}
type={type}
checked={checked}
disabled={disabled}
{...rest}
/>
)}
@@ -99,6 +141,12 @@ export const InputField = forwardRef(
}
);
export function ErrorMessage({ children }) {
return <p className={styles.errorMessage}>{children}</p>;
interface ErrorMessageProps {
error: Error;
}
export const ErrorMessage: FC<ErrorMessageProps> = ({ error }) => (
<p className={styles.errorMessage}>
{error instanceof TranslatedError ? error.translatedMessage : error.message}
</p>
);

View File

@@ -15,16 +15,23 @@ limitations under the License.
*/
import React, { useRef } from "react";
import { HiddenSelect, useSelect } from "@react-aria/select";
import { AriaSelectOptions, HiddenSelect, useSelect } from "@react-aria/select";
import { useButton } from "@react-aria/button";
import { useSelectState } from "@react-stately/select";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { Popover } from "../popover/Popover";
import { ListBox } from "../ListBox";
import styles from "./SelectInput.module.css";
import classNames from "classnames";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
export function SelectInput(props) {
interface Props extends AriaSelectOptions<object> {
className?: string;
}
export function SelectInput(props: Props): JSX.Element {
const { t } = useTranslation();
const state = useSelectState(props);
const ref = useRef();
@@ -51,7 +58,7 @@ export function SelectInput(props) {
<span {...valueProps} className={styles.selectedItem}>
{state.selectedItem
? state.selectedItem.rendered
: "Select an option"}
: t("Select an option")}
</span>
<ArrowDownIcon />
</button>

View File

@@ -15,22 +15,37 @@ limitations under the License.
*/
import React, { useCallback, useRef } from "react";
import styles from "./Toggle.module.css";
import { useToggleButton } from "@react-aria/button";
import classNames from "classnames";
import styles from "./Toggle.module.css";
import { Field } from "./Input";
export function Toggle({ id, label, className, onChange, isSelected }) {
const buttonRef = useRef();
interface Props {
id: string;
label: string;
onChange: (selected: boolean) => void;
isSelected: boolean;
className?: string;
}
export function Toggle({
id,
label,
className,
onChange,
isSelected,
}: Props): JSX.Element {
const buttonRef = useRef<HTMLButtonElement>();
const toggle = useCallback(() => {
onChange(!isSelected);
});
const { buttonProps } = useToggleButton(
}, [isSelected, onChange]);
const buttonProps = useToggleButton(
{ isSelected },
{ toggle },
{ isSelected: isSelected, setSelected: undefined, toggle },
buttonRef
);
return (
<Field
className={classNames(

View File

@@ -25,12 +25,15 @@ import ReactDOM from "react-dom";
import { createBrowserHistory } from "history";
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import "./index.css";
import App from "./App";
import { ErrorView } from "./FullScreenView";
import { init as initRageshake } from "./settings/rageshake";
import { InspectorContextProvider } from "./room/GroupCallInspector";
import { getUrlParams } from "./UrlParams";
initRageshake();
@@ -86,6 +89,10 @@ if (import.meta.env.VITE_CUSTOM_THEME) {
"--background",
import.meta.env.VITE_THEME_BACKGROUND as string
);
style.setProperty(
"--background-85",
import.meta.env.VITE_THEME_BACKGROUND_85 as string
);
}
const history = createBrowserHistory();
@@ -102,13 +109,38 @@ Sentry.init({
tracesSampleRate: 1.0,
});
const languageDetector = new LanguageDetector();
languageDetector.addDetector({
name: "urlFragment",
// Look for a language code in the URL's fragment
lookup: () => getUrlParams().lang ?? undefined,
});
i18n
.use(Backend)
.use(languageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en-GB",
defaultNS: "app",
keySeparator: false,
nsSeparator: false,
pluralSeparator: "|",
contextSeparator: "|",
interpolation: {
escapeValue: false, // React has built-in XSS protections
},
detection: {
// No localStorage detectors or caching here, since we don't have any way
// of letting the user manually select a language
order: ["urlFragment", "navigator"],
caches: [],
},
});
ReactDOM.render(
<React.StrictMode>
<Sentry.ErrorBoundary fallback={ErrorView}>
<InspectorContextProvider>
<App history={history} />
</InspectorContextProvider>
</Sentry.ErrorBoundary>
<App history={history} />
</React.StrictMode>,
document.getElementById("root")
);

View File

@@ -5,18 +5,21 @@ import { MemoryStore } from "matrix-js-sdk/src/store/memory";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import { createClient } from "matrix-js-sdk/src/matrix";
import { ICreateClientOpts } from "matrix-js-sdk/src/matrix";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { logger } from "matrix-js-sdk/src/logger";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { logger } from "matrix-js-sdk/src/logger";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room";
import IndexedDBWorker from "./IndexedDBWorker?worker";
import { getUrlParams } from "./UrlParams";
export const defaultHomeserver =
(import.meta.env.VITE_DEFAULT_HOMESERVER as string) ??
@@ -24,6 +27,19 @@ export const defaultHomeserver =
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
export class CryptoStoreIntegrityError extends Error {
constructor() {
super("Crypto store data was expected, but none was found");
}
}
const SYNC_STORE_NAME = "element-call-sync";
// Note that the crypto store name has changed from previous versions
// deliberately in order to force a logout for all users due to
// https://github.com/vector-im/element-call/issues/464
// (It's a good opportunity to make the database names consistent.)
const CRYPTO_STORE_NAME = "element-call-crypto";
function waitForSync(client: MatrixClient) {
return new Promise<void>((resolve, reject) => {
const onSync = (
@@ -43,8 +59,18 @@ function waitForSync(client: MatrixClient) {
});
}
/**
* Initialises and returns a new standalone Matrix Client.
* If true is passed for the 'restore' parameter, a check will be made
* to ensure that corresponding crypto data is stored and recovered.
* If the check fails, CryptoStoreIntegrityError will be thrown.
* @param clientOptions Object of options passed through to the client
* @param restore Whether the session is being restored from storage
* @returns The MatrixClient instance
*/
export async function initClient(
clientOptions: ICreateClientOpts
clientOptions: ICreateClientOpts,
restore: boolean
): Promise<MatrixClient> {
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
window.OLM_OPTIONS = {};
@@ -62,17 +88,45 @@ export async function initClient(
storeOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage,
dbName: "element-call-sync",
dbName: SYNC_STORE_NAME,
workerFactory: () => new IndexedDBWorker(),
});
} else if (localStorage) {
storeOpts.store = new MemoryStore({ localStorage });
}
// Check whether we have crypto data store. If we are restoring a session
// from storage then we will have started the crypto store and therefore
// have generated keys for that device, so if we can't recover those keys,
// we must not continue or we'll generate new keys and anyone who saw our
// previous keys will not accept our new key.
// It's worth mentioning here that if support for indexeddb or localstorage
// appears or disappears between sessions (it happens) then the failure mode
// here will be that we'll try a different store, not find crypto data and
// fail to restore the session. An alternative would be to continue using
// whatever we were using before, but that could be confusing since you could
// enable indexeddb and but the app would still not be using it.
if (restore) {
if (indexedDB) {
const cryptoStoreExists = await IndexedDBCryptoStore.exists(
indexedDB,
CRYPTO_STORE_NAME
);
if (!cryptoStoreExists) throw new CryptoStoreIntegrityError();
} else if (localStorage) {
if (!LocalStorageCryptoStore.exists(localStorage))
throw new CryptoStoreIntegrityError();
} else {
// if we get here then we're using the memory store, which cannot
// possibly have remembered a session, so it's an error.
throw new CryptoStoreIntegrityError();
}
}
if (indexedDB) {
storeOpts.cryptoStore = new IndexedDBCryptoStore(
indexedDB,
"matrix-js-sdk:crypto"
CRYPTO_STORE_NAME
);
} else if (localStorage) {
storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
@@ -80,16 +134,13 @@ export async function initClient(
storeOpts.cryptoStore = new MemoryCryptoStore();
}
// XXX: we read from the URL search params in RoomPage too:
// XXX: we read from the URL params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const params = new URLSearchParams(window.location.search);
// disable e2e only if enableE2e=false is given
const enableE2e = params.get("enableE2e") !== "false";
if (!enableE2e) {
const { e2eEnabled } = getUrlParams();
if (!e2eEnabled) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
@@ -97,10 +148,10 @@ export async function initClient(
...storeOpts,
...clientOptions,
useAuthorizationHeader: true,
// Use a relatively low timeout for API calls: this is a realtime application
// Use a relatively low timeout for API calls: this is a realtime app
// so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000,
useE2eForGroupCall: enableE2e,
useE2eForGroupCall: e2eEnabled,
});
try {
@@ -173,9 +224,9 @@ export function isLocalRoomId(roomId: string): boolean {
export async function createRoom(
client: MatrixClient,
name: string,
isPtt = false
): Promise<string> {
const createRoomResult = await client.createRoom({
ptt: boolean
): Promise<[string, string]> {
const createPromise = client.createRoom({
visibility: Visibility.Private,
preset: Preset.PublicChat,
name,
@@ -205,29 +256,50 @@ export async function createRoom(
},
});
console.log(`Creating ${isPtt ? "PTT" : "video"} group call room`);
// Wait for the room to arrive
await new Promise<void>((resolve, reject) => {
const onRoom = async (room: Room) => {
if (room.roomId === (await createPromise).room_id) {
resolve();
cleanUp();
}
};
createPromise.catch((e) => {
reject(e);
cleanUp();
});
const cleanUp = () => {
client.off(ClientEvent.Room, onRoom);
};
client.on(ClientEvent.Room, onRoom);
});
const result = await createPromise;
console.log(`Creating ${ptt ? "PTT" : "video"} group call room`);
await client.createGroupCall(
createRoomResult.room_id,
isPtt ? GroupCallType.Voice : GroupCallType.Video,
isPtt,
GroupCallIntent.Prompt
result.room_id,
ptt ? GroupCallType.Voice : GroupCallType.Video,
ptt,
GroupCallIntent.Room
);
return fullAliasFromRoomName(name, client);
return [fullAliasFromRoomName(name, client), result.room_id];
}
export function getRoomUrl(roomId: string): string {
if (roomId.startsWith("#")) {
const [localPart, host] = roomId.replace("#", "").split(":");
export function getRoomUrl(roomIdOrAlias: string): string {
if (roomIdOrAlias.startsWith("#")) {
const [localPart, host] = roomIdOrAlias.replace("#", "").split(":");
if (host !== defaultHomeserverHost) {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
return `${window.location.protocol}//${window.location.host}/room/${roomIdOrAlias}`;
} else {
return `${window.location.protocol}//${window.location.host}/${localPart}`;
}
} else {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
return `${window.location.protocol}//${window.location.host}/room/#?roomId=${roomIdOrAlias}`;
}
}

71
src/media-utils.ts Normal file
View File

@@ -0,0 +1,71 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
/**
* Finds a media device with label matching 'deviceName'
* @param deviceName The label of the device to look for
* @param devices The list of devices to search
* @returns A matching media device or undefined if no matching device was found
*/
export async function findDeviceByName(
deviceName: string,
kind: MediaDeviceKind,
devices: MediaDeviceInfo[]
): Promise<string | undefined> {
const deviceInfo = devices.find(
(d) => d.kind === kind && d.label === deviceName
);
return deviceInfo?.deviceId;
}
/**
* Gets the available audio input/output and video input devices
* from the browser: a wrapper around mediaDevices.enumerateDevices()
* that requests a stream and holds it while calling enumerateDevices().
* This is because some browsers (Firefox) only return device labels when
* the app has an active user media stream. In Chrome, this will get a
* stream from the default camera which can mean, for example, that the
* light for the FaceTime camera turns on briefly even if you selected
* another camera. Once the Permissions API
* (https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API)
* is ready for primetime, this should allow us to avoid this.
*
* @return The available media devices
*/
export async function getDevices(): Promise<MediaDeviceInfo[]> {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
} catch (e) {
logger.info("Couldn't get media stream for enumerateDevices: failing");
throw e;
}
try {
return await navigator.mediaDevices.enumerateDevices();
} catch (error) {
logger.warn("Unable to refresh WebRTC Devices: ", error);
} finally {
for (const track of stream.getTracks()) {
track.stop();
}
}
}

View File

@@ -14,14 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, useRef } from "react";
import React, { forwardRef, HTMLAttributes } from "react";
import { DismissButton, useOverlay } from "@react-aria/overlays";
import { FocusScope } from "@react-aria/focus";
import classNames from "classnames";
import styles from "./Popover.module.css";
import { useObjectRef } from "@react-aria/utils";
export const Popover = forwardRef(
import styles from "./Popover.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
isOpen: boolean;
onClose: () => void;
className?: string;
children?: JSX.Element;
}
export const Popover = forwardRef<HTMLDivElement, Props>(
({ isOpen = true, onClose, className, children, ...rest }, ref) => {
const popoverRef = useObjectRef(ref);

View File

@@ -1,84 +0,0 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, useRef } from "react";
import styles from "./PopoverMenu.module.css";
import { useMenuTriggerState } from "@react-stately/menu";
import { useMenuTrigger } from "@react-aria/menu";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import classNames from "classnames";
import { Popover } from "./Popover";
export const PopoverMenuTrigger = forwardRef(
({ children, placement, className, disableOnState, ...rest }, ref) => {
const popoverMenuState = useMenuTriggerState(rest);
const buttonRef = useObjectRef(ref);
const { menuTriggerProps, menuProps } = useMenuTrigger(
{},
popoverMenuState,
buttonRef
);
const popoverRef = useRef();
const { overlayProps } = useOverlayPosition({
targetRef: buttonRef,
overlayRef: popoverRef,
placement: placement || "top",
offset: 5,
isOpen: popoverMenuState.isOpen,
});
if (
!Array.isArray(children) ||
children.length > 2 ||
typeof children[1] !== "function"
) {
throw new Error(
"PopoverMenu must have two props. The first being a button and the second being a render prop."
);
}
const [popoverTrigger, popoverMenu] = children;
return (
<div className={classNames(styles.popoverMenuTrigger, className)}>
<popoverTrigger.type
{...mergeProps(popoverTrigger.props, menuTriggerProps)}
on={!disableOnState && popoverMenuState.isOpen}
ref={buttonRef}
/>
{popoverMenuState.isOpen && (
<OverlayContainer>
<Popover
{...overlayProps}
isOpen={popoverMenuState.isOpen}
onClose={popoverMenuState.close}
ref={popoverRef}
>
{popoverMenu({
...menuProps,
autoFocus: popoverMenuState.focusStrategy,
onClose: popoverMenuState.close,
})}
</Popover>
</OverlayContainer>
)}
</div>
);
}
);

View File

@@ -0,0 +1,96 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, useRef } from "react";
import { useMenuTriggerState } from "@react-stately/menu";
import { useMenuTrigger } from "@react-aria/menu";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import classNames from "classnames";
import { MenuTriggerProps } from "@react-types/menu";
import { Placement } from "@react-types/overlays";
import styles from "./PopoverMenu.module.css";
import { Popover } from "./Popover";
interface PopoverMenuTriggerProps extends MenuTriggerProps {
children: JSX.Element;
placement: Placement;
className: string;
disableOnState: boolean;
[index: string]: unknown;
}
export const PopoverMenuTrigger = forwardRef<
HTMLDivElement,
PopoverMenuTriggerProps
>(({ children, placement, className, disableOnState, ...rest }, ref) => {
const popoverMenuState = useMenuTriggerState(rest);
const buttonRef = useObjectRef(ref);
const { menuTriggerProps, menuProps } = useMenuTrigger(
{},
popoverMenuState,
buttonRef
);
const popoverRef = useRef();
const { overlayProps } = useOverlayPosition({
targetRef: buttonRef,
overlayRef: popoverRef,
placement: placement || "top",
offset: 5,
isOpen: popoverMenuState.isOpen,
});
if (
!Array.isArray(children) ||
children.length > 2 ||
typeof children[1] !== "function"
) {
throw new Error(
"PopoverMenu must have two props. The first being a button and the second being a render prop."
);
}
const [popoverTrigger, popoverMenu] = children;
return (
<div className={classNames(styles.popoverMenuTrigger, className)}>
<popoverTrigger.type
{...mergeProps(popoverTrigger.props, menuTriggerProps)}
on={!disableOnState && popoverMenuState.isOpen}
ref={buttonRef}
/>
{popoverMenuState.isOpen && (
<OverlayContainer>
<Popover
{...overlayProps}
isOpen={popoverMenuState.isOpen}
onClose={popoverMenuState.close}
ref={popoverRef}
>
{popoverMenu({
...menuProps,
autoFocus: popoverMenuState.focusStrategy,
onClose: popoverMenuState.close,
})}
</Popover>
</OverlayContainer>
)}
</div>
);
});

View File

@@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState } from "react";
import { MatrixClient } from "matrix-js-sdk";
import React, { ChangeEvent, useCallback, useEffect, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { Button } from "../button";
import { useProfile } from "./useProfile";
@@ -26,11 +27,12 @@ import styles from "./ProfileModal.module.css";
interface Props {
client: MatrixClient;
onClose: () => {};
onClose: () => void;
[rest: string]: unknown;
}
export function ProfileModal({ client, ...rest }: Props) {
const { onClose } = rest;
const { t } = useTranslation();
const {
success,
error,
@@ -47,7 +49,7 @@ export function ProfileModal({ client, ...rest }: Props) {
}, []);
const onChangeDisplayName = useCallback(
(e) => {
(e: ChangeEvent<HTMLInputElement>) => {
setDisplayName(e.target.value);
},
[setDisplayName]
@@ -83,14 +85,14 @@ export function ProfileModal({ client, ...rest }: Props) {
}, [success, onClose]);
return (
<Modal title="Profile" isDismissable {...rest}>
<Modal title={t("Profile")} isDismissable {...rest}>
<ModalContent>
<form onSubmit={onSubmit}>
<FieldRow className={styles.avatarFieldRow}>
<AvatarInputField
id="avatar"
name="avatar"
label="Avatar"
label={t("Avatar")}
avatarUrl={avatarUrl}
displayName={displayName}
onRemoveAvatar={onRemoveAvatar}
@@ -100,7 +102,7 @@ export function ProfileModal({ client, ...rest }: Props) {
<InputField
id="userId"
name="userId"
label="User Id"
label={t("User ID")}
type="text"
disabled
value={client.getUserId()}
@@ -110,18 +112,18 @@ export function ProfileModal({ client, ...rest }: Props) {
<InputField
id="displayName"
name="displayName"
label="Display Name"
label={t("Display name")}
type="text"
required
autoComplete="off"
placeholder="Display Name"
placeholder={t("Display name")}
value={displayName}
onChange={onChangeDisplayName}
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow rightAlign>
@@ -129,7 +131,7 @@ export function ProfileModal({ client, ...rest }: Props) {
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Saving..." : "Save"}
{loading ? t("Saving…") : t("Save")}
</Button>
</FieldRow>
</form>

View File

@@ -101,7 +101,9 @@ export function useProfile(client: MatrixClient) {
if (removeAvatar) {
await client.setAvatarUrl("");
} else if (avatar) {
mxcAvatarUrl = await client.uploadContent(avatar);
({ content_uri: mxcAvatarUrl } = await client.uploadContent(
avatar
));
await client.setAvatarUrl(mxcAvatarUrl);
}

View File

@@ -15,12 +15,25 @@ limitations under the License.
*/
import React from "react";
import styles from "./AudioPreview.module.css";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";
import styles from "./AudioPreview.module.css";
import { SelectInput } from "../input/SelectInput";
import { Body } from "../typography/Typography";
interface Props {
state: GroupCallState;
roomName: string;
audioInput: string;
audioInputs: MediaDeviceInfo[];
setAudioInput: (deviceId: string) => void;
audioOutput: string;
audioOutputs: MediaDeviceInfo[];
setAudioOutput: (deviceId: string) => void;
}
export function AudioPreview({
state,
roomName,
@@ -30,42 +43,52 @@ export function AudioPreview({
audioOutput,
audioOutputs,
setAudioOutput,
}) {
}: Props) {
const { t } = useTranslation();
return (
<>
<h1>{`${roomName} - Walkie-talkie call`}</h1>
<h1>{t("{{roomName}} - Walkie-talkie call", { roomName })}</h1>
<div className={styles.preview}>
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
Microphone permissions needed to join the call.
{t("Microphone permissions needed to join the call.")}
</Body>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
Accept microphone permissions to join the call.
{t("Accept microphone permissions to join the call.")}
</Body>
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<>
<SelectInput
label="Microphone"
label={t("Microphone")}
selectedKey={audioInput}
onSelectionChange={setAudioInput}
className={styles.inputField}
>
{audioInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{audioInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: t("Microphone {{n}}", { n: index + 1 })}
</Item>
))}
</SelectInput>
{audioOutputs.length > 0 && (
<SelectInput
label="Speaker"
label={t("Speaker")}
selectedKey={audioOutput}
onSelectionChange={setAudioOutput}
className={styles.inputField}
>
{audioOutputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{audioOutputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: t("Speaker {{n}}", { n: index + 1 })}
</Item>
))}
</SelectInput>
)}

View File

@@ -15,13 +15,17 @@ limitations under the License.
*/
import React from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Trans, useTranslation } from "react-i18next";
import styles from "./CallEndedView.module.css";
import { LinkButton } from "../button";
import { useProfile } from "../profile/useProfile";
import { Subtitle, Body, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
export function CallEndedView({ client }) {
export function CallEndedView({ client }: { client: MatrixClient }) {
const { t } = useTranslation();
const { displayName } = useProfile(client);
return (
@@ -35,29 +39,31 @@ export function CallEndedView({ client }) {
<div className={styles.container}>
<main className={styles.main}>
<Headline className={styles.headline}>
{displayName}, your call is now ended
{t("{{displayName}}, your call is now ended", { displayName })}
</Headline>
<div className={styles.callEndedContent}>
<Subtitle>
Why not finish by setting up a password to keep your account?
</Subtitle>
<Subtitle>
You'll be able to keep your name and set an avatar for use on
future calls
</Subtitle>
<Trans>
<Subtitle>
Why not finish by setting up a password to keep your account?
</Subtitle>
<Subtitle>
You'll be able to keep your name and set an avatar for use on
future calls
</Subtitle>
</Trans>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/register"
>
Create account
{t("Create account")}
</LinkButton>
</div>
</main>
<Body className={styles.footer}>
<Link color="primary" to="/">
Not now, return to home screen
{t("Not now, return to home screen")}
</Link>
</Body>
</div>

View File

@@ -15,6 +15,9 @@ limitations under the License.
*/
import React, { useCallback, useEffect } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent } from "../Modal";
import { Button } from "../button";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
@@ -23,9 +26,17 @@ import {
useRageshakeRequest,
} from "../settings/submit-rageshake";
import { Body } from "../typography/Typography";
import { randomString } from "matrix-js-sdk/src/randomstring";
export function FeedbackModal({ inCall, roomId, ...rest }) {
interface Props {
inCall: boolean;
roomId: string;
onClose?: () => void;
// TODO: add all props for for <Modal>
[index: string]: unknown;
}
export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const sendRageshakeRequest = useRageshakeRequest();
@@ -33,8 +44,10 @@ export function FeedbackModal({ inCall, roomId, ...rest }) {
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const description = data.get("description");
const sendLogs = data.get("sendLogs");
const descriptionData = data.get("description");
const description =
typeof descriptionData === "string" ? descriptionData : "";
const sendLogs = Boolean(data.get("sendLogs"));
const rageshakeRequestId = randomString(16);
submitRageshake({
@@ -53,20 +66,25 @@ export function FeedbackModal({ inCall, roomId, ...rest }) {
useEffect(() => {
if (sent) {
rest.onClose();
onClose();
}
}, [sent, rest.onClose]);
}, [sent, onClose]);
return (
<Modal title="Submit Feedback" isDismissable {...rest}>
<Modal
title={t("Submit feedback")}
isDismissable
onClose={onClose}
{...rest}
>
<ModalContent>
<Body>Having trouble? Help us fix it.</Body>
<Body>{t("Having trouble? Help us fix it.")}</Body>
<form onSubmit={onSubmitFeedback}>
<FieldRow>
<InputField
id="description"
name="description"
label="Description (optional)"
label={t("Description (optional)")}
type="textarea"
/>
</FieldRow>
@@ -74,19 +92,19 @@ export function FeedbackModal({ inCall, roomId, ...rest }) {
<InputField
id="sendLogs"
name="sendLogs"
label="Include Debug Logs"
label={t("Include debug logs")}
type="checkbox"
defaultChecked
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={sending}>
{sending ? "Submitting feedback..." : "Submit Feedback"}
{sending ? t("Submitting feedback…") : t("Submit feedback")}
</Button>
</FieldRow>
</form>

View File

@@ -14,7 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { useCallback } from "react";
import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";
import { Button } from "../button";
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { ReactComponent as SpotlightIcon } from "../icons/Spotlight.svg";
@@ -22,28 +25,36 @@ import { ReactComponent as FreedomIcon } from "../icons/Freedom.svg";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import menuStyles from "../Menu.module.css";
import { Menu } from "../Menu";
import { Item } from "@react-stately/collections";
import { Tooltip, TooltipTrigger } from "../Tooltip";
import { TooltipTrigger } from "../Tooltip";
export type Layout = "freedom" | "spotlight";
interface Props {
layout: Layout;
setLayout: (layout: Layout) => void;
}
export function GridLayoutMenu({ layout, setLayout }: Props) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Change layout"), [t]);
export function GridLayoutMenu({ layout, setLayout }) {
return (
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger>
<TooltipTrigger tooltip={tooltip}>
<Button variant="icon">
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
</Button>
{() => "Layout Type"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="Grid layout menu" onAction={setLayout}>
<Item key="freedom" textValue="Freedom">
{(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label={t("Grid layout menu")} onAction={setLayout}>
<Item key="freedom" textValue={t("Freedom")}>
<FreedomIcon />
<span>Freedom</span>
{layout === "freedom" && (
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item>
<Item key="spotlight" textValue="Spotlight">
<Item key="spotlight" textValue={t("Spotlight")}>
<SpotlightIcon />
<span>Spotlight</span>
{layout === "spotlight" && (

View File

@@ -23,39 +23,25 @@ import React, {
createContext,
useContext,
} from "react";
import ReactJson from "react-json-view";
import ReactJson, { CollapsedFieldProps } from "react-json-view";
import mermaid from "mermaid";
import { Item } from "@react-stately/collections";
import { MatrixEvent, IContent } from "matrix-js-sdk/src/models/event";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { CallEvent } from "matrix-js-sdk/src/webrtc/call";
import styles from "./GroupCallInspector.module.css";
import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections";
function getCallUserId(call) {
return call.getOpponentMember()?.userId || call.invitee || null;
interface InspectorContextState {
eventsByUserId?: { [userId: string]: SequenceDiagramMatrixEvent[] };
remoteUserIds?: string[];
localUserId?: string;
localSessionId?: string;
}
function getCallState(call) {
return {
id: call.callId,
opponentMemberId: getCallUserId(call),
state: call.state,
direction: call.direction,
};
}
function getHangupCallState(call) {
return {
...getCallState(call),
hangupReason: call.hangupReason,
};
}
const dateFormatter = new Intl.DateTimeFormat([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
});
const defaultCollapsedFields = [
"org.matrix.msc3401.call",
"org.matrix.msc3401.call.member",
@@ -67,19 +53,19 @@ const defaultCollapsedFields = [
"content",
];
function shouldCollapse({ name, src, type, namespace }) {
function shouldCollapse({ name }: CollapsedFieldProps) {
return defaultCollapsedFields.includes(name);
}
function getUserName(userId) {
const match = userId.match(/@([^\:]+):/);
function getUserName(userId: string) {
const match = userId.match(/@([^:]+):/);
return match && match.length > 0
? match[1].replace("-", " ").replace(/\W/g, "")
: userId.replace(/\W/g, "");
}
function formatContent(type, content) {
function formatContent(type: string, content: CallEventContent) {
if (type === "m.call.hangup") {
return `callId: ${content.call_id.slice(-4)} reason: ${
content.reason
@@ -109,14 +95,38 @@ function formatContent(type, content) {
}
}
function formatTimestamp(timestamp) {
const dateFormatter = new Intl.DateTimeFormat([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore the linter does not know about this property of the DataTimeFormatOptions
fractionalSecondDigits: 3,
});
function formatTimestamp(timestamp: number | Date) {
return dateFormatter.format(timestamp);
}
export const InspectorContext = createContext();
export const InspectorContext =
createContext<
[
InspectorContextState,
React.Dispatch<React.SetStateAction<InspectorContextState>>
]
>(undefined);
export function InspectorContextProvider({ children }) {
const context = useState({});
export function InspectorContextProvider({
children,
}: {
children: React.ReactNode;
}) {
// We take the tuple of [currentState, setter] and stick
// it straight into the context for other things to call
// the setState method... this feels like a fairly severe
// contortion of the hooks API - is this really the best way
// to do this?
const context = useState<InspectorContextState>({});
return (
<InspectorContext.Provider value={context}>
{children}
@@ -124,14 +134,43 @@ export function InspectorContextProvider({ children }) {
);
}
type CallEventContent = {
["m.calls"]: {
["m.devices"]: { session_id: string; [x: string]: unknown }[];
["m.call_id"]: string;
}[];
} & {
call_id: string;
reason: string;
sender_session_id: string;
dest_session_id: string;
} & IContent;
export type SequenceDiagramMatrixEvent = {
to: string;
from: string;
timestamp: number;
type: string;
content: CallEventContent;
ignored: boolean;
};
interface SequenceDiagramViewerProps {
localUserId: string;
remoteUserIds: string[];
selectedUserId: string;
onSelectUserId: (userId: string) => void;
events: SequenceDiagramMatrixEvent[];
}
export function SequenceDiagramViewer({
localUserId,
remoteUserIds,
selectedUserId,
onSelectUserId,
events,
}) {
const mermaidElRef = useRef();
}: SequenceDiagramViewerProps) {
const mermaidElRef = useRef<HTMLDivElement>();
useEffect(() => {
mermaid.initialize({
@@ -165,7 +204,7 @@ export function SequenceDiagramViewer({
}
`;
mermaid.mermaidAPI.render("mermaid", graphDefinition, (svgCode) => {
mermaid.mermaidAPI.render("mermaid", graphDefinition, (svgCode: string) => {
mermaidElRef.current.innerHTML = svgCode;
});
}, [events, localUserId, selectedUserId]);
@@ -190,9 +229,18 @@ export function SequenceDiagramViewer({
);
}
function reducer(state, action) {
function reducer(
state: InspectorContextState,
action: {
type?: CallEvent | ClientEvent | RoomStateEvent;
event?: MatrixEvent;
rawEvent?: Record<string, unknown>;
callStateEvent?: MatrixEvent;
memberStateEvents?: MatrixEvent[];
}
) {
switch (action.type) {
case "receive_room_state_event": {
case RoomStateEvent.Events: {
const { event, callStateEvent, memberStateEvents } = action;
let eventsByUserId = state.eventsByUserId;
@@ -247,12 +295,12 @@ function reducer(state, action) {
),
};
}
case "received_voip_event": {
case ClientEvent.ReceivedVoipEvent: {
const event = action.event;
const eventsByUserId = { ...state.eventsByUserId };
const fromId = event.getSender();
const toId = state.localUserId;
const content = event.getContent();
const content = event.getContent<CallEventContent>();
const remoteUserIds = eventsByUserId[fromId]
? state.remoteUserIds
@@ -272,11 +320,11 @@ function reducer(state, action) {
return { ...state, eventsByUserId, remoteUserIds };
}
case "send_voip_event": {
const event = action.event;
case CallEvent.SendVoipEvent: {
const event = action.rawEvent;
const eventsByUserId = { ...state.eventsByUserId };
const fromId = state.localUserId;
const toId = event.userId;
const toId = event.userId as string;
const remoteUserIds = eventsByUserId[toId]
? state.remoteUserIds
@@ -287,8 +335,8 @@ function reducer(state, action) {
{
from: fromId,
to: toId,
type: event.eventType,
content: event.content,
type: event.eventType as string,
content: event.content as CallEventContent,
timestamp: Date.now(),
ignored: false,
},
@@ -301,7 +349,11 @@ function reducer(state, action) {
}
}
function useGroupCallState(client, groupCall, pollCallStats) {
function useGroupCallState(
client: MatrixClient,
groupCall: GroupCall,
showPollCallStats: boolean
) {
const [state, dispatch] = useReducer(reducer, {
localUserId: client.getUserId(),
localSessionId: client.getSessionId(),
@@ -312,7 +364,7 @@ function useGroupCallState(client, groupCall, pollCallStats) {
});
useEffect(() => {
function onUpdateRoomState(event) {
function onUpdateRoomState(event?: MatrixEvent) {
const callStateEvent = groupCall.room.currentState.getStateEvents(
"org.matrix.msc3401.call",
groupCall.groupCallId
@@ -323,120 +375,60 @@ function useGroupCallState(client, groupCall, pollCallStats) {
);
dispatch({
type: "receive_room_state_event",
type: RoomStateEvent.Events,
event,
callStateEvent,
memberStateEvents,
});
}
// function onCallsChanged() {
// const calls = groupCall.calls.reduce((obj, call) => {
// obj[
// `${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
// ] = getCallState(call);
// return obj;
// }, {});
// updateState({ calls });
// }
// function onCallHangup(call) {
// setState(({ hangupCalls, ...rest }) => ({
// ...rest,
// hangupCalls: {
// ...hangupCalls,
// [`${call.callId} (${
// call.getOpponentMember()?.userId || call.sender
// })`]: getHangupCallState(call),
// },
// }));
// dispatch({ type: "call_hangup", call });
// }
function onReceivedVoipEvent(event) {
dispatch({ type: "received_voip_event", event });
function onReceivedVoipEvent(event: MatrixEvent) {
dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
}
function onSendVoipEvent(event) {
dispatch({ type: "send_voip_event", event });
function onSendVoipEvent(event: Record<string, unknown>) {
dispatch({ type: CallEvent.SendVoipEvent, rawEvent: event });
}
client.on("RoomState.events", onUpdateRoomState);
client.on(RoomStateEvent.Events, onUpdateRoomState);
//groupCall.on("calls_changed", onCallsChanged);
groupCall.on("send_voip_event", onSendVoipEvent);
groupCall.on(CallEvent.SendVoipEvent, onSendVoipEvent);
//client.on("state", onCallsChanged);
//client.on("hangup", onCallHangup);
client.on("received_voip_event", onReceivedVoipEvent);
client.on(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
onUpdateRoomState();
return () => {
client.removeListener("RoomState.events", onUpdateRoomState);
client.removeListener(RoomStateEvent.Events, onUpdateRoomState);
//groupCall.removeListener("calls_changed", onCallsChanged);
groupCall.removeListener("send_voip_event", onSendVoipEvent);
groupCall.removeListener(CallEvent.SendVoipEvent, onSendVoipEvent);
//client.removeListener("state", onCallsChanged);
//client.removeListener("hangup", onCallHangup);
client.removeListener("received_voip_event", onReceivedVoipEvent);
client.removeListener(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
};
}, [client, groupCall]);
// useEffect(() => {
// let timeout;
// async function updateCallStats() {
// const callIds = groupCall.calls.map(
// (call) =>
// `${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
// );
// const stats = await Promise.all(
// groupCall.calls.map((call) =>
// call.peerConn
// ? call.peerConn
// .getStats(null)
// .then((stats) =>
// Object.fromEntries(
// Array.from(stats).map(([_id, report], i) => [
// report.type + i,
// report,
// ])
// )
// )
// : Promise.resolve(null)
// )
// );
// const callStats = {};
// for (let i = 0; i < groupCall.calls.length; i++) {
// callStats[callIds[i]] = stats[i];
// }
// dispatch({ type: "callStats", callStats });
// timeout = setTimeout(updateCallStats, 1000);
// }
// if (pollCallStats) {
// updateCallStats();
// }
// return () => {
// clearTimeout(timeout);
// };
// }, [pollCallStats]);
return state;
}
export function GroupCallInspector({ client, groupCall, show }) {
interface GroupCallInspectorProps {
client: MatrixClient;
groupCall: GroupCall;
show: boolean;
}
export function GroupCallInspector({
client,
groupCall,
show,
}: GroupCallInspectorProps) {
const [currentTab, setCurrentTab] = useState("sequence-diagrams");
const [selectedUserId, setSelectedUserId] = useState();
const [selectedUserId, setSelectedUserId] = useState<string>();
const state = useGroupCallState(client, groupCall, show);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, setState] = useContext(InspectorContext);
useEffect(() => {
setState({ json: state });
setState(state);
}, [setState, state]);
if (!show) {
@@ -446,7 +438,7 @@ export function GroupCallInspector({ client, groupCall, show }) {
return (
<Resizable
enable={{ top: true }}
defaultSize={{ height: 200 }}
defaultSize={{ height: 200, width: undefined }}
className={styles.inspector}
>
<div className={styles.toolbar}>

View File

@@ -14,25 +14,44 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ReactNode } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { useTranslation } from "react-i18next";
import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { usePageTitle } from "../usePageTitle";
export function GroupCallLoader({ client, roomId, viaServers, children }) {
interface Props {
client: MatrixClient;
roomIdOrAlias: string;
viaServers: string[];
children: (groupCall: GroupCall) => ReactNode;
createPtt: boolean;
}
export function GroupCallLoader({
client,
roomIdOrAlias,
viaServers,
children,
createPtt,
}: Props): JSX.Element {
const { t } = useTranslation();
const { loading, error, groupCall } = useLoadGroupCall(
client,
roomId,
roomIdOrAlias,
viaServers,
true
createPtt
);
usePageTitle(groupCall ? groupCall.room.name : "Loading...");
usePageTitle(groupCall ? groupCall.room.name : t("Loading…"));
if (loading) {
return (
<FullScreenView>
<h1>Loading room...</h1>
<h1>{t("Loading room…")}</h1>
</FullScreenView>
);
}
@@ -41,5 +60,5 @@ export function GroupCallLoader({ client, roomId, viaServers, children }) {
return <ErrorView error={error} />;
}
return children(groupCall);
return <>{children(groupCall)}</>;
}

View File

@@ -1,150 +0,0 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { useGroupCall } from "./useGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView";
import { InCallView } from "./InCallView";
import { PTTCallView } from "./PTTCallView";
import { CallEndedView } from "./CallEndedView";
import { useRoomAvatar } from "./useRoomAvatar";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation";
export function GroupCallView({
client,
isPasswordlessUser,
isEmbedded,
roomId,
groupCall,
}) {
const {
state,
error,
activeSpeaker,
userMediaFeeds,
microphoneMuted,
localVideoMuted,
localCallFeed,
initLocalCallFeed,
enter,
leave,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
toggleScreensharing,
requestingScreenshare,
isScreensharing,
localScreenshareFeed,
screenshareFeeds,
hasLocalParticipant,
participants,
unencryptedEventsFromUsers,
} = useGroupCall(groupCall);
const avatarUrl = useRoomAvatar(groupCall.room);
useEffect(() => {
window.groupCall = groupCall;
}, [groupCall]);
useSentryGroupCallHandler(groupCall);
useLocationNavigation(requestingScreenshare);
const [left, setLeft] = useState(false);
const history = useHistory();
const onLeave = useCallback(() => {
setLeft(true);
leave();
if (!isPasswordlessUser) {
history.push("/");
}
}, [leave, history]);
if (error) {
return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered) {
if (groupCall.isPtt) {
return (
<PTTCallView
client={client}
roomId={roomId}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
groupCall={groupCall}
participants={participants}
userMediaFeeds={userMediaFeeds}
onLeave={onLeave}
isEmbedded={isEmbedded}
/>
);
} else {
return (
<InCallView
groupCall={groupCall}
client={client}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
userMediaFeeds={userMediaFeeds}
activeSpeaker={activeSpeaker}
onLeave={onLeave}
toggleScreensharing={toggleScreensharing}
isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
roomId={roomId}
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
/>
);
}
} else if (state === GroupCallState.Entering) {
return (
<FullScreenView>
<h1>Entering room...</h1>
</FullScreenView>
);
} else if (left) {
return <CallEndedView client={client} />;
} else {
return (
<LobbyView
client={client}
groupCall={groupCall}
hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
state={state}
onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed}
onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
roomId={roomId}
/>
);
}
}

285
src/room/GroupCallView.tsx Normal file
View File

@@ -0,0 +1,285 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import type { IWidgetApiRequest } from "matrix-widget-api";
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
import { useGroupCall } from "./useGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView";
import { InCallView } from "./InCallView";
import { PTTCallView } from "./PTTCallView";
import { CallEndedView } from "./CallEndedView";
import { useRoomAvatar } from "./useRoomAvatar";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation";
import { useMediaHandler } from "../settings/useMediaHandler";
import { findDeviceByName, getDevices } from "../media-utils";
declare global {
interface Window {
groupCall?: GroupCall;
}
}
interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
isEmbedded: boolean;
preload: boolean;
hideHeader: boolean;
roomIdOrAlias: string;
groupCall: GroupCall;
}
export function GroupCallView({
client,
isPasswordlessUser,
isEmbedded,
preload,
hideHeader,
roomIdOrAlias,
groupCall,
}: Props) {
const {
state,
error,
activeSpeaker,
userMediaFeeds,
microphoneMuted,
localVideoMuted,
localCallFeed,
initLocalCallFeed,
enter,
leave,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
toggleScreensharing,
requestingScreenshare,
isScreensharing,
localScreenshareFeed,
screenshareFeeds,
participants,
unencryptedEventsFromUsers,
} = useGroupCall(groupCall);
const { t } = useTranslation();
const { setAudioInput, setVideoInput } = useMediaHandler();
const avatarUrl = useRoomAvatar(groupCall.room);
useEffect(() => {
window.groupCall = groupCall;
return () => {
delete window.groupCall;
};
}, [groupCall]);
useEffect(() => {
if (widget && preload) {
// In preload mode, wait for a join action before entering
const onJoin = async (ev: CustomEvent<IWidgetApiRequest>) => {
// Get the available devices so we can match the selected device
// to its ID. This involves getting a media stream (see docs on
// the function) so we only do it once and re-use the result.
const devices = await getDevices();
const { audioInput, videoInput } = ev.detail
.data as unknown as JoinCallData;
if (audioInput !== null) {
const deviceId = await findDeviceByName(
audioInput,
"audioinput",
devices
);
if (!deviceId) {
logger.warn("Unknown audio input: " + audioInput);
} else {
logger.debug(
`Found audio input ID ${deviceId} for name ${audioInput}`
);
setAudioInput(deviceId);
}
}
if (videoInput !== null) {
const deviceId = await findDeviceByName(
videoInput,
"videoinput",
devices
);
if (!deviceId) {
logger.warn("Unknown video input: " + videoInput);
} else {
logger.debug(
`Found video input ID ${deviceId} for name ${videoInput}`
);
setVideoInput(deviceId);
}
}
await Promise.all([
groupCall.setMicrophoneMuted(audioInput === null),
groupCall.setLocalVideoMuted(videoInput === null),
]);
await groupCall.enter();
await Promise.all([
widget.api.setAlwaysOnScreen(true),
widget.api.transport.reply(ev.detail, {}),
]);
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return () => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
}
}, [groupCall, preload, setAudioInput, setVideoInput]);
useEffect(() => {
if (isEmbedded && !preload) {
// In embedded mode, bypass the lobby and just enter the call straight away
groupCall.enter();
}
}, [groupCall, isEmbedded, preload]);
useSentryGroupCallHandler(groupCall);
useLocationNavigation(requestingScreenshare);
const [left, setLeft] = useState(false);
const history = useHistory();
const onLeave = useCallback(() => {
setLeft(true);
leave();
if (widget) {
widget.api.transport.send(ElementWidgetActions.HangupCall, {});
widget.api.setAlwaysOnScreen(false);
}
if (!isPasswordlessUser && !isEmbedded) {
history.push("/");
}
}, [leave, isPasswordlessUser, isEmbedded, history]);
useEffect(() => {
if (widget && state === GroupCallState.Entered) {
const onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
leave();
await widget.api.transport.reply(ev.detail, {});
widget.api.setAlwaysOnScreen(false);
};
widget.lazyActions.once(ElementWidgetActions.HangupCall, onHangup);
return () => {
widget.lazyActions.off(ElementWidgetActions.HangupCall, onHangup);
};
}
}, [groupCall, state, leave]);
if (error) {
return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered) {
if (groupCall.isPtt) {
return (
<PTTCallView
client={client}
roomIdOrAlias={roomIdOrAlias}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
groupCall={groupCall}
participants={participants}
userMediaFeeds={userMediaFeeds}
onLeave={onLeave}
isEmbedded={isEmbedded}
hideHeader={hideHeader}
/>
);
} else {
return (
<InCallView
groupCall={groupCall}
client={client}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
userMediaFeeds={userMediaFeeds}
activeSpeaker={activeSpeaker}
onLeave={onLeave}
toggleScreensharing={toggleScreensharing}
isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
roomIdOrAlias={roomIdOrAlias}
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
hideHeader={hideHeader}
/>
);
}
} else if (state === GroupCallState.Entering) {
return (
<FullScreenView>
<h1>{t("Entering room…")}</h1>
</FullScreenView>
);
} else if (left) {
if (isPasswordlessUser) {
return <CallEndedView client={client} />;
} else {
// If the user is a regular user, we'll have sent them back to the homepage,
// so just sit here & do nothing: otherwise we would (briefly) mount the
// LobbyView again which would open capture devices again.
return null;
}
} else if (preload) {
return null;
} else if (isEmbedded) {
return (
<FullScreenView>
<h1>{t("Loading room…")}</h1>
</FullScreenView>
);
} else {
return (
<LobbyView
client={client}
groupCall={groupCall}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
state={state}
onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed}
onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
roomIdOrAlias={roomIdOrAlias}
isEmbedded={isEmbedded}
hideHeader={hideHeader}
/>
);
}
}

View File

@@ -1,209 +0,0 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useMemo, useRef } from "react";
import styles from "./InCallView.module.css";
import {
HangupButton,
MicButton,
VideoButton,
ScreenshareButton,
} from "../button";
import {
Header,
LeftNav,
RightNav,
RoomHeaderInfo,
VersionMismatchWarning,
} from "../Header";
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
import { GridLayoutMenu } from "./GridLayoutMenu";
import { Avatar } from "../Avatar";
import { UserMenuContainer } from "../UserMenuContainer";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { usePreventScroll } from "@react-aria/overlays";
import { useMediaHandler } from "../settings/useMediaHandler";
import { useShowInspector } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { useAudioContext } from "../video-grid/useMediaStream";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
// or with getUsermedia and getDisplaymedia being used within the same session.
// For now we can disable screensharing in Safari.
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
export function InCallView({
client,
groupCall,
roomName,
avatarUrl,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
userMediaFeeds,
activeSpeaker,
onLeave,
toggleScreensharing,
isScreensharing,
screenshareFeeds,
roomId,
unencryptedEventsFromUsers,
}) {
usePreventScroll();
const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0);
const [audioContext, audioDestination, audioRef] = useAudioContext();
const { audioOutput } = useMediaHandler();
const [showInspector] = useShowInspector();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const items = useMemo(() => {
const participants = [];
for (const callFeed of userMediaFeeds) {
participants.push({
id: callFeed.stream.id,
callFeed,
focused:
screenshareFeeds.length === 0 && layout === "spotlight"
? callFeed.userId === activeSpeaker
: false,
isLocal: callFeed.isLocal(),
});
}
for (const callFeed of screenshareFeeds) {
const userMediaItem = participants.find(
(item) => item.callFeed.userId === callFeed.userId
);
if (userMediaItem) {
userMediaItem.presenter = true;
}
participants.push({
id: callFeed.stream.id,
callFeed,
focused: true,
isLocal: callFeed.isLocal(),
});
}
return participants;
}, [userMediaFeeds, activeSpeaker, screenshareFeeds, layout]);
const renderAvatar = useCallback(
(roomMember, width, height) => {
const avatarUrl = roomMember.user?.avatarUrl;
const size = Math.round(Math.min(width, height) / 2);
return (
<Avatar
key={roomMember.userId}
size={size}
src={avatarUrl}
fallback={roomMember.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
);
},
[client]
);
const {
modalState: rageshakeRequestModalState,
modalProps: rageshakeRequestModalProps,
} = useRageshakeRequestModal(groupCall.room.roomId);
return (
<div className={styles.inRoom}>
<audio ref={audioRef} />
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
<VersionMismatchWarning
users={unencryptedEventsFromUsers}
room={groupCall.room}
/>
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
<UserMenuContainer preventNavigation />
</RightNav>
</Header>
{items.length === 0 ? (
<div className={styles.centerMessage}>
<p>Waiting for other participants...</p>
</div>
) : (
<VideoGrid items={items} layout={layout} disableAnimations={isSafari}>
{({ item, ...rest }) => (
<VideoTileContainer
key={item.id}
item={item}
getAvatar={renderAvatar}
showName={items.length > 2 || item.focused}
audioOutputDevice={audioOutput}
audioContext={audioContext}
audioDestination={audioDestination}
disableSpeakingIndicator={items.length < 3}
{...rest}
/>
)}
</VideoGrid>
)}
<div className={styles.footer}>
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
{canScreenshare && !isSafari && (
<ScreenshareButton
enabled={isScreensharing}
onPress={toggleScreensharing}
/>
)}
<OverflowMenu
inCall
roomId={roomId}
client={client}
groupCall={groupCall}
showInvite={true}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
<HangupButton onPress={onLeave} />
</div>
<GroupCallInspector
client={client}
groupCall={groupCall}
show={showInspector}
/>
{rageshakeRequestModalState.isOpen && (
<RageshakeRequestModal
{...rageshakeRequestModalProps}
roomId={roomId}
/>
)}
</div>
);
}

View File

@@ -43,7 +43,7 @@ limitations under the License.
display: flex;
justify-content: center;
align-items: center;
height: 64px;
height: calc(50px + 2 * 8px);
}
.footer > * {
@@ -54,6 +54,12 @@ limitations under the License.
margin-right: 0px;
}
.maximised .footer {
position: absolute;
width: 100%;
bottom: 0;
}
.avatar {
position: absolute;
top: 50%;
@@ -61,8 +67,14 @@ limitations under the License.
transform: translate(-50%, -50%);
}
@media (min-width: 800px) {
@media (min-height: 300px) {
.footer {
height: 118px;
height: calc(50px + 2 * 24px);
}
}
@media (min-width: 800px) {
.footer {
height: calc(50px + 2 * 32px);
}
}

374
src/room/InCallView.tsx Normal file
View File

@@ -0,0 +1,374 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useCallback, useMemo, useRef } from "react";
import { usePreventScroll } from "@react-aria/overlays";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import type { IWidgetApiRequest } from "matrix-widget-api";
import styles from "./InCallView.module.css";
import {
HangupButton,
MicButton,
VideoButton,
ScreenshareButton,
} from "../button";
import {
Header,
LeftNav,
RightNav,
RoomHeaderInfo,
VersionMismatchWarning,
} from "../Header";
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
import { GridLayoutMenu } from "./GridLayoutMenu";
import { Avatar } from "../Avatar";
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 { useModalTriggerState } from "../Modal";
import { useAudioContext } from "../video-grid/useMediaStream";
import { useFullscreen } from "../video-grid/useFullscreen";
import { AudioContainer } from "../video-grid/AudioContainer";
import { useAudioOutputDevice } from "../video-grid/useAudioOutputDevice";
import { widget, ElementWidgetActions } from "../widget";
import { useJoinRule } from "./useJoinRule";
import { useUrlParams } from "../UrlParams";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
// or with getUsermedia and getDisplaymedia being used within the same session.
// For now we can disable screensharing in Safari.
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
interface Props {
client: MatrixClient;
groupCall: GroupCall;
roomName: string;
avatarUrl: string;
microphoneMuted: boolean;
localVideoMuted: boolean;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
toggleScreensharing: () => void;
userMediaFeeds: CallFeed[];
activeSpeaker: string;
onLeave: () => void;
isScreensharing: boolean;
screenshareFeeds: CallFeed[];
localScreenshareFeed: CallFeed;
roomIdOrAlias: string;
unencryptedEventsFromUsers: Set<string>;
hideHeader: boolean;
}
export interface Participant {
id: string;
focused: boolean;
presenter: boolean;
callFeed?: CallFeed;
isLocal?: boolean;
}
export function InCallView({
client,
groupCall,
roomName,
avatarUrl,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
userMediaFeeds,
activeSpeaker,
onLeave,
toggleScreensharing,
isScreensharing,
screenshareFeeds,
localScreenshareFeed,
roomIdOrAlias,
unencryptedEventsFromUsers,
hideHeader,
}: Props) {
const { t } = useTranslation();
usePreventScroll();
const joinRule = useJoinRule(groupCall.room);
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
// Merge the refs so they can attach to the same element
const containerRef = useCallback(
(el: HTMLDivElement) => {
containerRef1.current = el;
containerRef2(el);
},
[containerRef1, containerRef2]
);
const { layout, setLayout } = useVideoGridLayout(screenshareFeeds.length > 0);
const { toggleFullscreen, fullscreenParticipant } =
useFullscreen(containerRef1);
const [spatialAudio] = useSpatialAudio();
const [audioContext, audioDestination, audioRef] = useAudioContext();
const { audioOutput } = useMediaHandler();
const [showInspector] = useShowInspector();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
useAudioOutputDevice(audioRef, audioOutput);
const { hideScreensharing } = useUrlParams();
useEffect(() => {
widget?.api.transport.send(
layout === "freedom"
? ElementWidgetActions.TileLayout
: ElementWidgetActions.SpotlightLayout,
{}
);
}, [layout]);
useEffect(() => {
if (widget) {
const onTileLayout = async (ev: CustomEvent<IWidgetApiRequest>) => {
setLayout("freedom");
await widget.api.transport.reply(ev.detail, {});
};
const onSpotlightLayout = async (ev: CustomEvent<IWidgetApiRequest>) => {
setLayout("spotlight");
await widget.api.transport.reply(ev.detail, {});
};
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
widget.lazyActions.on(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout
);
return () => {
widget.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
widget.lazyActions.off(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout
);
};
}
}, [setLayout]);
const items = useMemo(() => {
const participants: Participant[] = [];
for (const callFeed of userMediaFeeds) {
participants.push({
id: callFeed.stream.id,
callFeed,
focused:
screenshareFeeds.length === 0 && callFeed.userId === activeSpeaker,
isLocal: callFeed.isLocal(),
presenter: false,
});
}
for (const callFeed of screenshareFeeds) {
const userMediaItem = participants.find(
(item) => item.callFeed.userId === callFeed.userId
);
if (userMediaItem) {
userMediaItem.presenter = true;
}
participants.push({
id: callFeed.stream.id,
callFeed,
focused: true,
isLocal: callFeed.isLocal(),
presenter: false,
});
}
return participants;
}, [userMediaFeeds, activeSpeaker, screenshareFeeds]);
// The maximised participant: either the participant that the user has
// manually put in fullscreen, or the focused (active) participant if the
// window is too small to show everyone
const maximisedParticipant = useMemo(
() =>
fullscreenParticipant ??
(bounds.height <= 400 && bounds.width <= 400
? items.find((item) => item.focused) ??
items.find((item) => item.callFeed) ??
null
: null),
[fullscreenParticipant, bounds, items]
);
const reducedControls = bounds.width <= 400;
const renderAvatar = useCallback(
(roomMember: RoomMember, width: number, height: number) => {
const avatarUrl = roomMember.user?.avatarUrl;
const size = Math.round(Math.min(width, height) / 2);
return (
<Avatar
key={roomMember.userId}
size={size}
src={avatarUrl}
fallback={roomMember.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
);
},
[]
);
const renderContent = (): JSX.Element => {
if (items.length === 0) {
return (
<div className={styles.centerMessage}>
<p>{t("Waiting for other participants…")}</p>
</div>
);
}
if (maximisedParticipant) {
return (
<VideoTileContainer
height={bounds.height}
width={bounds.width}
key={maximisedParticipant.id}
item={maximisedParticipant}
getAvatar={renderAvatar}
audioContext={audioContext}
audioDestination={audioDestination}
disableSpeakingIndicator={true}
maximised={Boolean(maximisedParticipant)}
fullscreen={maximisedParticipant === fullscreenParticipant}
onFullscreen={toggleFullscreen}
/>
);
}
return (
<VideoGrid items={items} layout={layout} disableAnimations={isSafari}>
{({ item, ...rest }: { item: Participant; [x: string]: unknown }) => (
<VideoTileContainer
key={item.id}
item={item}
getAvatar={renderAvatar}
audioContext={audioContext}
audioDestination={audioDestination}
disableSpeakingIndicator={items.length < 3}
maximised={false}
fullscreen={false}
onFullscreen={toggleFullscreen}
{...rest}
/>
)}
</VideoGrid>
);
};
const {
modalState: rageshakeRequestModalState,
modalProps: rageshakeRequestModalProps,
} = useRageshakeRequestModal(groupCall.room.roomId);
const containerClasses = classNames(styles.inRoom, {
[styles.maximised]: maximisedParticipant,
});
return (
<div className={containerClasses} ref={containerRef}>
<audio ref={audioRef} />
{(!spatialAudio || maximisedParticipant) && (
<AudioContainer
items={items}
audioContext={audioContext}
audioDestination={audioDestination}
/>
)}
{!hideHeader && !maximisedParticipant && (
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
<VersionMismatchWarning
users={unencryptedEventsFromUsers}
room={groupCall.room}
/>
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
<UserMenuContainer preventNavigation />
</RightNav>
</Header>
)}
{renderContent()}
<div className={styles.footer}>
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
{canScreenshare &&
!hideScreensharing &&
!isSafari &&
!reducedControls && (
<ScreenshareButton
enabled={isScreensharing}
onPress={toggleScreensharing}
/>
)}
{!reducedControls && (
<OverflowMenu
inCall
roomIdOrAlias={roomIdOrAlias}
groupCall={groupCall}
showInvite={joinRule === JoinRule.Public}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
)}
<HangupButton onPress={onLeave} />
</div>
<GroupCallInspector
client={client}
groupCall={groupCall}
show={showInspector}
/>
{rageshakeRequestModalState.isOpen && (
<RageshakeRequestModal
{...rageshakeRequestModalProps}
roomIdOrAlias={roomIdOrAlias}
/>
)}
</div>
);
}

View File

@@ -14,24 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { Modal, ModalContent } from "../Modal";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent, ModalProps } from "../Modal";
import { CopyButton } from "../button";
import { getRoomUrl } from "../matrix-utils";
import styles from "./InviteModal.module.css";
export function InviteModal({ roomId, ...rest }) {
interface Props extends Omit<ModalProps, "title" | "children"> {
roomIdOrAlias: string;
}
export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
const { t } = useTranslation();
return (
<Modal
title="Invite People"
title={t("Invite people")}
isDismissable
className={styles.inviteModal}
{...rest}
>
<ModalContent>
<p>Copy and share this meeting link</p>
<CopyButton className={styles.copyButton} value={getRoomUrl(roomId)} />
<p>{t("Copy and share this call link")}</p>
<CopyButton
className={styles.copyButton}
value={getRoomUrl(roomIdOrAlias)}
/>
</ModalContent>
</Modal>
);
}
};

View File

@@ -15,10 +15,15 @@ limitations under the License.
*/
import React, { useEffect, useRef } from "react";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { PressEvent } from "@react-types/shared";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useTranslation } from "react-i18next";
import styles from "./LobbyView.module.css";
import { Button, CopyButton } from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { useCallFeed } from "../video-grid/useCallFeed";
import { getRoomUrl } from "../matrix-utils";
import { UserMenuContainer } from "../UserMenuContainer";
@@ -28,6 +33,23 @@ import { useMediaHandler } from "../settings/useMediaHandler";
import { VideoPreview } from "./VideoPreview";
import { AudioPreview } from "./AudioPreview";
interface Props {
client: MatrixClient;
groupCall: GroupCall;
roomName: string;
avatarUrl: string;
state: GroupCallState;
onInitLocalCallFeed: () => void;
onEnter: (e: PressEvent) => void;
localCallFeed: CallFeed;
microphoneMuted: boolean;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
localVideoMuted: boolean;
roomIdOrAlias: string;
isEmbedded: boolean;
hideHeader: boolean;
}
export function LobbyView({
client,
groupCall,
@@ -41,8 +63,11 @@ export function LobbyView({
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
roomId,
}) {
roomIdOrAlias,
isEmbedded,
hideHeader,
}: Props) {
const { t } = useTranslation();
const { stream } = useCallFeed(localCallFeed);
const {
audioInput,
@@ -59,7 +84,7 @@ export function LobbyView({
useLocationNavigation(state === GroupCallState.InitializingLocalCallFeed);
const joinCallButtonRef = useRef();
const joinCallButtonRef = useRef<HTMLButtonElement>();
useEffect(() => {
if (state === GroupCallState.LocalCallFeedInitialized) {
@@ -69,14 +94,16 @@ export function LobbyView({
return (
<div className={styles.room}>
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</LeftNav>
<RightNav>
<UserMenuContainer />
</RightNav>
</Header>
{!hideHeader && (
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</LeftNav>
<RightNav>
<UserMenuContainer />
</RightNav>
</Header>
)}
<div className={styles.joinRoom}>
<div className={styles.joinRoomContent}>
{groupCall.isPtt ? (
@@ -94,7 +121,7 @@ export function LobbyView({
<VideoPreview
state={state}
client={client}
roomId={roomId}
roomIdOrAlias={roomIdOrAlias}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
@@ -115,18 +142,20 @@ export function LobbyView({
<Body>Or</Body>
<CopyButton
variant="secondaryCopy"
value={getRoomUrl(roomId)}
value={getRoomUrl(roomIdOrAlias)}
className={styles.copyButton}
copiedMessage="Call link copied"
copiedMessage={t("Call link copied")}
>
Copy call link and join later
{t("Copy call link and join later")}
</CopyButton>
</div>
<Body className={styles.joinRoomFooter}>
<Link color="primary" to="/">
Take me Home
</Link>
</Body>
{!isEmbedded && (
<Body className={styles.joinRoomFooter}>
<Link color="primary" to="/">
{t("Take me Home")}
</Link>
</Body>
)}
</div>
</div>
);

View File

@@ -15,10 +15,14 @@ limitations under the License.
*/
import React, { useCallback } from "react";
import { Item } from "@react-stately/collections";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { OverlayTriggerState } from "@react-stately/overlays";
import { useTranslation } from "react-i18next";
import { Button } from "../button";
import { Menu } from "../Menu";
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { Item } from "@react-stately/collections";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
@@ -29,66 +33,100 @@ import { InviteModal } from "./InviteModal";
import { TooltipTrigger } from "../Tooltip";
import { FeedbackModal } from "./FeedbackModal";
interface Props {
roomIdOrAlias: string;
inCall: boolean;
groupCall: GroupCall;
showInvite: boolean;
feedbackModalState: OverlayTriggerState;
feedbackModalProps: {
isOpen: boolean;
onClose: () => void;
};
}
export function OverflowMenu({
roomId,
roomIdOrAlias,
inCall,
groupCall,
showInvite,
feedbackModalState,
feedbackModalProps,
}) {
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
const { modalState: settingsModalState, modalProps: settingsModalProps } =
useModalTriggerState();
}: Props) {
const { t } = useTranslation();
const {
modalState: inviteModalState,
modalProps: inviteModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const {
modalState: settingsModalState,
modalProps: settingsModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
// TODO: On closing modal, focus should be restored to the trigger button
// https://github.com/adobe/react-spectrum/issues/2444
const onAction = useCallback((key) => {
switch (key) {
case "invite":
inviteModalState.open();
break;
case "settings":
settingsModalState.open();
break;
case "feedback":
feedbackModalState.open();
break;
}
});
const onAction = useCallback(
(key) => {
switch (key) {
case "invite":
inviteModalState.open();
break;
case "settings":
settingsModalState.open();
break;
case "feedback":
feedbackModalState.open();
break;
}
},
[feedbackModalState, inviteModalState, settingsModalState]
);
const tooltip = useCallback(() => t("More"), [t]);
return (
<>
<PopoverMenuTrigger disableOnState>
<TooltipTrigger placement="top">
<TooltipTrigger tooltip={tooltip} placement="top">
<Button variant="toolbar">
<OverflowIcon />
</Button>
{() => "More"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="More menu" onAction={onAction}>
{(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label={t("More menu")} onAction={onAction}>
{showInvite && (
<Item key="invite" textValue="Invite people">
<Item key="invite" textValue={t("Invite people")}>
<AddUserIcon />
<span>Invite people</span>
<span>{t("Invite people")}</span>
</Item>
)}
<Item key="settings" textValue="Settings">
<Item key="settings" textValue={t("Settings")}>
<SettingsIcon />
<span>Settings</span>
<span>{t("Settings")}</span>
</Item>
<Item key="feedback" textValue="Submit Feedback">
<Item key="feedback" textValue={t("Submit feedback")}>
<FeedbackIcon />
<span>Submit Feedback</span>
<span>{t("Submit feedback")}</span>
</Item>
</Menu>
)}
</PopoverMenuTrigger>
{settingsModalState.isOpen && <SettingsModal {...settingsModalProps} />}
{inviteModalState.isOpen && (
<InviteModal roomId={roomId} {...inviteModalProps} />
<InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
)}
{feedbackModalState.isOpen && (
<FeedbackModal

View File

@@ -1,17 +1,28 @@
.pttButton {
width: 100vw;
height: 100vh;
max-height: 232px;
max-width: 232px;
aspect-ratio: 1;
max-height: min(232px, calc(100vh - 16px));
max-width: min(232px, calc(100vw - 16px));
border-radius: 116px;
color: var(--primary-content);
border: 6px solid var(--accent);
background-color: #21262c;
position: relative;
padding: 0;
margin: 4px;
cursor: pointer;
}
.micIcon {
max-height: 50%;
}
.avatar {
/* Remove explicit size to allow avatar to scale with the button */
width: 100% !important;
height: 100% !important;
}
.talking {
background-color: var(--accent);
cursor: unset;

Some files were not shown because too many files have changed in this diff Show More