Compare commits

...

175 Commits

Author SHA1 Message Date
Enrico Schwendig
0c66b32b49 matrix-js-sdk: update to last develop branch with call group fix (#941) 2023-03-03 08:06:10 +01:00
Robin
5eb552e36a Merge pull request #935 from alariej/alariej
Add e2eEnabled parameter to Widget client
2023-03-02 16:07:04 -05:00
Enrico Schwendig
29e41c7227 Allow Element Call to be started without audio / video interface (#924)
* config: add feature in `config.json`

* groupCall: adjust connection state in feed if allowCallWithoutVideoAndAudio

* matrix-js-sdk: update version for allowCallWithoutVideoAndAudio

- I modified the SDK so that mute unmute work without media and check device permission inside the SDK
- allowCallWithoutVideoAndAudio is only checked at one point outside the SDK

* docu: add join group call without media docu in READMe

---------

Co-authored-by: Robin Townsend <robin@robin.town>
Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
2023-03-02 18:48:32 +01:00
alariej
58d87db55f Add e2eEnabled parameter to Widget client 2023-03-01 14:30:25 +01:00
Timo
0423a494c4 Checkbox for analytics opt in & settings redesign (#934) 2023-03-01 13:47:36 +01:00
David Baker
2454daeef9 Merge pull request #932 from vector-im/dbkr/allow_full_alias_input
Behave sensibly if a full room alias is entered
2023-02-28 14:12:10 +00:00
David Baker
64703fd3cc Typo
Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
2023-02-28 14:09:52 +00:00
David Baker
53bc8eb82f Behave sensibly if a full room alias is entered
Check explicitly to see if the room name that's enetered into the box
looks like a room alias and if so, do the sensible thing.

Fixes https://github.com/vector-im/element-call/issues/852
2023-02-28 13:50:24 +00:00
Robin
cdf2d560b8 Merge pull request #911 from robintown/big-grid
New grid system
2023-02-19 09:02:57 -05:00
Robin
9dff42b437 Merge pull request #919 from robintown/ignore-coverage
Add coverage reports to .gitignore
2023-02-16 11:32:29 -05:00
Robin Townsend
1fc181dc28 Document useReactiveState 2023-02-15 16:38:49 -05:00
Robin Townsend
fbc72283d4 Add missing copyright headers 2023-02-15 16:20:58 -05:00
Robin Townsend
9a0dfad5f9 Add coverage reports to .gitignore 2023-02-15 15:13:39 -05:00
Robin Townsend
efbf319fa1 Explain why we cast the tile springs 2023-02-13 22:40:26 -05:00
Robin Townsend
ef4a62ca62 Document useMergedRefs 2023-02-13 22:38:27 -05:00
Robin Townsend
8c818b9ce1 Get 100% test coverage on grid operations 2023-02-13 22:24:04 -05:00
Robin Townsend
58ed372afa Fix type and lint errors 2023-02-13 21:57:57 -05:00
Robin Townsend
69e6ba93c1 Add a switch to toggle between the new and old grids 2023-02-13 20:36:42 -05:00
Robin Townsend
b2b2f0bb15 Merge branch 'main' into big-grid 2023-02-13 18:54:19 -05:00
Robin Townsend
8d0bf4cacc Test grid operations 2023-02-13 18:35:50 -05:00
Robin Townsend
5448744871 Document grid operations 2023-02-13 18:35:23 -05:00
David Baker
933dc4e2d3 Merge pull request #910 from vector-im/dbkr/ignore_media_action
Don't pause audio streams on media actions
2023-02-13 17:35:33 +01:00
David Baker
605dd44df0 Rename other instance of variable 2023-02-13 15:49:58 +00:00
David Baker
07a4de638f Don't pause audio streams on media actions
This adds handlers for the media actions to do nothing, otherwise
they cause the audio element for a random participant to get paused.

Fixes https://github.com/vector-im/element-call/issues/855
2023-02-13 15:20:48 +00:00
Timo
eda11cfc08 Inform that the user that config keys are missing (#880) 2023-02-09 12:57:54 +01:00
Robin Townsend
d852e33413 Document the component 2023-02-08 00:32:08 -05:00
Robin Townsend
8d46687a54 Refactor grid state tracking 2023-02-07 23:27:49 -05:00
Robin Townsend
978b0f08e8 Move grid algorithms into a separate file 2023-02-07 22:13:50 -05:00
Robin Townsend
374c68e3c0 Fix tiles enlarging to the wrong place on mobile 2023-02-05 01:17:28 -05:00
Robin Townsend
82ac775124 Fix scrolling on mobile 2023-02-05 00:55:12 -05:00
Robin Townsend
6adcf95aaa Implement different column counts and mobile layout 2023-02-04 00:43:53 -05:00
Robin Townsend
206730ffc0 Fix infinite loop when a tile can't be enlarged 2023-02-03 16:52:42 -05:00
Robin Townsend
1e858f6ba3 Fix a typo 2023-02-03 16:27:49 -05:00
Robin Townsend
22382413dc Make drag and drop mobile-friendly 2023-02-03 15:42:47 -05:00
Robin Townsend
6cd939db0c Fix a crash when there's only 1 tile and it gets shrunk 2023-02-03 09:11:25 -05:00
Robin Townsend
42e4f6ce83 Don't allow the grid to overflow horizontally 2023-02-03 08:44:35 -05:00
David Baker
aabca7ebff Merge pull request #895 from vector-im/revert-893-dbkr/yarn_upgrade_jan23
Revert "Yarn upgrade"
2023-02-02 14:42:05 +00:00
David Baker
579b91abff Revert "Yarn upgrade" 2023-02-02 14:32:44 +00:00
David Baker
e3b4a695d6 Merge pull request #893 from vector-im/dbkr/yarn_upgrade_jan23
Yarn upgrade
2023-02-02 13:47:00 +00:00
David Baker
e1abbd5291 Yarn upgrade
Along with some type fixes to make typescript happy again. Hopefully
they are sensible.
2023-02-02 12:49:54 +00:00
Robin Townsend
4fc8598e36 Keep tile elements in a stable order 2023-02-01 11:50:52 -05:00
Robin Townsend
6784d2ba97 Remove redundant key prop 2023-02-01 11:50:25 -05:00
Robin Townsend
0915e327e1 Implement somewhat working drag & drop and improve render memoization 2023-02-01 11:32:10 -05:00
Robin Townsend
eedf8a6d1b Make tiles draggable (but not yet droppable) 2023-02-01 00:17:22 -05:00
Robin Townsend
d7db845f3b Scroll snap was a bad idea 2023-01-30 23:52:46 -05:00
Robin Townsend
82c7293308 Replace premature animation optimization with a potentially wiser one 2023-01-30 23:44:19 -05:00
Robin Townsend
0166eb67fb Make avatars scale smoothly during animations 2023-01-30 23:43:45 -05:00
Robin Townsend
e3081c1c06 Try out a snappier spring 2023-01-30 23:32:26 -05:00
Robin Townsend
f540f48461 Fix some layout bugs 2023-01-30 23:32:00 -05:00
Robin Townsend
55dece274f Fix some tile resizing bugs 2023-01-30 17:04:43 -05:00
Robin
b12e52d972 Merge pull request #866 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2023-01-30 14:00:59 -05:00
Suguru Hirahara
82f2fd05b5 Translated using Weblate (Japanese)
Currently translated at 74.4% (105 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-30 18:25:09 +00:00
Genbuchan
b4c6684ff5 Translated using Weblate (Japanese)
Currently translated at 74.4% (105 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-30 18:25:09 +00:00
Robin Townsend
4e73c07cb2 Try out scroll snapping 2023-01-30 09:35:40 -05:00
Robin Townsend
3805a2f20e Format with Prettier 2023-01-29 21:56:07 -05:00
Robin Townsend
4e35984900 Extract tile size change logic into a function 2023-01-29 21:54:53 -05:00
Robin Townsend
e99294c3f1 Simplify some code 2023-01-29 21:45:10 -05:00
DarkCoder15
32fb14107f Translated using Weblate (Russian)
Currently translated at 100.0% (141 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ru/
2023-01-27 15:33:29 +00:00
afr4283
14a5e53e65 Translated using Weblate (Polish)
Currently translated at 97.1% (137 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/pl/
2023-01-27 15:33:29 +00:00
Šimon Brandner
24fea189dc Translated using Weblate (Czech)
Currently translated at 100.0% (141 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/cs/
2023-01-27 15:33:29 +00:00
Genbuchan
0f73527ccf Translated using Weblate (Japanese)
Currently translated at 72.3% (102 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-27 15:33:29 +00:00
Suguru Hirahara
e0b94b51ab Translated using Weblate (Japanese)
Currently translated at 72.3% (102 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-27 15:33:29 +00:00
David Baker
ee0c623866 Merge pull request #884 from vector-im/dbkr/posthog_pr_previews
Add posthog to PR preview builds
2023-01-26 17:24:48 +00:00
Šimon Brandner
c321deecba Merge pull request #882 from dinosmm/patch-2 2023-01-26 17:27:40 +01:00
David Baker
b7ac131614 Add posthog to PR preview builds 2023-01-26 14:59:54 +00:00
Dino
acade92d70 Removed one more reference to Synapse
I forgot to remove one reference to Synapse previously.
2023-01-26 14:50:32 +00:00
Dino
6e275b9221 Clarified homeserver requirements
I clarified homeserver requirements (i.e. that Element Call needs a homeserver like Synapse but not necessarily Synapse), and also edited some other parts for clarity. I also updated the recommendation to not log in to an existing homeserver based on my new findings that Element Call *may* allow you to log in to an existing HS but log in is unreliable.
2023-01-26 14:24:53 +00:00
Dino
42d5db6d0f Update README.md
Updated README.md to include more detailed information about the limitations of Element Call and a recommended homeserver set up.
2023-01-26 10:35:30 +00:00
Robin Townsend
8912daa922 Make tiles resizable and fix some miscellaneous bugs 2023-01-25 23:51:36 -05:00
Robin Townsend
045103dbc9 Backfill the grid as people leave by moving tiles along paths 2023-01-25 02:30:52 -05:00
David Baker
0f2a62a59f Merge pull request #870 from vector-im/dbkr/fix_rageshake_modal_mobile
Fix the rageshake modal on mobile
2023-01-23 20:21:23 +00:00
David Baker
d2631a3e02 Fix the rageshake modal on mobile
As per comment

Unsure if this is the best fix - ideally we wouldn't go into no-controls
mode at all, but this part doesn't know whether the dialog is open so
the only thing we could really do is tweak the threshold, or possibly
guess based on width instead?
2023-01-23 17:52:02 +00:00
David Baker
41b72440a0 Merge pull request #869 from vector-im/dbkr/suppress_dup_ptt_unhold
Avoid duplicate PTT button 'unhold' events
2023-01-23 16:59:54 +00:00
David Baker
d65464e4db Avoid duplicate PTT button 'unhold' events
We called the 'unhold' function even if the button wasn't held which
probably will have been generating unmute events even when we weren't
muted.

Also use separate handlers for events so we can have specific log lines
(and also see where the event comes from when caught in the debugger).
2023-01-23 16:53:24 +00:00
Robin Townsend
59f3b05c07 Merge branch 'main' into big-grid 2023-01-23 08:57:04 -05:00
Robin
4f0a780ecf Merge pull request #863 from robintown/demo-screenshot
Add a demo screenshot to the README
2023-01-20 15:38:48 -05:00
David Baker
b8c1dd4c78 Merge pull request #865 from vector-im/dbkr/wait_until_loaded_before_registering
Don't try to register users until client is loaded
2023-01-20 18:32:14 +00:00
Robin
cf5e9ba2f9 Merge pull request #864 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2023-01-20 13:21:12 -05:00
David Baker
4f8bd18efd Don't try to register users until client is loaded 2023-01-20 17:59:57 +00:00
Suguru Hirahara
f56177b96a Translated using Weblate (Japanese)
Currently translated at 65.9% (93 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:20:44 +00:00
Genbuchan
85b206c270 Translated using Weblate (Japanese)
Currently translated at 65.9% (93 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:20:43 +00:00
Suguru Hirahara
bf7c45b0bc Translated using Weblate (Japanese)
Currently translated at 64.5% (91 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:20:30 +00:00
Genbuchan
906fcdf72e Translated using Weblate (Japanese)
Currently translated at 64.5% (91 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:20:29 +00:00
Suguru Hirahara
17a3e14d09 Translated using Weblate (Japanese)
Currently translated at 63.1% (89 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:20:07 +00:00
Genbuchan
26e1772c75 Translated using Weblate (Japanese)
Currently translated at 63.1% (89 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:20:07 +00:00
Suguru Hirahara
edfb8709d1 Translated using Weblate (Japanese)
Currently translated at 60.9% (86 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:19:33 +00:00
Genbuchan
7798128cbd Translated using Weblate (Japanese)
Currently translated at 60.9% (86 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:19:33 +00:00
Suguru Hirahara
5c3c15266a Translated using Weblate (Japanese)
Currently translated at 41.1% (58 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:14:22 +00:00
Genbuchan
ea7bfb5afb Translated using Weblate (Japanese)
Currently translated at 41.1% (58 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:14:22 +00:00
Suguru Hirahara
f940063e03 Translated using Weblate (Japanese)
Currently translated at 34.7% (49 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:12:49 +00:00
Genbuchan
a56d974f48 Translated using Weblate (Japanese)
Currently translated at 34.7% (49 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:12:49 +00:00
Suguru Hirahara
435f6f1ae9 Translated using Weblate (Japanese)
Currently translated at 31.2% (44 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:11:53 +00:00
Genbuchan
375db2a47b Translated using Weblate (Japanese)
Currently translated at 31.2% (44 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:11:53 +00:00
Suguru Hirahara
ed1b1c3d3e Translated using Weblate (Japanese)
Currently translated at 29.7% (42 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:11:39 +00:00
Genbuchan
ddb3637d79 Translated using Weblate (Japanese)
Currently translated at 29.7% (42 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:11:39 +00:00
Suguru Hirahara
85d5946d6a Translated using Weblate (Japanese)
Currently translated at 28.3% (40 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:11:26 +00:00
Genbuchan
3cdb413587 Translated using Weblate (Japanese)
Currently translated at 28.3% (40 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:11:25 +00:00
Suguru Hirahara
5ac1212988 Translated using Weblate (Japanese)
Currently translated at 21.2% (30 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:09:41 +00:00
Genbuchan
c9c0ed85f8 Translated using Weblate (Japanese)
Currently translated at 21.2% (30 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:09:40 +00:00
Suguru Hirahara
e4b2180bc2 Translated using Weblate (Japanese)
Currently translated at 19.1% (27 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:09:04 +00:00
Genbuchan
cab8a71ac2 Translated using Weblate (Japanese)
Currently translated at 19.1% (27 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:09:04 +00:00
Suguru Hirahara
ff5ff175fd Translated using Weblate (Japanese)
Currently translated at 17.7% (25 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:08:27 +00:00
Genbuchan
26e1530882 Translated using Weblate (Japanese)
Currently translated at 17.7% (25 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:08:27 +00:00
Suguru Hirahara
35386b5e16 Translated using Weblate (Japanese)
Currently translated at 17.0% (24 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:08:17 +00:00
Genbuchan
18fe2daea7 Translated using Weblate (Japanese)
Currently translated at 17.0% (24 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:08:17 +00:00
Suguru Hirahara
5f24bf0b9c Translated using Weblate (Japanese)
Currently translated at 14.1% (20 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:07:45 +00:00
Genbuchan
990a08f4f6 Translated using Weblate (Japanese)
Currently translated at 14.1% (20 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:07:45 +00:00
Suguru Hirahara
af565ecd77 Translated using Weblate (Japanese)
Currently translated at 7.0% (10 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:05:44 +00:00
Genbuchan
e3d72e1104 Translated using Weblate (Japanese)
Currently translated at 7.0% (10 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:05:44 +00:00
Suguru Hirahara
29ea2cfe90 Translated using Weblate (Japanese)
Currently translated at 5.6% (8 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:04:22 +00:00
Genbuchan
008ecd7409 Translated using Weblate (Japanese)
Currently translated at 5.6% (8 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:04:21 +00:00
Suguru Hirahara
3220b030fb Translated using Weblate (Japanese)
Currently translated at 4.9% (7 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:03:57 +00:00
Genbuchan
0b1689e6f7 Translated using Weblate (Japanese)
Currently translated at 4.9% (7 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:03:57 +00:00
Suguru Hirahara
74255d0554 Translated using Weblate (Japanese)
Currently translated at 4.2% (6 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:03:43 +00:00
Genbuchan
a55046148f Translated using Weblate (Japanese)
Currently translated at 4.2% (6 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:03:43 +00:00
Genbuchan
6acbf792fc Translated using Weblate (Japanese)
Currently translated at 2.8% (4 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:02:49 +00:00
Suguru Hirahara
1cbda01051 Translated using Weblate (Japanese)
Currently translated at 2.1% (3 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:02:33 +00:00
Suguru Hirahara
f61b2db80d Translated using Weblate (Japanese)
Currently translated at 2.1% (3 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:02:33 +00:00
Genbuchan
17d273135f Translated using Weblate (Japanese)
Currently translated at 2.1% (3 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/ja/
2023-01-20 17:02:33 +00:00
Robin Townsend
9e8dadcc44 Add a demo screenshot to the README 2023-01-20 10:51:28 -05:00
David Baker
785756dc91 Merge pull request #862 from vector-im/dbkr/ptt_null_member_workarounds
Don't crash if we can't find our own member event
2023-01-20 13:20:04 +00:00
David Baker
785fa51e0c Use merged js-sdk commit 2023-01-20 13:12:03 +00:00
David Baker
47c2e9e101 Don't crash if we can't find our own member event 2023-01-20 12:10:58 +00:00
David Baker
0bb18be4ef Merge pull request #861 from vector-im/dbkr/dont_lowercase_room_ids
Fix joining rooms by ID
2023-01-20 10:57:46 +00:00
David Baker
81997624d4 Fix joining rooms by ID
We use this in embedded mode. Regressed by https://github.com/vector-im/element-call/pull/860

Lowercasing room IDs obviously makes them break, so… don't do that.
2023-01-20 10:51:52 +00:00
David Baker
c2883e52bb Merge pull request #860 from vector-im/dbkr/lowercase_room_alias
Lowercase room alias before joining
2023-01-20 09:43:28 +00:00
David Baker
7e1033f5a4 Add colon in comment
Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
2023-01-20 09:35:50 +00:00
David Baker
524f530dce Lowercase room alias before joining 2023-01-19 19:20:42 +00:00
Robin Townsend
46d1351d83 More fixes 2023-01-18 13:38:29 -05:00
Robin Townsend
2318d75bc7 prettier 2023-01-18 11:33:40 -05:00
Robin Townsend
486674c442 fixes 2023-01-18 11:32:51 -05:00
Robin Townsend
d3fba7fd5f WIP minus unfinished split grid layouts 2023-01-18 10:52:12 -05:00
David Baker
9437a00997 Merge pull request #853 from vector-im/dbkr/better_rageshake
Change rageshake to save much more regularly
2023-01-17 10:20:26 +00:00
David Baker
e1c4042d15 Add units to constant 2023-01-17 10:16:55 +00:00
David Baker
860aff4958 Change rageshake persistence to throttled flushing
Rather than every 30 seconds. This way we'll save logs for sessions
lasting less than 30 seconds which we previously didn't. Also save
on window unload just in case that doesn't catch everything.

Plus remove some more unused params.
2023-01-16 17:27:49 +00:00
David Baker
13b1dcf785 Mostly cosmetic fixes to rageshake
* Remove duplicate copyright header
 * Remove ts-ignores by just using the objects directly rather than via
   event.target
 * Use error.message rather than errorCode which TS doesn't know about
   and may or may not exist.
 * Remove some unused things like the skip rageshake function and
   the option to init rageshakes without storage.
 * Turn single function with a boolean param to make it take two entirely
   separate code paths into two functions.
2023-01-16 16:43:45 +00:00
Robin
035498a8eb Merge pull request #849 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2023-01-15 22:20:48 -05:00
Avery
030ca29664 Translated using Weblate (Spanish)
Currently translated at 100.0% (141 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/es/
2023-01-14 15:33:25 +00:00
David Baker
ecdeb97502 Merge pull request #848 from vector-im/dbkr/uncecryptable_todevice
Log undecryptable to-device events
2023-01-13 21:44:39 +00:00
David Baker
6168109894 Use merged js-sdk commit 2023-01-13 21:41:21 +00:00
David Baker
a2a1a9032a Use js-sdk from branch 2023-01-13 18:33:58 +00:00
David Baker
abd909c03a Log undecryptable to-device events
Listen for the new undecryptable to-device event event and log
events for it in Posthog & Sentry, and make it visible in the
call flow diagram.
2023-01-13 18:27:22 +00:00
David Baker
be1db442d9 Merge pull request #846 from vector-im/dbkr/prevent_keyrepeat_mute_spam
Prevent mute event spam from key repeats
2023-01-13 11:59:34 +00:00
David Baker
afdce66896 Merge remote-tracking branch 'origin/main' into dbkr/prevent_keyrepeat_mute_spam 2023-01-13 11:57:02 +00:00
David Baker
1b08a5cac3 Rename file 2023-01-13 11:56:29 +00:00
David Baker
843e7690fa Merge pull request #845 from vector-im/dbkr/disable_keyboard_shortcuts_rageshake
Disable keyboard shortcuts when feedback modal is open
2023-01-13 11:55:51 +00:00
David Baker
a5977fc992 Rename to useCallViewKeyboardShortcuts 2023-01-13 11:52:40 +00:00
David Baker
6277359d30 Merge pull request #844 from vector-im/dbkr/fix_cache
Fix caching headers on Docker image
2023-01-13 10:39:50 +00:00
David Baker
73682b67ba Merge pull request #843 from vector-im/dbkr/dev_mode_idb_no_worker
Use IndexedDB storage in dev mode, just without the worker
2023-01-13 10:14:01 +00:00
David Baker
f2193302c1 Prevent mute event spam from key repeats 2023-01-12 18:26:21 +00:00
David Baker
9ba4ce429f Disable keyboard shortcuts when feedback modal is open 2023-01-12 17:31:19 +00:00
David Baker
d9b0e08ea2 Fix caching headers on Docker image 2023-01-12 16:20:37 +00:00
David Baker
5f26534496 Use IndexedDB storage in dev mode, just without the worker
As per comment, we can't use workers in Vite dev mode. We previously
fell back to the memory store but this ends up with it working significantly
differently in dev mode to production, eg. dev mode would always start
by doing an initial sync, so old to-device messages would arrive again.

There's no need to fall all the way back to the memory store though,
we can use the IndexedDB store without the worker.
2023-01-12 15:17:46 +00:00
David Baker
30688715cd Revert f20fc78bd7 2023-01-12 13:20:11 +00:00
David Baker
f20fc78bd7 Use branch of js-sdk with Olm debugging
Pulls in changes from https://github.com/matrix-org/matrix-js-sdk/pull/3055

Not intended to stay long-term.
2023-01-12 11:28:15 +00:00
Robin
741233909d Merge pull request #829 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2023-01-09 13:35:43 -05:00
Robin
4e0f4a8dc7 Merge pull request #835 from robintown/unmuted-when-speaking
Work around mute state updates being slow
2023-01-09 13:35:05 -05:00
Šimon Brandner
0d151452ba Merge pull request #833 from vector-im/SimonBrandner/feat/hide-audio 2023-01-09 19:28:04 +01:00
Robin Townsend
4fd76f9599 Work around mute state updates being slow
Since the app already determines when someone is speaking, we can use that information to make it less obvious when to-device messages are being slow to deliver mute state updates.
2023-01-09 11:10:59 -05:00
Robin
d123793deb Merge pull request #832 from robintown/update-js-sdk
Update matrix-js-sdk
2023-01-09 10:52:19 -05:00
Robin Townsend
449c1c9d79 Try updating Olm to fix type errors 2023-01-09 10:49:01 -05:00
Robin Townsend
de5b58792e Update matrix-js-sdk 2023-01-09 10:32:05 -05:00
Robin Townsend
7769074410 Merge branch 'main' into update-js-sdk 2023-01-09 10:31:39 -05:00
Šimon Brandner
881054e265 Hide local volume controls for tiles with no audio
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2023-01-07 10:09:20 +01:00
Robin
767f9cdc4a Merge pull request #831 from robintown/no-video-mute
Leave audio elements unmuted regardless of mute state
2023-01-06 12:08:13 -05:00
Robin Townsend
946f564f84 Update matrix-js-sdk 2023-01-06 10:39:29 -05:00
Robin Townsend
468e389324 Leave audio elements unmuted regardless of mute state 2023-01-06 10:26:10 -05:00
Jozef Gaal
62e98f6c47 Translated using Weblate (Slovak)
Currently translated at 100.0% (141 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/sk/
2023-01-06 09:33:22 +00:00
Priit Jõerüüt
de31c099e3 Translated using Weblate (Estonian)
Currently translated at 100.0% (141 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/et/
2023-01-06 09:33:22 +00:00
Ihor Hordiichuk
49cae76387 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (141 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/uk/
2023-01-06 09:33:22 +00:00
Glandos
d45ea78ddb Translated using Weblate (French)
Currently translated at 100.0% (141 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/fr/
2023-01-06 09:33:22 +00:00
Linerly
dcbc3ed865 Translated using Weblate (Indonesian)
Currently translated at 100.0% (141 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/id/
2023-01-06 09:33:22 +00:00
Vri
ff19135d4e Translated using Weblate (German)
Currently translated at 100.0% (141 of 141 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2023-01-06 09:33:22 +00:00
Robin
de7343d16a Merge pull request #821 from robintown/save-lockfile
Save lockfile
2023-01-05 10:35:52 -05:00
Robin Townsend
c09fec5f88 Save lockfile 2023-01-04 08:25:26 -05:00
62 changed files with 2557 additions and 483 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ dist-ssr
*.local
.idea/
public/config.json
/coverage

View File

@@ -5,6 +5,8 @@
Full mesh group calls powered by [Matrix](https://matrix.org), implementing [MatrixRTC](https://github.com/matrix-org/matrix-spec-proposals/blob/matthew/group-voip/proposals/3401-group-voip.md).
![A demo of Element Call with six people](demo.jpg)
To try it out, visit our hosted version at [call.element.io](https://call.element.io). You can also find the latest development version continuously deployed to [element-call.netlify.app](https://element-call.netlify.app).
## Host it yourself
@@ -40,6 +42,28 @@ server {
}
```
By default, the app expects you to have a Matrix homeserver (such as [Synapse](https://matrix-org.github.io/synapse/latest/setup/installation.html)) installed locally and running on port 8008. If you wish to use a homeserver on a different URL or one that is hosted on a different server, you can add a config file as above, and include the homeserver URL that you'd like to use.
Element Call requires a homeserver with registration enabled without any 3pid or token requirements, if you want it to be used by unregistered users. Furthermore, it is not recommended to use it with an existing homeserver where user accounts have joined normal rooms, as it may not be able to handle those yet and it may behave unreliably.
Therefore, to use a self-hosted homeserver, this is recommended to be a new server where any user account created has not joined any normal rooms anywhere in the Matrix federated network. The homeserver used can be setup to disable federation, so as to prevent spam registrations (if you keep registrations open) and to ensure Element Call continues to work in case any user decides to log in to their Element Call account using the standard Element app and joins normal rooms that Element Call cannot handle.
### Features
#### Allow joining group calls without a camera and a microphone
You can allow joining a group call without video and audio enabling this feature in your `config.json`:
```json
{
...
"features": {
"feature_group_calls_without_video_and_audio": true
}
}
```
## Development
Element Call is built against [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/pull/2553). To get started, clone, install, and link the package:
@@ -60,8 +84,6 @@ yarn
yarn link matrix-js-sdk
```
By default, the app expects you to have [Synapse](https://matrix-org.github.io/synapse/latest/setup/installation.html) installed locally and running on port 8008. If you wish to use another homeserver, you can add a config file as above.
You're now ready to launch the development server:
```

View File

@@ -5,6 +5,10 @@
"server_name": "call.ems.host"
}
},
"posthog": {
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
"api_host": "https://posthog-element-call.element.io"
},
"rageshake": {
"submit_url": "https://element.io/bugreports/submit"
}

View File

@@ -2,9 +2,24 @@ server {
listen 8080;
server_name localhost;
location / {
root /app;
location / {
# disable cache entriely by default (apart from Etag which is accurate enough)
add_header Cache-Control 'private no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
if_modified_since off;
expires off;
# also turn off last-modified since they are just the timestamps of the file in the docker image
# and may or may not bear any resemblance to when the resource changed
add_header Last-Modified "";
try_files $uri /$uri /index.html;
}
# assets can be cached because they have hashed filenames
location /assets {
expires 1w;
add_header Cache-Control "public, no-transform";
}
}

BIN
demo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

View File

@@ -18,7 +18,7 @@
},
"dependencies": {
"@juggle/resize-observer": "^3.3.1",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
"@react-aria/button": "^3.3.4",
"@react-aria/dialog": "^3.1.4",
"@react-aria/focus": "^3.5.0",
@@ -45,7 +45,8 @@
"i18next": "^21.10.0",
"i18next-browser-languagedetector": "^6.1.8",
"i18next-http-backend": "^1.4.4",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#2c8eece5ca5333c6e6a14e8ed53f359ed0e9e9bf",
"lodash": "^4.17.21",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#8cbbdaa239e449848e8874f041ef1879c1956696",
"matrix-widget-api": "^1.0.0",
"mermaid": "^8.13.8",
"normalize.css": "^8.0.1",
@@ -62,6 +63,7 @@
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"sdp-transform": "^2.14.1",
"tinyqueue": "^2.0.3",
"unique-names-generator": "^4.6.0"
},
"devDependencies": {

View File

@@ -136,5 +136,8 @@
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Proč neskončit nastavením hesla, abyste mohli účet použít znovu?</0><1>Budete si moci nechat své jméno a nastavit si avatar pro budoucí hovory </1>",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Připojit se</0><1>Or</1><2>Zkopírovat odkaz a připojit se později</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Už máte účet?</0><1><0>Přihlásit se</0> Or <2>Jako host</2></1>",
"{{name}} (Waiting for video...)": "{{name}} (Čekání na video...)"
"{{name}} (Waiting for video...)": "{{name}} (Čekání na video...)",
"This feature is only supported on Firefox.": "Tato funkce je podporována jen ve Firefoxu.",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Odeslání ladících záznamů nám pomůže diagnostikovat problém.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Oops, něco se pokazilo.</0>"
}

View File

@@ -137,5 +137,7 @@
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Ob Tastenkürzel mit nur einer Taste aktiviert sein sollen, z. B. „m“ um das Mikrofon stumm/aktiv zu schalten.",
"Single-key keyboard shortcuts": "Ein-Tasten-Tastenkürzel",
"{{name}} (Waiting for video...)": "{{name}} (Warte auf Video …)",
"This feature is only supported on Firefox.": "Diese Funktion wird nur in Firefox unterstützt."
"This feature is only supported on Firefox.": "Diese Funktion wird nur in Firefox unterstützt.",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Übermittelte Problemberichte helfen uns, Fehler zu beheben.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Hoppla, etwas ist schiefgelaufen.</0>"
}

View File

@@ -16,13 +16,12 @@
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",
"Accept camera/microphone permissions to join the call.": "Accept camera/microphone permissions to join the call.",
"Accept microphone permissions to join the call.": "Accept microphone permissions to join the call.",
"Advanced": "Advanced",
"Allow analytics": "Allow analytics",
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.",
"Audio": "Audio",
"Avatar": "Avatar",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "By clicking \"Go\", you agree to our <2>Terms and conditions</2>",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>",
"By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our ": "By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our ",
"Call link copied": "Call link copied",
"Call type menu": "Call type menu",
"Camera": "Camera",
@@ -41,10 +40,12 @@
"Description (optional)": "Description (optional)",
"Details": "Details",
"Developer": "Developer",
"Developer Settings": "Developer Settings",
"Display name": "Display name",
"Download debug logs": "Download debug logs",
"Element Call Home": "Element Call Home",
"Exit full screen": "Exit full screen",
"Expose developer settings in the settings window.": "Expose developer settings in the settings window.",
"Fetching group call timed out.": "Fetching group call timed out.",
"Freedom": "Freedom",
"Full screen": "Full screen",
@@ -84,6 +85,7 @@
"Press and hold spacebar to talk over {{name}}": "Press and hold spacebar to talk over {{name}}",
"Press and hold to talk": "Press and hold to talk",
"Press and hold to talk over {{name}}": "Press and hold to talk over {{name}}",
"Privacy Policy": "Privacy Policy",
"Profile": "Profile",
"Recaptcha dismissed": "Recaptcha dismissed",
"Recaptcha not loaded": "Recaptcha not loaded",
@@ -120,10 +122,10 @@
"This feature is only supported on Firefox.": "This feature is only supported on Firefox.",
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>",
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)",
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.",
"Turn off camera": "Turn off camera",
"Turn on camera": "Turn on camera",
"Unmute microphone": "Unmute microphone",
"Use the upcoming grid system": "Use the upcoming grid system",
"User ID": "User ID",
"User menu": "User menu",
"Username": "Username",

View File

@@ -136,5 +136,8 @@
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "Esto enviará datos anónimos (como la duración de la llamada y el número de participantes) al equipo de Element Call para ayudarnos a optimizar la aplicación dependiendo de cómo se use.",
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Habilita los atajos de teclado de una sola tecla, por ejemplo 'm' para silenciar/desilenciar el micrófono.",
"Single-key keyboard shortcuts": "Atajos de teclado de una sola tecla",
"{{name}} (Waiting for video...)": "{{name}} (Esperando al video...)"
"{{name}} (Waiting for video...)": "{{name}} (Esperando al video...)",
"This feature is only supported on Firefox.": "Esta característica solo está disponible en Firefox.",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Subir los registros de depuración nos ayudará a encontrar el problema.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Ups, algo ha salido mal.</0>"
}

View File

@@ -137,5 +137,7 @@
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Kas kasutame üheklahvilisi kiirklahve, näiteks „m“ mikrofoni sisse/välja lülitamiseks.",
"Single-key keyboard shortcuts": "Üheklahvilised kiirklahvid",
"{{name}} (Waiting for video...)": "{{name}} (Ootame videovoo algust...)",
"This feature is only supported on Firefox.": "See funktsionaalsus on toetatud vaid Firefoxis."
"This feature is only supported on Firefox.": "See funktsionaalsus on toetatud vaid Firefoxis.",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Kui saadad meile vealogid, siis on lihtsam vea põhjust otsida.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Ohoo, midagi on nüüd katki.</0>"
}

View File

@@ -137,5 +137,7 @@
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Bascule sur les raccourcis clavier à touche unique, par exemple « m » pour désactiver / activer le micro.",
"Single-key keyboard shortcuts": "Raccourcis clavier en une touche",
"{{name}} (Waiting for video...)": "{{name}} (En attente de vidéo…)",
"This feature is only supported on Firefox.": "Cette fonctionnalité est prise en charge dans Firefox uniquement."
"This feature is only supported on Firefox.": "Cette fonctionnalité est prise en charge dans Firefox uniquement.",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Soumettre les journaux de débogage nous aidera à déterminer le problème.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Oups, quelque chose sest mal passé.</0>"
}

View File

@@ -137,5 +137,7 @@
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Apakah pintasan papan ketik seharusnya diaktifkan, mis. 'm' untuk membisukan/menyuarakan mikrofon.",
"Single-key keyboard shortcuts": "Pintasan papan ketik satu tombol",
"{{name}} (Waiting for video...)": "{{name}} (Menunggu video...)",
"This feature is only supported on Firefox.": "Fitur ini hanya didukung di Firefox."
"This feature is only supported on Firefox.": "Fitur ini hanya didukung di Firefox.",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Mengirim catatan pengawakutuan akan membantu kami melacak masalahnya.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Aduh, ada yang salah.</0>"
}

View File

@@ -1 +1,107 @@
{}
{
"{{name}} (Connecting...)": "{{name}}(接続しています…)",
"{{count}} people connected|other": "{{count}}人が接続済",
"{{count}} people connected|one": "{{count}}人が接続済",
"{{name}} (Waiting for video...)": "{{name}}(ビデオを待機しています…)",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>既にアカウントをお持ちですか?</0><1><0>ログイン</0>または<2>ゲストとしてアクセス</2></1>",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - トランシーバー通話",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>アカウントを作成</0>または<2>ゲストとしてアクセス</2>",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>今すぐ通話に参加</0><1>または</1><2>通話リンクをコピーし、後で参加</2>",
"Accept camera/microphone permissions to join the call.": "通話に参加するには、カメラ・マイクの許可が必要です。",
"<0>Oops, something's gone wrong.</0>": "<0>何かがうまく行きませんでした。</0>",
"Camera/microphone permissions needed to join the call.": "通話に参加する場合、カメラ・マイクの許可が必要です。",
"Allow analytics": "アナリティクスを許可",
"Camera": "カメラ",
"Call link copied": "通話リンクをコピーしました",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "「今すぐ通話に参加」をクリックすると、<2>利用規約</2>に同意したとみなされます",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "「続行」をクリックすると、 <2>利用規約</2>に同意したとみなされます",
"Avatar": "アバター",
"Accept microphone permissions to join the call.": "通話に参加するには、マイクの許可が必要です。",
"Audio": "音声",
"Advanced": "高度",
"Connection lost": "接続が切断されました",
"Confirm password": "パスワードを確認",
"Close": "閉じる",
"Change layout": "レイアウトを変更",
"Copied!": "コピーしました!",
"Copy and share this call link": "通話リンクをコピーし共有",
"Copy": "コピー",
"Description (optional)": "概要(任意)",
"Debug log": "デバッグログ",
"Create account": "アカウントを作成",
"Having trouble? Help us fix it.": "問題が起きましたか?修正にご協力ください。",
"Go": "続行",
"Fetching group call timed out.": "グループ通話の取得がタイムアウトしました。",
"Element Call Home": "Element Call ホーム",
"Download debug logs": "デバッグログをダウンロード",
"Display name": "表示名",
"Developer": "開発者",
"Details": "詳細",
"Full screen": "全画面表示",
"Exit full screen": "全画面表示を終了",
"Include debug logs": "デバッグログを含める",
"Home": "ホーム",
"Incompatible versions!": "互換性のないバージョンです!",
"Incompatible versions": "互換性のないバージョン",
"Join existing call?": "既存の通話に参加しますか?",
"Join call now": "今すぐ通話に参加",
"Join call": "通話に参加",
"Invite": "招待",
"Invite people": "連絡先を招待",
"Not registered yet? <2>Create an account</2>": "アカウントがありませんか? <2>アカウントを作成</2>",
"Mute microphone": "マイクをミュート",
"Microphone permissions needed to join the call.": "通話の参加にはマイクの許可が必要です。",
"Microphone": "マイク",
"Login": "ログイン",
"Logging in…": "ログインしています…",
"Loading…": "読み込んでいます…",
"Loading room…": "ルームを読み込んでいます…",
"Leave": "退出",
"Version: {{version}}": "バージョン:{{version}}",
"Username": "ユーザー名",
"User menu": "ユーザーメニュー",
"User ID": "ユーザーID",
"Unmute microphone": "マイクのミュートを解除",
"Turn on camera": "カメラをつける",
"Turn off camera": "カメラを切る",
"Submitting feedback…": "フィードバックを送信しています…",
"Submit feedback": "フィードバックを送信",
"Stop sharing screen": "画面共有を停止",
"Spotlight": "スポットライト",
"Send debug logs": "デバッグログを送信",
"Sign out": "サインアウト",
"Sign in": "サインイン",
"Share screen": "画面共有",
"Settings": "設定",
"Sending…": "送信しています…",
"Sending debug logs…": "デバッグログを送信しています…",
"Saving…": "保存しています…",
"Save": "保存",
"Return to home screen": "ホーム画面に戻る",
"Registering…": "登録しています…",
"Register": "登録",
"Profile": "プロフィール",
"Press and hold spacebar to talk": "スペースを長押しで会話",
"Passwords must match": "パスワードが一致する必要があります",
"Password": "パスワード",
"Speaker": "スピーカー",
"Video call name": "ビデオ通話の名称",
"Video call": "ビデオ通話",
"Video": "ビデオ",
"Waiting for other participants…": "他の参加者を待機しています…",
"Waiting for network": "ネットワークを待機しています",
"Walkie-talkie call name": "トランシーバー通話の名称",
"Walkie-talkie call": "トランシーバー通話",
"Camera {{n}}": "カメラ {{n}}",
"{{name}} is talking…": "{{name}}が話しています…",
"Yes, join call": "はい、通話に参加",
"Spatial audio": "空間オーディオ",
"Select an option": "オプションを選択",
"Debug log request": "デバッグログを要求",
"Your recent calls": "最近の通話",
"You can't talk at the same time": "同時に会話することはできません",
"WebRTC is not supported or is being blocked in this browser.": "お使いのブラウザでWebRTCがサポートされていないか、またはブロックされています。",
"Login to your account": "お持ちのアカウントでログイン",
"Freedom": "自由",
"{{displayName}}, your call is now ended": "{{displayName}}、通話が終了しました"
}

View File

@@ -124,5 +124,16 @@
"{{name}} is talking…": "{{name}} mówi…",
"{{name}} is presenting": "{{name}} prezentuje",
"{{displayName}}, your call is now ended": "{{displayName}}, twoje połączenie zostało zakończone",
"{{count}} people connected|one": "{{count}} osoba połączona"
"{{count}} people connected|one": "{{count}} osoba połączona",
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Czy włączyć skróty klawiszowe pojedynczych klawiszy, np. 'm' aby wyciszyć/załączyć mikrofon.",
"This feature is only supported on Firefox.": "Ta funkcjonalność jest dostępna tylko w Firefox.",
"Single-key keyboard shortcuts": "Skróty klawiszowe (pojedyncze klawisze)",
"Copy": "Kopiuj",
"Allow analytics": "Zezwól na analitykę",
"Advanced": "Zaawansowane",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Wysłanie logów debuggowania pomoże nam ustalić przyczynę problemu.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Ojej, coś poszło nie tak.</0>",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Dołącz do rozmowy teraz</0><1>Or</1><2>Skopiuj link do rozmowy i dołącz później</2>",
"{{name}} (Waiting for video...)": "{{name}} (Oczekiwanie na wideo...)",
"{{name}} (Connecting...)": "{{name}} (Łączenie...)"
}

View File

@@ -132,6 +132,12 @@
"Copy": "Копировать",
"Allow analytics": "Разрешить аналитику",
"Advanced": "Расширенные",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Присоединиться сейчас</0><1>или<1><2>Скопировать ссылку на звонок и присоединиться позже</2>",
"{{name}} (Connecting...)": "{{name}} (Соединение...)"
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Присоединиться сейчас</0><1>или<1><2>cкопировать ссылку на звонок и присоединиться позже</2>",
"{{name}} (Connecting...)": "{{name}} (Соединение...)",
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Включить горячие клавиши, например 'm' чтобы отключить/включить микрофон.",
"This feature is only supported on Firefox.": "Эта возможность доступна только в Firefox.",
"Single-key keyboard shortcuts": "Горячие клавиши",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Отправка журналов поможет нам найти и устранить проблему.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Упс, что-то пошло не так.</0>",
"{{name}} (Waiting for video...)": "{{name}} (Ожидание видео...)"
}

View File

@@ -137,5 +137,7 @@
"{{displayName}}, your call is now ended": "{{displayName}}, váš hovor je teraz ukončený",
"{{count}} people connected|other": "{{count}} osôb pripojených",
"{{count}} people connected|one": "{{count}} osoba pripojená",
"This feature is only supported on Firefox.": "Táto funkcia je podporovaná len v prehliadači Firefox."
"This feature is only supported on Firefox.": "Táto funkcia je podporovaná len v prehliadači Firefox.",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Odoslanie záznamov ladenia nám pomôže nájsť problém.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Hups, niečo sa pokazilo.</0>"
}

View File

@@ -137,5 +137,7 @@
"Whether to enable single-key keyboard shortcuts, e.g. 'm' to mute/unmute the mic.": "Чи вмикати/вимикати мікрофон однією клавішею, наприклад, «m» для ввімкнення/вимкнення мікрофона.",
"Single-key keyboard shortcuts": "Одноклавішні комбінації клавіш",
"{{name}} (Waiting for video...)": "{{name}} (Очікування на відео...)",
"This feature is only supported on Firefox.": "Ця функція підтримується лише в браузері Firefox."
"This feature is only supported on Firefox.": "Ця функція підтримується лише в браузері Firefox.",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Надсилання журналів зневадження допоможе нам виявити проблему.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Йой, щось пішло не за планом.</0>"
}

View File

@@ -36,7 +36,10 @@ import {
fallbackICEServerAllowed,
} from "./matrix-utils";
import { widget } from "./widget";
import { PosthogAnalytics, RegistrationType } from "./PosthogAnalytics";
import {
PosthogAnalytics,
RegistrationType,
} from "./analytics/PosthogAnalytics";
import { translatedError } from "./TranslatedError";
import { useEventTarget } from "./useEvents";
import { Config } from "./config/Config";

View File

@@ -0,0 +1,20 @@
import { t } from "i18next";
import React from "react";
import { Link } from "../typography/Typography";
export const optInDescription: () => JSX.Element = () => {
return (
<>
<>
{t(
"By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our "
)}
</>
<Link color="primary" href="https://element.io/privacy">
<>{t("Privacy Policy")}</>
</Link>
.
</>
);
};

View File

@@ -19,8 +19,8 @@ import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient } from "matrix-js-sdk";
import { Buffer } from "buffer";
import { widget } from "./widget";
import { getSetting, setSetting, settingsBus } from "./settings/useSetting";
import { widget } from "../widget";
import { getSetting, setSetting, settingsBus } from "../settings/useSetting";
import {
CallEndedTracker,
CallStartedTracker,
@@ -28,9 +28,10 @@ import {
SignupTracker,
MuteCameraTracker,
MuteMicrophoneTracker,
UndecryptableToDeviceEventTracker,
} from "./PosthogEvents";
import { Config } from "./config/Config";
import { getUrlParams } from "./UrlParams";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
/* Posthog analytics tracking.
*
@@ -93,7 +94,7 @@ export class PosthogAnalytics {
private static ANALYTICS_EVENT_TYPE = "im.vector.analytics";
// set true during the constructor if posthog config is present, otherwise false
private static internalInstance = null;
private static internalInstance: PosthogAnalytics | null = null;
private identificationPromise: Promise<void>;
private readonly enabled: boolean = false;
@@ -136,6 +137,9 @@ export class PosthogAnalytics {
});
this.enabled = true;
} else {
logger.info(
"Posthog is not enabled because there is no api key or no host given in the config"
);
this.enabled = false;
}
this.startListeningToSettingsChanges();
@@ -224,9 +228,7 @@ export class PosthogAnalytics {
}
public async identifyUser(analyticsIdGenerator: () => string) {
// There might be a better way to get the client here.
if (this.anonymity == Anonymity.Pseudonymous) {
if (this.anonymity == Anonymity.Pseudonymous && this.enabled) {
// Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows
// different devices to send the same ID.
let analyticsID = await this.getAnalyticsId();
@@ -415,4 +417,5 @@ export class PosthogAnalytics {
public eventLogin = new LoginTracker();
public eventMuteMicrophone = new MuteMicrophoneTracker();
public eventMuteCamera = new MuteCameraTracker();
public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker();
}

View File

@@ -149,3 +149,17 @@ export class MuteCameraTracker {
});
}
}
interface UndecryptableToDeviceEvent {
eventName: "UndecryptableToDeviceEvent";
callId: string;
}
export class UndecryptableToDeviceEventTracker {
track(callId: string) {
PosthogAnalytics.instance.trackEvent<UndecryptableToDeviceEvent>({
eventName: "UndecryptableToDeviceEvent",
callId,
});
}
}

View File

@@ -25,7 +25,7 @@ import { Button } from "../button";
import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { Config } from "../config/Config";
export const LoginPage: FC = () => {

View File

@@ -38,7 +38,7 @@ import { LoadingView } from "../FullScreenView";
import { useRecaptcha } from "./useRecaptcha";
import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { Config } from "../config/Config";
export const RegisterPage: FC = () => {

View File

@@ -44,6 +44,14 @@ export interface ConfigOptions {
server_name: string;
};
};
/**
* Allow to join a group calls without audio and video.
* TEMPORARY: Is a feature that's not proved and experimental
*/
features?: {
feature_group_calls_without_video_and_audio: boolean;
};
}
// Overrides members from ConfigOptions that are always provided by the

View File

@@ -24,7 +24,11 @@ import { useHistory } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import {
createRoom,
roomAliasLocalpartFromRoomName,
sanitiseRoomNameInput,
} from "../matrix-utils";
import { useGroupCallRooms } from "./useGroupCallRooms";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import commonStyles from "./common.module.css";
@@ -38,6 +42,8 @@ import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { Title } from "../typography/Typography";
import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import { useOptInAnalytics } from "../settings/useSetting";
import { optInDescription } from "../analytics/AnalyticsOptInDescription";
interface Props {
client: MatrixClient;
@@ -48,6 +54,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const history = useHistory();
const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState();
@@ -57,7 +64,10 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const roomNameData = data.get("callName");
const roomName = typeof roomNameData === "string" ? roomNameData : "";
const roomName =
typeof roomNameData === "string"
? sanitiseRoomNameInput(roomNameData)
: "";
const ptt = callType === CallType.Radio;
async function submit() {
@@ -134,6 +144,15 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
{loading ? t("Loading…") : t("Go")}
</Button>
</FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics}
description={optInDescription()}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
{error && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage error={error} />

View File

@@ -24,7 +24,11 @@ import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { UserMenuContainer } from "../UserMenuContainer";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import {
createRoom,
roomAliasLocalpartFromRoomName,
sanitiseRoomNameInput,
} from "../matrix-utils";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
@@ -35,12 +39,15 @@ import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName";
import { useOptInAnalytics } from "../settings/useSetting";
import { optInDescription } from "../analytics/AnalyticsOptInDescription";
export const UnauthenticatedView: FC = () => {
const { setClient } = useClient();
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
@@ -54,7 +61,7 @@ export const UnauthenticatedView: FC = () => {
(e) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const roomName = data.get("callName") as string;
const roomName = sanitiseRoomNameInput(data.get("callName") as string);
const displayName = data.get("displayName") as string;
const ptt = callType === CallType.Radio;
@@ -148,6 +155,15 @@ export const UnauthenticatedView: FC = () => {
autoComplete="off"
/>
</FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics}
description={optInDescription()}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
<Caption>
<Trans>
By clicking "Go", you agree to our{" "}

View File

@@ -214,5 +214,5 @@ export class Initializer {
resolve();
}
}
private initPromise: Promise<void>;
private initPromise: Promise<void> | null;
}

View File

@@ -209,3 +209,7 @@ limitations under the License.
margin-left: 26px;
width: 100%; /* Ensure that it breaks onto the next row */
}
.description.noLabel {
margin-top: -20px; /* Ensures that there is no weired spacing if the checkbox doesn't have a label */
}

View File

@@ -55,14 +55,14 @@ export function Field({ children, className }: FieldProps): JSX.Element {
}
interface InputFieldProps {
label: string;
label?: string;
type: string;
prefix?: string;
suffix?: string;
id?: string;
checked?: boolean;
className?: string;
description?: string;
description?: string | ReactNode;
disabled?: boolean;
required?: boolean;
// this is a hack. Those variables should be part of `HTMLAttributes<HTMLInputElement> | HTMLAttributes<HTMLTextAreaElement>`
@@ -140,7 +140,14 @@ export const InputField = forwardRef<
</label>
{suffix && <span>{suffix}</span>}
{description && (
<p id={descriptionId} className={styles.description}>
<p
id={descriptionId}
className={
label
? styles.description
: classNames(styles.description, styles.noLabel)
}
>
{description}
</p>
)}

View File

@@ -92,17 +92,26 @@ export async function initClient(
indexedDB = window.indexedDB;
} catch (e) {}
const storeOpts = {} as ICreateClientOpts;
const baseOpts = {
fallbackICEServerAllowed: fallbackICEServerAllowed,
isVoipWithNoMediaAllowed:
Config.get().features?.feature_group_calls_without_video_and_audio,
} as ICreateClientOpts;
if (indexedDB && localStorage && !import.meta.env.DEV) {
storeOpts.store = new IndexedDBStore({
if (indexedDB && localStorage) {
baseOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage,
dbName: SYNC_STORE_NAME,
workerFactory: () => new IndexedDBWorker(),
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
// in dev mode: it expects them to use native modules. Ours don't, and even then only
// Chrome supports it. (It bundles them fine in production mode.)
workerFactory: import.meta.env.DEV
? undefined
: () => new IndexedDBWorker(),
});
} else if (localStorage) {
storeOpts.store = new MemoryStore({ localStorage });
baseOpts.store = new MemoryStore({ localStorage });
}
// Check whether we have crypto data store. If we are restoring a session
@@ -134,14 +143,14 @@ export async function initClient(
}
if (indexedDB) {
storeOpts.cryptoStore = new IndexedDBCryptoStore(
baseOpts.cryptoStore = new IndexedDBCryptoStore(
indexedDB,
CRYPTO_STORE_NAME
);
} else if (localStorage) {
storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
baseOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
} else {
storeOpts.cryptoStore = new MemoryCryptoStore();
baseOpts.cryptoStore = new MemoryCryptoStore();
}
// XXX: we read from the URL params in RoomPage too:
@@ -155,7 +164,7 @@ export async function initClient(
}
const client = createClient({
...storeOpts,
...baseOpts,
...clientOptions,
useAuthorizationHeader: true,
// Use a relatively low timeout for API calls: this is a realtime app
@@ -206,6 +215,37 @@ export function fullAliasFromRoomName(
return `#${roomAliasLocalpartFromRoomName(roomName)}:${client.getDomain()}`;
}
/**
* Applies some basic sanitisation to a room name that the user
* has given us
* @param input The room name from the user
* @param client A matrix client object
*/
export function sanitiseRoomNameInput(input: string): string {
// check to see if the user has enetered a fully qualified room
// alias. If so, turn it into just the localpart because that's what
// we use
const parts = input.split(":", 2);
if (parts.length === 2 && parts[0][0] === "#") {
// looks like a room alias
if (parts[1] === Config.defaultServerName()) {
// it's local to our own homeserver
return parts[0];
} else {
throw new Error("Unsupported remote room alias");
}
}
// that's all we do here right now
return input;
}
/**
* XXX: What is this trying to do? It looks like it's getting the localpart from
* a room alias, but why is it splitting on hyphens and then putting spaces in??
* @param roomId
* @returns
*/
export function roomNameFromRoomId(roomId: string): string {
return roomId
.match(/([^:]+):.*$/)[1]

View File

@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Sentry from "@sentry/react";
import { Resizable } from "re-resizable";
import React, {
useEffect,
@@ -34,6 +35,7 @@ import { CallEvent } from "matrix-js-sdk/src/webrtc/call";
import styles from "./GroupCallInspector.module.css";
import { SelectInput } from "../input/SelectInput";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
interface InspectorContextState {
eventsByUserId?: { [userId: string]: SequenceDiagramMatrixEvent[] };
@@ -108,6 +110,19 @@ function formatTimestamp(timestamp: number | Date) {
return dateFormatter.format(timestamp);
}
function formatType(event: SequenceDiagramMatrixEvent): string {
if (event.content.msgtype === "m.bad.encrypted") return "Undecryptable";
return event.type;
}
function lineForEvent(event: SequenceDiagramMatrixEvent): string {
return `${getUserName(event.from)} ${
event.ignored ? "-x" : "->>"
} ${getUserName(event.to)}: ${formatTimestamp(event.timestamp)} ${formatType(
event
)} ${formatContent(event.type, event.content)}`;
}
export const InspectorContext =
createContext<
[
@@ -187,21 +202,7 @@ export function SequenceDiagramViewer({
participant ${getUserName(localUserId)}
participant Room
participant ${selectedUserId ? getUserName(selectedUserId) : "unknown"}
${
events
? events
.map(
({ to, from, timestamp, type, content, ignored }) =>
`${getUserName(from)} ${ignored ? "-x" : "->>"} ${getUserName(
to
)}: ${formatTimestamp(timestamp)} ${type} ${formatContent(
type,
content
)}`
)
.join("\n ")
: ""
}
${events ? events.map(lineForEvent).join("\n ") : ""}
`;
mermaid.mermaidAPI.render("mermaid", graphDefinition, (svgCode: string) => {
@@ -389,12 +390,23 @@ function useGroupCallState(
function onSendVoipEvent(event: Record<string, unknown>) {
dispatch({ type: CallEvent.SendVoipEvent, rawEvent: event });
}
function onUndecryptableToDevice(event: MatrixEvent) {
dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
Sentry.captureMessage("Undecryptable to-device Event");
PosthogAnalytics.instance.eventUndecryptableToDevice.track(
groupCall.groupCallId
);
}
client.on(RoomStateEvent.Events, onUpdateRoomState);
//groupCall.on("calls_changed", onCallsChanged);
groupCall.on(CallEvent.SendVoipEvent, onSendVoipEvent);
//client.on("state", onCallsChanged);
//client.on("hangup", onCallHangup);
client.on(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
client.on(ClientEvent.UndecryptableToDeviceEvent, onUndecryptableToDevice);
onUpdateRoomState();
@@ -405,6 +417,10 @@ function useGroupCallState(
//client.removeListener("state", onCallsChanged);
//client.removeListener("hangup", onCallHangup);
client.removeListener(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
client.removeListener(
ClientEvent.UndecryptableToDeviceEvent,
onUndecryptableToDevice
);
};
}, [client, groupCall]);

View File

@@ -32,7 +32,7 @@ import { CallEndedView } from "./CallEndedView";
import { useRoomAvatar } from "./useRoomAvatar";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useMediaHandler } from "../settings/useMediaHandler";
import { findDeviceByName, getDevices } from "../media-utils";
@@ -75,6 +75,7 @@ export function GroupCallView({
toggleLocalVideoMuted,
toggleMicrophoneMuted,
toggleScreensharing,
setMicrophoneMuted,
requestingScreenshare,
isScreensharing,
screenshareFeeds,
@@ -251,6 +252,7 @@ export function GroupCallView({
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
setMicrophoneMuted={setMicrophoneMuted}
userMediaFeeds={userMediaFeeds}
activeSpeaker={activeSpeaker}
onLeave={onLeave}

View File

@@ -20,9 +20,10 @@ limitations under the License.
flex-direction: column;
overflow: hidden;
min-height: 100%;
position: fixed;
height: 100%;
width: 100%;
--footerPadding: 8px;
--footerHeight: calc(50px + 2 * var(--footerPadding));
}
.centerMessage {
@@ -39,11 +40,27 @@ limitations under the License.
}
.footer {
position: relative;
position: absolute;
left: 0;
bottom: 0;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
height: calc(50px + 2 * 8px);
padding: var(--footerPadding) 0;
/* TODO: Un-hardcode these colors */
background: linear-gradient(
360deg,
#15191e 0%,
rgba(21, 25, 30, 0.9) 37%,
rgba(21, 25, 30, 0.8) 49.68%,
rgba(21, 25, 30, 0.7) 56.68%,
rgba(21, 25, 30, 0.427397) 72.92%,
rgba(21, 25, 30, 0.257534) 81.06%,
rgba(21, 25, 30, 0.136986) 87.29%,
rgba(21, 25, 30, 0.0658079) 92.4%,
rgba(21, 25, 30, 0) 100%
);
}
.footer > * {
@@ -65,16 +82,22 @@ limitations under the License.
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* To make avatars scale smoothly with their tiles during animations, we
override the styles set on the element */
--avatarSize: calc(min(var(--tileWidth), var(--tileHeight)) / 2);
width: var(--avatarSize) !important;
height: var(--avatarSize) !important;
border-radius: 10000px !important;
}
@media (min-height: 300px) {
.footer {
height: calc(50px + 2 * 24px);
.inRoom {
--footerPadding: 24px;
}
}
@media (min-width: 800px) {
.footer {
height: calc(50px + 2 * 32px);
.inRoom {
--footerPadding: 32px;
}
}

View File

@@ -41,7 +41,11 @@ import {
RoomHeaderInfo,
VersionMismatchWarning,
} from "../Header";
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
import {
VideoGrid,
useVideoGridLayout,
ChildrenProperties,
} from "../video-grid/VideoGrid";
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
@@ -51,11 +55,15 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { useMediaHandler } from "../settings/useMediaHandler";
import { useShowInspector, useSpatialAudio } from "../settings/useSetting";
import {
useNewGrid,
useShowInspector,
useSpatialAudio,
} from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { useAudioContext } from "../video-grid/useMediaStream";
import { useFullscreen } from "../video-grid/useFullscreen";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { widget, ElementWidgetActions } from "../widget";
import { useJoinRule } from "./useJoinRule";
import { useUrlParams } from "../UrlParams";
@@ -63,6 +71,8 @@ import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { ParticipantInfo } from "./useGroupCall";
import { TileDescriptor } from "../video-grid/TileDescriptor";
import { AudioSink } from "../video-grid/AudioSink";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -81,6 +91,7 @@ interface Props {
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
toggleScreensharing: () => void;
setMicrophoneMuted: (muted: boolean) => void;
userMediaFeeds: CallFeed[];
activeSpeaker: CallFeed | null;
onLeave: () => void;
@@ -101,6 +112,7 @@ export function InCallView({
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
setMicrophoneMuted,
userMediaFeeds,
activeSpeaker,
onLeave,
@@ -141,6 +153,13 @@ export function InCallView({
const { hideScreensharing } = useUrlParams();
useCallViewKeyboardShortcuts(
!feedbackModalState.isOpen,
toggleMicrophoneMuted,
toggleLocalVideoMuted,
setMicrophoneMuted
);
useEffect(() => {
widget?.api.transport.send(
layout === "freedom"
@@ -266,6 +285,8 @@ export function InCallView({
[]
);
const [newGrid] = useNewGrid();
const Grid = newGrid ? NewVideoGrid : VideoGrid;
const prefersReducedMotion = usePrefersReducedMotion();
const renderContent = (): JSX.Element => {
@@ -279,8 +300,8 @@ export function InCallView({
if (maximisedParticipant) {
return (
<VideoTileContainer
height={bounds.height}
width={bounds.width}
targetHeight={bounds.height}
targetWidth={bounds.width}
key={maximisedParticipant.id}
item={maximisedParticipant}
getAvatar={renderAvatar}
@@ -295,20 +316,13 @@ export function InCallView({
}
return (
<VideoGrid
<Grid
items={items}
layout={layout}
disableAnimations={prefersReducedMotion || isSafari}
>
{({
item,
...rest
}: {
item: TileDescriptor;
[x: string]: unknown;
}) => (
{({ item, ...rest }: ChildrenProperties) => (
<VideoTileContainer
key={item.id}
item={item}
getAvatar={renderAvatar}
audioContext={audioContext}
@@ -320,7 +334,7 @@ export function InCallView({
{...rest}
/>
)}
</VideoGrid>
</Grid>
);
};
@@ -358,27 +372,36 @@ export function InCallView({
if (noControls) {
footer = null;
} else if (reducedControls) {
footer = (
<div className={styles.footer}>
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
<HangupButton onPress={onLeave} />
</div>
);
} else {
footer = (
<div className={styles.footer}>
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
{canScreenshare && !hideScreensharing && !isSafari && (
const buttons: JSX.Element[] = [];
buttons.push(
<MicButton
key="1"
muted={microphoneMuted}
onPress={toggleMicrophoneMuted}
/>,
<VideoButton
key="2"
muted={localVideoMuted}
onPress={toggleLocalVideoMuted}
/>
);
if (!reducedControls) {
if (canScreenshare && !hideScreensharing && !isSafari) {
buttons.push(
<ScreenshareButton
key="3"
enabled={isScreensharing}
onPress={toggleScreensharing}
/>
)}
{!maximisedParticipant && (
);
}
if (!maximisedParticipant) {
buttons.push(
<OverflowMenu
key="4"
inCall
roomIdOrAlias={roomIdOrAlias}
groupCall={groupCall}
@@ -386,11 +409,13 @@ export function InCallView({
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
)}
<HangupButton onPress={onLeave} />
</div>
);
}
}
buttons.push(<HangupButton key="6" onPress={onLeave} />);
footer = <div className={styles.footer}>{buttons}</div>;
}
return (
<div className={containerClasses} ref={containerRef}>

View File

@@ -17,6 +17,7 @@ limitations under the License.
import React, { useCallback, useState, useRef } from "react";
import classNames from "classnames";
import { useSpring, animated } from "@react-spring/web";
import { logger } from "@sentry/utils";
import styles from "./PTTButton.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
@@ -68,11 +69,23 @@ export const PTTButton: React.FC<Props> = ({
enqueueNetworkWaiting(true, 100);
startTalking();
}, [enqueueNetworkWaiting, startTalking, buttonHeld]);
const unhold = useCallback(() => {
if (!buttonHeld) return;
setButtonHeld(false);
setNetworkWaiting(false);
stopTalking();
}, [setNetworkWaiting, stopTalking]);
}, [setNetworkWaiting, stopTalking, buttonHeld]);
const onMouseUp = useCallback(() => {
logger.info("Mouse up event: unholding PTT button");
unhold();
}, [unhold]);
const onBlur = useCallback(() => {
logger.info("Blur event: unholding PTT button");
unhold();
}, [unhold]);
const onButtonMouseDown = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
@@ -85,7 +98,7 @@ export const PTTButton: React.FC<Props> = ({
// These listeners go on the window so even if the user's cursor / finger
// leaves the button while holding it, the button stays pushed until
// they stop clicking / tapping.
useEventTarget(window, "mouseup", unhold);
useEventTarget(window, "mouseup", onMouseUp);
useEventTarget(
window,
"touchend",
@@ -103,6 +116,8 @@ export const PTTButton: React.FC<Props> = ({
}
if (!touchFound) return;
logger.info("Touch event ended: unholding PTT button");
e.preventDefault();
unhold();
setActiveTouchId(null);
@@ -163,6 +178,8 @@ export const PTTButton: React.FC<Props> = ({
e.preventDefault();
logger.info("Keyup event for spacebar: unholding PTT button");
unhold();
}
},
@@ -171,7 +188,7 @@ export const PTTButton: React.FC<Props> = ({
);
// TODO: We will need to disable this for a global PTT hotkey to work
useEventTarget(window, "blur", unhold);
useEventTarget(window, "blur", onBlur);
const prefersReducedMotion = usePrefersReducedMotion();
const { shadow } = useSpring({

View File

@@ -210,8 +210,9 @@ export const PTTCallView: React.FC<Props> = ({
</Header>
)}
<div className={styles.center}>
{showControls && (
<>
{/* Always render this because the window will become shorter when the on-screen
keyboard appears, so if we don't render it, the dialog will unmount. */}
<div style={{ display: showControls ? "block" : "none" }}>
<div className={styles.participants}>
<p>
{t("{{count}} people connected", {
@@ -238,8 +239,7 @@ export const PTTCallView: React.FC<Props> = ({
{!isEmbedded && <HangupButton onPress={onLeave} />}
<InviteButton onPress={() => inviteModalState.open()} />
</div>
</>
)}
</div>
<div className={styles.pttButtonContainer}>
{showControls &&

View File

@@ -50,9 +50,9 @@ export const RoomPage: FC = () => {
const [isRegistering, setIsRegistering] = useState(false);
useEffect(() => {
// If we're not already authed and we've been given a display name as
// If we've finished loading, are not already authed and we've been given a display name as
// a URL param, automatically register a passwordless user
if (!isAuthenticated && displayName) {
if (!loading && !isAuthenticated && displayName) {
setIsRegistering(true);
registerPasswordlessUser(displayName).finally(() => {
setIsRegistering(false);
@@ -63,6 +63,7 @@ export const RoomPage: FC = () => {
displayName,
setIsRegistering,
registerPasswordlessUser,
loading,
]);
const groupCallView = useCallback(

View File

@@ -29,11 +29,9 @@ import { useTranslation } from "react-i18next";
import { IWidgetApiRequest } from "matrix-widget-api";
import { usePageUnload } from "./usePageUnload";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { TranslatedError, translatedError } from "../TranslatedError";
import { ElementWidgetActions, ScreenshareStartData, widget } from "../widget";
import { getSetting } from "../settings/useSetting";
import { useEventTarget } from "../useEvents";
export enum ConnectionState {
EstablishingCall = "establishing call", // call hasn't been established yet
@@ -60,6 +58,7 @@ export interface UseGroupCallReturnType {
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
toggleScreensharing: () => void;
setMicrophoneMuted: (muted: boolean) => void;
requestingScreenshare: boolean;
isScreensharing: boolean;
screenshareFeeds: CallFeed[];
@@ -99,12 +98,24 @@ function getParticipants(
(f) => f.userId === member.userId && f.deviceId === deviceId
);
participantInfoMap.set(deviceId, {
connectionState: feed
let connectionState: ConnectionState;
// If we allow calls without media, we have no feeds and cannot read the connection status from them.
// @TODO: The connection state should generally not be determined by the feed.
if (
groupCall.allowCallWithoutVideoAndAudio &&
!feed &&
!participant.screensharing
) {
connectionState = ConnectionState.Connected;
} else {
connectionState = feed
? feed.connected
? ConnectionState.Connected
: ConnectionState.WaitMedia
: ConnectionState.EstablishingCall,
: ConnectionState.EstablishingCall;
}
participantInfoMap.set(deviceId, {
connectionState,
presenter: participant.screensharing,
});
}
@@ -159,6 +170,38 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
[setState]
);
const doNothingMediaActionCallback = useCallback(
(details: MediaSessionActionDetails) => {},
[]
);
useEffect(() => {
// disable the media action keys, otherwise audio elements get paused when
// the user presses media keys or unplugs headphones, etc.
// Note there are actions for muting / unmuting a microphone & hanging up
// which we could wire up.
const mediaActions: MediaSessionAction[] = [
"play",
"pause",
"stop",
"nexttrack",
"previoustrack",
];
for (const mediaAction of mediaActions) {
navigator.mediaSession.setActionHandler(
mediaAction,
doNothingMediaActionCallback
);
}
return () => {
for (const mediaAction of mediaActions) {
navigator.mediaSession.setActionHandler(mediaAction, null);
}
};
}, [doNothingMediaActionCallback]);
useEffect(() => {
function onGroupCallStateChanged() {
updateState({
@@ -472,68 +515,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
}
}, [t, updateState]);
const [spacebarHeld, setSpacebarHeld] = useState(false);
useEventTarget(
window,
"keydown",
useCallback(
(event: KeyboardEvent) => {
// Check if keyboard shortcuts are enabled
const keyboardShortcuts = getSetting("keyboard-shortcuts", true);
if (!keyboardShortcuts) {
return;
}
if (event.key === "m") {
toggleMicrophoneMuted();
} else if (event.key == "v") {
toggleLocalVideoMuted();
} else if (event.key === " ") {
setSpacebarHeld(true);
setMicrophoneMuted(false);
}
},
[
toggleLocalVideoMuted,
toggleMicrophoneMuted,
setMicrophoneMuted,
setSpacebarHeld,
]
)
);
useEventTarget(
window,
"keyup",
useCallback(
(event: KeyboardEvent) => {
// Check if keyboard shortcuts are enabled
const keyboardShortcuts = getSetting("keyboard-shortcuts", true);
if (!keyboardShortcuts) {
return;
}
if (event.key === " ") {
setSpacebarHeld(false);
setMicrophoneMuted(true);
}
},
[setMicrophoneMuted, setSpacebarHeld]
)
);
useEventTarget(
window,
"blur",
useCallback(() => {
if (spacebarHeld) {
setSpacebarHeld(false);
setMicrophoneMuted(true);
}
}, [setMicrophoneMuted, setSpacebarHeld, spacebarHeld])
);
return {
state,
localCallFeed,
@@ -548,6 +529,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
toggleLocalVideoMuted,
toggleMicrophoneMuted,
toggleScreensharing,
setMicrophoneMuted,
requestingScreenshare,
isScreensharing,
screenshareFeeds,

View File

@@ -52,12 +52,22 @@ export const useLoadGroupCall = (
const fetchOrCreateRoom = async (): Promise<Room> => {
try {
const room = await client.joinRoom(roomIdOrAlias, { viaServers });
// We lowercase the localpart when we create the room, so we must lowercase
// it here too (we just do the whole alias). We can't do the same to room IDs
// though.
const sanitisedIdOrAlias =
roomIdOrAlias[0] === "#"
? roomIdOrAlias.toLowerCase()
: roomIdOrAlias;
const room = await client.joinRoom(sanitisedIdOrAlias, {
viaServers,
});
logger.info(
`Joined ${roomIdOrAlias}, waiting room to be ready for group calls`
`Joined ${sanitisedIdOrAlias}, waiting room to be ready for group calls`
);
await client.waitUntilRoomReadyForGroupCalls(room.roomId);
logger.info(`${roomIdOrAlias}, is ready for group calls`);
logger.info(`${sanitisedIdOrAlias}, is ready for group calls`);
return room;
} catch (error) {
if (

View File

@@ -113,12 +113,14 @@ export const usePTT = (
},
setState,
] = useState(() => {
// slightly concerningly, this can end up null as we seem to sometimes get
// here before the room state contains our own member event
const roomMember = groupCall.room.getMember(client.getUserId());
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
return {
isAdmin: roomMember.powerLevel >= 100,
isAdmin: roomMember ? roomMember.powerLevel >= 100 : false,
talkOverEnabled: false,
pttButtonHeld: false,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,

View File

@@ -26,3 +26,12 @@ limitations under the License.
.fieldRowText {
margin-bottom: 0;
}
/*
This style guarantees a fixed width of the tab bar in the settings window.
The "Developer" item in the tab bar can be toggled.
Without a defined width activating the developer tab makes the tab container jump to the right.
*/
.tabLabel {
width: 80px;
}

View File

@@ -33,11 +33,14 @@ import {
useShowInspector,
useOptInAnalytics,
canEnableSpatialAudio,
useNewGrid,
useDeveloperSettingsTab,
} from "./useSetting";
import { FieldRow, InputField } from "../input/Input";
import { Button } from "../button";
import { useDownloadDebugLog } from "./submit-rageshake";
import { Body } from "../typography/Typography";
import { optInDescription } from "../analytics/AnalyticsOptInDescription";
interface Props {
isOpen: boolean;
@@ -61,7 +64,10 @@ export const SettingsModal = (props: Props) => {
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
const [showInspector, setShowInspector] = useShowInspector();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const [developerSettingsTab, setDeveloperSettingsTab] =
useDeveloperSettingsTab();
const [keyboardShortcuts, setKeyboardShortcuts] = useKeyboardShortcuts();
const [newGrid, setNewGrid] = useNewGrid();
const downloadDebugLog = useDownloadDebugLog();
@@ -78,7 +84,7 @@ export const SettingsModal = (props: Props) => {
title={
<>
<AudioIcon width={16} height={16} />
<span>{t("Audio")}</span>
<span className={styles.tabLabel}>{t("Audio")}</span>
</>
}
>
@@ -156,24 +162,11 @@ export const SettingsModal = (props: Props) => {
title={
<>
<OverflowIcon width={16} height={16} />
<span>{t("Advanced")}</span>
<span>{t("More")}</span>
</>
}
>
<FieldRow>
<InputField
id="optInAnalytics"
label={t("Allow analytics")}
type="checkbox"
checked={optInAnalytics}
description={t(
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used."
)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
</FieldRow>
<h4>Keyboard</h4>
<FieldRow>
<InputField
id="keyboardShortcuts"
@@ -188,7 +181,34 @@ export const SettingsModal = (props: Props) => {
}
/>
</FieldRow>
<h4>Analytics</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics}
description={optInDescription()}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
</FieldRow>
<FieldRow>
<InputField
id="developerSettingsTab"
type="checkbox"
checked={developerSettingsTab}
label={t("Developer Settings")}
description={t(
"Expose developer settings in the settings window."
)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setDeveloperSettingsTab(event.target.checked)
}
/>
</FieldRow>
</TabItem>
{developerSettingsTab && (
<TabItem
title={
<>
@@ -216,12 +236,24 @@ export const SettingsModal = (props: Props) => {
}
/>
</FieldRow>
<FieldRow>
<InputField
id="newGrid"
label={t("Use the upcoming grid system")}
type="checkbox"
checked={newGrid}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNewGrid(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<Button onPress={downloadDebugLog}>
{t("Download debug logs")}
</Button>
</FieldRow>
</TabItem>
)}
</TabContainer>
</Modal>
);

View File

@@ -1,21 +1,4 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment */
/*
Copyright 2017 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The New Vector Ltd
@@ -54,15 +37,23 @@ limitations under the License.
// actually timestamps. We then purge the remaining logs. We also do this
// purge on startup to prevent logs from accumulating.
import EventEmitter from "events";
import { throttle } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { randomString } from "matrix-js-sdk/src/randomstring";
// the frequency with which we flush to indexeddb
const FLUSH_RATE_MS = 30 * 1000;
// the length of log data we keep in indexeddb (and include in the reports)
const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB
// Shortest amount of time between flushes. We are just appending to an
// IndexedDB table so we don't expect flushing to be that expensive, but
// we can batch the writes a little.
const MAX_FLUSH_INTERVAL_MS = 2 * 1000;
enum ConsoleLoggerEvent {
Log = "log",
}
type LogFunction = (
...args: (Error | DOMException | object | string)[]
) => void;
@@ -76,7 +67,7 @@ interface LogEntry {
index?: number;
}
export class ConsoleLogger {
export class ConsoleLogger extends EventEmitter {
private logs = "";
private originalFunctions: { [key in LogFunctionName]?: LogFunction } = {};
@@ -99,13 +90,6 @@ export class ConsoleLogger {
});
}
public bypassRageshake(
fnName: LogFunctionName,
...args: (Error | DOMException | object | string)[]
): void {
this.originalFunctions[fnName](...args);
}
public log(
level: string,
...args: (Error | DOMException | object | string)[]
@@ -137,23 +121,27 @@ export class ConsoleLogger {
// Using + really is the quickest way in JS
// http://jsperf.com/concat-vs-plus-vs-join
this.logs += line;
this.emit(ConsoleLoggerEvent.Log);
}
/**
* Retrieve log lines to flush to disk.
* @param {boolean} keepLogs True to not delete logs after flushing.
* @return {string} \n delimited log lines to flush.
* Returns the log lines to flush to disk and empties the internal log buffer
* @return {string} \n delimited log lines
*/
public flush(keepLogs?: boolean): string {
// The ConsoleLogger doesn't care how these end up on disk, it just
// flushes them to the caller.
if (keepLogs) {
return this.logs;
}
public popLogs(): string {
const logsToFlush = this.logs;
this.logs = "";
return logsToFlush;
}
/**
* Returns lines currently in the log buffer without removing them
* @return {string} \n delimited log lines
*/
public peekLogs(): string {
return this.logs;
}
}
// A class which stores log lines in an IndexedDB instance.
@@ -164,8 +152,14 @@ export class IndexedDBLogStore {
private flushAgainPromise: Promise<void> = null;
private id: string;
constructor(private indexedDB: IDBFactory, private logger: ConsoleLogger) {
constructor(
private indexedDB: IDBFactory,
private loggerInstance: ConsoleLogger
) {
this.id = "instance-" + randomString(16);
loggerInstance.on(ConsoleLoggerEvent.Log, this.onLoggerLog);
window.addEventListener("beforeunload", this.flush);
}
/**
@@ -174,30 +168,31 @@ export class IndexedDBLogStore {
public connect(): Promise<void> {
const req = this.indexedDB.open("logs");
return new Promise((resolve, reject) => {
req.onsuccess = (event: Event) => {
// @ts-ignore
this.db = event.target.result;
// Periodically flush logs to local storage / indexeddb
setInterval(this.flush.bind(this), FLUSH_RATE_MS);
req.onsuccess = () => {
this.db = req.result;
resolve();
};
req.onerror = (event) => {
const err =
// @ts-ignore
"Failed to open log database: " + event.target.error.name;
req.onerror = () => {
const err = "Failed to open log database: " + req.error.name;
logger.error(err);
reject(new Error(err));
};
// First time: Setup the object store
req.onupgradeneeded = (event) => {
// @ts-ignore
const db = event.target.result;
req.onupgradeneeded = () => {
const db = req.result;
// This is the log entries themselves. Each entry is a chunk of
// logs (ie multiple lines). 'id' is the instance ID (so logs with
// the same instance ID are all from the same session) and 'index'
// is a sequence number for the chunk. The log lines live in the
// 'lines' key, which is a chunk of many newline-separated log lines.
const logObjStore = db.createObjectStore("logs", {
keyPath: ["id", "index"],
});
// Keys in the database look like: [ "instance-148938490", 0 ]
// (The instance ID plus the ID of each log chunk).
// Later on we need to query everything based on an instance id.
// In order to do this, we need to set up indexes "id".
logObjStore.createIndex("id", "id", { unique: false });
@@ -206,6 +201,9 @@ export class IndexedDBLogStore {
this.generateLogEntry(new Date() + " ::: Log database was created.")
);
// This records the last time each instance ID generated a log message, such
// that the logs from each session can be collated in the order they last logged
// something.
const lastModifiedStore = db.createObjectStore("logslastmod", {
keyPath: "id",
});
@@ -214,6 +212,26 @@ export class IndexedDBLogStore {
});
}
private onLoggerLog = () => {
if (!this.db) return;
this.throttledFlush();
};
// Throttled function to flush logs. We use throttle rather
// than debounce as we want logs to be written regularly, otherwise
// if there's a constant stream of logging, we'd never write anything.
private throttledFlush = throttle(
() => {
this.flush();
},
MAX_FLUSH_INTERVAL_MS,
{
leading: false,
trailing: true,
}
);
/**
* Flush logs to disk.
*
@@ -233,7 +251,7 @@ export class IndexedDBLogStore {
*
* @return {Promise} Resolved when the logs have been flushed.
*/
public flush(): Promise<void> {
public flush = (): Promise<void> => {
// check if a flush() operation is ongoing
if (this.flushPromise) {
if (this.flushAgainPromise) {
@@ -258,20 +276,19 @@ export class IndexedDBLogStore {
reject(new Error("No connected database"));
return;
}
const lines = this.logger.flush();
const lines = this.loggerInstance.popLogs();
if (lines.length === 0) {
resolve();
return;
}
const txn = this.db.transaction(["logs", "logslastmod"], "readwrite");
const objStore = txn.objectStore("logs");
txn.oncomplete = (event) => {
txn.oncomplete = () => {
resolve();
};
txn.onerror = (event) => {
logger.error("Failed to flush logs : ", event);
// @ts-ignore
reject(new Error("Failed to write logs: " + event.target.errorCode));
reject(new Error("Failed to write logs: " + txn.error.message));
};
objStore.add(this.generateLogEntry(lines));
const lastModStore = txn.objectStore("logslastmod");
@@ -280,7 +297,7 @@ export class IndexedDBLogStore {
this.flushPromise = null;
});
return this.flushPromise;
}
};
/**
* Consume the most recent logs and return them. Older logs which are not
@@ -307,13 +324,11 @@ export class IndexedDBLogStore {
.index("id")
.openCursor(IDBKeyRange.only(id), "prev");
let lines = "";
query.onerror = (event) => {
// @ts-ignore
reject(new Error("Query failed: " + event.target.errorCode));
query.onerror = () => {
reject(new Error("Query failed: " + query.error.message));
};
query.onsuccess = (event) => {
// @ts-ignore
const cursor = event.target.result;
query.onsuccess = () => {
const cursor = query.result;
if (!cursor) {
resolve(lines);
return; // end of results
@@ -355,9 +370,8 @@ export class IndexedDBLogStore {
const o = txn.objectStore("logs");
// only load the key path, not the data which may be huge
const query = o.index("id").openKeyCursor(IDBKeyRange.only(id));
query.onsuccess = (event) => {
// @ts-ignore
const cursor = event.target.result;
query.onsuccess = () => {
const cursor = query.result;
if (!cursor) {
return;
}
@@ -367,12 +381,10 @@ export class IndexedDBLogStore {
txn.oncomplete = () => {
resolve();
};
txn.onerror = (event) => {
txn.onerror = () => {
reject(
new Error(
"Failed to delete logs for " +
// @ts-ignore
`'${id}' : ${event.target.errorCode}`
"Failed to delete logs for " + `'${id}' : ${txn.error.message}`
)
);
};
@@ -456,14 +468,12 @@ function selectQuery<T>(
const query = store.openCursor(keyRange);
return new Promise((resolve, reject) => {
const results = [];
query.onerror = (event) => {
// @ts-ignore
reject(new Error("Query failed: " + event.target.errorCode));
query.onerror = () => {
reject(new Error("Query failed: " + query.error.message));
};
// collect results
query.onsuccess = (event) => {
// @ts-ignore
const cursor = event.target.result;
query.onsuccess = () => {
const cursor = query.result;
if (!cursor) {
resolve(results);
return; // end of results
@@ -479,8 +489,6 @@ declare global {
// eslint-disable-next-line no-var, camelcase
var mx_rage_logger: ConsoleLogger;
// eslint-disable-next-line no-var, camelcase
var mx_rage_initPromise: Promise<void>;
// eslint-disable-next-line no-var, camelcase
var mx_rage_initStoragePromise: Promise<void>;
}
@@ -491,21 +499,13 @@ declare global {
* be set up immediately for the logs.
* @return {Promise} Resolves when set up.
*/
export function init(setUpPersistence = true): Promise<void> {
if (global.mx_rage_initPromise) {
return global.mx_rage_initPromise;
}
export function init(): Promise<void> {
global.mx_rage_logger = new ConsoleLogger();
global.mx_rage_logger.monkeyPatch(window.console);
if (setUpPersistence) {
return tryInitStorage();
}
global.mx_rage_initPromise = Promise.resolve();
return global.mx_rage_initPromise;
}
/**
* Try to start up the rageshake storage for logs. If not possible (client unsupported)
* then this no-ops.
@@ -573,7 +573,7 @@ export async function getLogsForReport(): Promise<LogEntry[]> {
} else {
return [
{
lines: global.mx_rage_logger.flush(true),
lines: global.mx_rage_logger.peekLogs(),
id: "-",
},
];

View File

@@ -90,3 +90,6 @@ export const useShowInspector = () => useSetting("show-inspector", false);
export const useOptInAnalytics = () => useSetting("opt-in-analytics", false);
export const useKeyboardShortcuts = () =>
useSetting("keyboard-shortcuts", true);
export const useNewGrid = () => useSetting("new-grid", false);
export const useDeveloperSettingsTab = () =>
useSetting("developer-settings-tab", false);

View File

@@ -0,0 +1,93 @@
/*
Copyright 2022-2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback, useState } from "react";
import { getSetting } from "./settings/useSetting";
import { useEventTarget } from "./useEvents";
export function useCallViewKeyboardShortcuts(
enabled: boolean,
toggleMicrophoneMuted: () => void,
toggleLocalVideoMuted: () => void,
setMicrophoneMuted: (muted: boolean) => void
) {
const [spacebarHeld, setSpacebarHeld] = useState(false);
useEventTarget(
window,
"keydown",
useCallback(
(event: KeyboardEvent) => {
if (!enabled) return;
// Check if keyboard shortcuts are enabled
const keyboardShortcuts = getSetting("keyboard-shortcuts", true);
if (!keyboardShortcuts) {
return;
}
if (event.key === "m") {
toggleMicrophoneMuted();
} else if (event.key == "v") {
toggleLocalVideoMuted();
} else if (event.key === " " && !spacebarHeld) {
setSpacebarHeld(true);
setMicrophoneMuted(false);
}
},
[
enabled,
spacebarHeld,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
setMicrophoneMuted,
setSpacebarHeld,
]
)
);
useEventTarget(
window,
"keyup",
useCallback(
(event: KeyboardEvent) => {
if (!enabled) return;
// Check if keyboard shortcuts are enabled
const keyboardShortcuts = getSetting("keyboard-shortcuts", true);
if (!keyboardShortcuts) {
return;
}
if (event.key === " ") {
setSpacebarHeld(false);
setMicrophoneMuted(true);
}
},
[enabled, setMicrophoneMuted, setSpacebarHeld]
)
);
useEventTarget(
window,
"blur",
useCallback(() => {
if (spacebarHeld) {
setSpacebarHeld(false);
setMicrophoneMuted(true);
}
}, [setMicrophoneMuted, setSpacebarHeld, spacebarHeld])
);
}

39
src/useMergedRefs.ts Normal file
View File

@@ -0,0 +1,39 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MutableRefObject, RefCallback, useCallback } from "react";
/**
* Combines multiple refs into one, useful for attaching multiple refs to the
* same DOM node.
*/
export const useMergedRefs = <T>(
...refs: (MutableRefObject<T | null> | RefCallback<T | null>)[]
): RefCallback<T | null> =>
useCallback(
(value) =>
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else {
ref.current = value;
}
}),
// Since this isn't an array literal, we can't use the static dependency
// checker, but that's okay
// eslint-disable-next-line react-hooks/exhaustive-deps
refs
);

67
src/useReactiveState.ts Normal file
View File

@@ -0,0 +1,67 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
DependencyList,
Dispatch,
SetStateAction,
useCallback,
useRef,
useState,
} from "react";
/**
* Hook creating a stateful value that updates automatically whenever the
* dependencies change. Or equivalently, a version of useMemo that takes its own
* previous value as an input, and can be updated manually.
*/
export const useReactiveState = <T>(
updateFn: (prevState?: T) => T,
deps: DependencyList
): [T, Dispatch<SetStateAction<T>>] => {
const state = useRef<T>();
if (state.current === undefined) state.current = updateFn();
const prevDeps = useRef<DependencyList>();
// Since we store the state in a ref, we use this counter to force an update
// when someone calls setState
const [, setNumUpdates] = useState(0);
// If this is the first render or the deps have changed, recalculate the state
if (
prevDeps.current === undefined ||
deps.length !== prevDeps.current.length ||
deps.some((d, i) => d !== prevDeps.current![i])
) {
state.current = updateFn(state.current);
}
prevDeps.current = deps;
return [
state.current,
useCallback(
(action) => {
if (typeof action === "function") {
state.current = (action as (prevValue: T) => T)(state.current!);
} else {
state.current = action;
}
setNumUpdates((n) => n + 1); // Force an update
},
[setNumUpdates]
),
];
};

View File

@@ -31,14 +31,15 @@ export const AudioSink: React.FC<Props> = ({
tileDescriptor,
audioOutput,
}: Props) => {
const { audioMuted, localVolume, stream } = useCallFeed(
tileDescriptor.callFeed
);
const { localVolume, stream } = useCallFeed(tileDescriptor.callFeed);
const audioElementRef = useMediaStream(
stream,
audioOutput,
audioMuted,
// We don't compare the audioMuted flag of useCallFeed here, since unmuting
// depends on to-device messages which may lag behind the audio actually
// starting to flow over the stream
tileDescriptor.isLocal,
localVolume
);

View File

@@ -0,0 +1,48 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.grid {
contain: strict;
position: relative;
flex-grow: 1;
padding: 0 20px;
overflow-y: auto;
overflow-x: hidden;
}
.slotGrid {
position: relative;
display: grid;
grid-auto-rows: 163px;
gap: 8px;
padding-bottom: var(--footerHeight);
}
.slot {
contain: strict;
}
@media (min-width: 800px) {
.grid {
padding: 0 22px;
}
.slotGrid {
grid-auto-rows: 183px;
column-gap: 18px;
row-gap: 21px;
}
}

View File

@@ -0,0 +1,461 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SpringRef, TransitionFn, useTransition } from "@react-spring/web";
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
import React, {
Dispatch,
FC,
ReactNode,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useMeasure from "react-use-measure";
import { zipWith } from "lodash";
import styles from "./NewVideoGrid.module.css";
import { TileDescriptor } from "./TileDescriptor";
import { VideoGridProps as Props, TileSpring } from "./VideoGrid";
import { useReactiveState } from "../useReactiveState";
import { useMergedRefs } from "../useMergedRefs";
import {
Grid,
Cell,
row,
column,
fillGaps,
forEachCellInArea,
cycleTileSize,
appendItems,
} from "./model";
interface GridState extends Grid {
/**
* The ID of the current state of the grid.
*/
generation: number;
}
const useGridState = (
columns: number | null,
items: TileDescriptor[]
): [GridState | null, Dispatch<SetStateAction<Grid>>] => {
const [grid, setGrid_] = useReactiveState<GridState | null>(
(prevGrid = null) => {
if (prevGrid === null) {
// We can't do anything if the column count isn't known yet
if (columns === null) {
return null;
} else {
prevGrid = { generation: 0, columns, cells: [] };
}
}
// Step 1: Update tiles that still exist, and remove tiles that have left
// the grid
const itemsById = new Map(items.map((i) => [i.id, i]));
const grid1: Grid = {
...prevGrid,
cells: prevGrid.cells.map((c) => {
if (c === undefined) return undefined;
const item = itemsById.get(c.item.id);
return item === undefined ? undefined : { ...c, item };
}),
};
// Step 2: Backfill gaps left behind by removed tiles
const grid2 = fillGaps(grid1);
// Step 3: Add new tiles to the end of the grid
const existingItemIds = new Set(
grid2.cells.filter((c) => c !== undefined).map((c) => c!.item.id)
);
const newItems = items.filter((i) => !existingItemIds.has(i.id));
const grid3 = appendItems(newItems, grid2);
return { ...grid3, generation: prevGrid.generation + 1 };
},
[columns, items]
);
const setGrid: Dispatch<SetStateAction<Grid>> = useCallback(
(action) => {
if (typeof action === "function") {
setGrid_((prevGrid) =>
prevGrid === null
? null
: {
...(action as (prev: Grid) => Grid)(prevGrid),
generation: prevGrid.generation + 1,
}
);
} else {
setGrid_((prevGrid) => ({
...action,
generation: prevGrid?.generation ?? 1,
}));
}
},
[setGrid_]
);
return [grid, setGrid];
};
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
interface Tile extends Rect {
item: TileDescriptor;
}
interface DragState {
tileId: string;
tileX: number;
tileY: number;
cursorX: number;
cursorY: number;
}
/**
* An interactive, animated grid of video tiles.
*/
export const NewVideoGrid: FC<Props> = ({
items,
disableAnimations,
children,
}) => {
// Overview: This component lays out tiles by rendering an invisible template
// grid of "slots" for tiles to go in. Once rendered, it uses the DOM API to
// get the dimensions of each slot, feeding these numbers back into
// react-spring to let the actual tiles move freely atop the template.
// To know when the rendered grid becomes consistent with the layout we've
// requested, we give it a data-generation attribute which holds the ID of the
// most recently rendered generation of the grid, and watch it with a
// MutationObserver.
const [slotGrid, setSlotGrid] = useState<HTMLDivElement | null>(null);
const [slotGridGeneration, setSlotGridGeneration] = useState(0);
useEffect(() => {
if (slotGrid !== null) {
setSlotGridGeneration(
parseInt(slotGrid.getAttribute("data-generation")!)
);
const observer = new MutationObserver((mutations) => {
if (mutations.some((m) => m.type === "attributes")) {
setSlotGridGeneration(
parseInt(slotGrid.getAttribute("data-generation")!)
);
}
});
observer.observe(slotGrid, { attributes: true });
return () => observer.disconnect();
}
}, [slotGrid, setSlotGridGeneration]);
const [gridRef1, gridBounds] = useMeasure();
const gridRef2 = useRef<HTMLDivElement | null>(null);
const gridRef = useMergedRefs(gridRef1, gridRef2);
const slotRects = useMemo(() => {
if (slotGrid === null) return [];
const slots = slotGrid.getElementsByClassName(styles.slot);
const rects = new Array<Rect>(slots.length);
for (let i = 0; i < slots.length; i++) {
const slot = slots[i] as HTMLElement;
rects[i] = {
x: slot.offsetLeft,
y: slot.offsetTop,
width: slot.offsetWidth,
height: slot.offsetHeight,
};
}
return rects;
// The rects may change due to the grid being resized or rerendered, but
// eslint can't statically verify this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slotGrid, slotGridGeneration, gridBounds]);
const [columns] = useReactiveState<number | null>(
// Since grid resizing isn't implemented yet, pick a column count on mount
// and stick to it
(prevColumns) =>
prevColumns !== undefined && prevColumns !== null
? prevColumns
: // The grid bounds might not be known yet
gridBounds.width === 0
? null
: Math.max(2, Math.floor(gridBounds.width * 0.0045)),
[gridBounds]
);
const [grid, setGrid] = useGridState(columns, items);
const [tiles] = useReactiveState<Tile[]>(
(prevTiles) => {
// If React hasn't yet rendered the current generation of the grid, skip
// the update, because grid and slotRects will be out of sync
if (slotGridGeneration !== grid?.generation) return prevTiles ?? [];
const tileCells = grid.cells.filter((c) => c?.origin) as Cell[];
const tileRects = new Map<TileDescriptor, Rect>(
zipWith(tileCells, slotRects, (cell, rect) => [cell.item, rect])
);
return items.map((item) => ({ ...tileRects.get(item)!, item }));
},
[slotRects, grid, slotGridGeneration]
);
// Drag state is stored in a ref rather than component state, because we use
// react-spring's imperative API during gestures to improve responsiveness
const dragState = useRef<DragState | null>(null);
const [tileTransitions, springRef] = useTransition(
tiles,
() => ({
key: ({ item }: Tile) => item.id,
from: ({ x, y, width, height }: Tile) => ({
opacity: 0,
scale: 0,
shadow: 1,
zIndex: 1,
x,
y,
width,
height,
immediate: disableAnimations,
}),
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
update: ({ item, x, y, width, height }: Tile) =>
item.id === dragState.current?.tileId
? {}
: {
x,
y,
width,
height,
immediate: disableAnimations,
},
leave: { opacity: 0, scale: 0, immediate: disableAnimations },
config: { mass: 0.7, tension: 252, friction: 25 },
}),
[tiles, disableAnimations]
// react-spring's types are bugged and can't infer the spring type
) as unknown as [TransitionFn<Tile, TileSpring>, SpringRef<TileSpring>];
const animateDraggedTile = (endOfGesture: boolean) => {
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
const tile = tiles.find((t) => t.item.id === tileId)!;
springRef.start((_i, controller) => {
if ((controller.item as Tile).item.id === tileId) {
if (endOfGesture) {
return {
scale: 1,
zIndex: 1,
shadow: 1,
x: tile.x,
y: tile.y,
width: tile.width,
height: tile.height,
immediate: disableAnimations || ((key) => key === "zIndex"),
// Allow the tile's position to settle before pushing its
// z-index back down
delay: (key) => (key === "zIndex" ? 500 : 0),
};
} else {
return {
scale: 1.1,
zIndex: 2,
shadow: 15,
x: tileX,
y: tileY,
immediate:
disableAnimations ||
((key) => key === "zIndex" || key === "x" || key === "y"),
};
}
} else {
return {};
}
});
const overTile = tiles.find(
(t) =>
cursorX >= t.x &&
cursorX < t.x + t.width &&
cursorY >= t.y &&
cursorY < t.y + t.height
);
if (overTile !== undefined && overTile.item.id !== tileId) {
setGrid((g) => ({
...g!,
cells: g!.cells.map((c) => {
if (c?.item === overTile.item) return { ...c, item: tile.item };
if (c?.item === tile.item) return { ...c, item: overTile.item };
return c;
}),
}));
}
};
// Callback for useDrag. We could call useDrag here, but the default
// pattern of spreading {...bind()} across the children to bind the gesture
// ends up breaking memoization and ruining this component's performance.
// Instead, we pass this callback to each tile via a ref, to let them bind the
// gesture using the much more sensible ref-based method.
const onTileDrag = (
tileId: string,
{
tap,
initial: [initialX, initialY],
delta: [dx, dy],
last,
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => {
if (tap) {
setGrid((g) => cycleTileSize(tileId, g!));
} else {
const tileSpring = springRef.current
.find((c) => (c.item as Tile).item.id === tileId)!
.get();
if (dragState.current === null) {
dragState.current = {
tileId,
tileX: tileSpring.x,
tileY: tileSpring.y,
cursorX: initialX - gridBounds.x,
cursorY: initialY - gridBounds.y + scrollOffset.current,
};
}
dragState.current.tileX += dx;
dragState.current.tileY += dy;
dragState.current.cursorX += dx;
dragState.current.cursorY += dy;
animateDraggedTile(last);
if (last) dragState.current = null;
}
};
const onTileDragRef = useRef(onTileDrag);
onTileDragRef.current = onTileDrag;
const scrollOffset = useRef(0);
useScroll(
({ xy: [, y], delta: [, dy] }) => {
scrollOffset.current = y;
if (dragState.current !== null) {
dragState.current.tileY += dy;
dragState.current.cursorY += dy;
animateDraggedTile(false);
}
},
{ target: gridRef2 }
);
const slotGridStyle = useMemo(() => {
if (grid === null) return {};
const areas = new Array<(number | null)[]>(
Math.ceil(grid.cells.length / grid.columns)
);
for (let i = 0; i < areas.length; i++)
areas[i] = new Array<number | null>(grid.columns).fill(null);
let slotId = 0;
for (let i = 0; i < grid.cells.length; i++) {
const cell = grid.cells[i];
if (cell?.origin) {
const slotEnd = i + cell.columns - 1 + grid.columns * (cell.rows - 1);
forEachCellInArea(
i,
slotEnd,
grid,
(_c, j) => (areas[row(j, grid)][column(j, grid)] = slotId)
);
slotId++;
}
}
return {
gridTemplateAreas: areas
.map(
(row) =>
`'${row
.map((slotId) => (slotId === null ? "." : `s${slotId}`))
.join(" ")}'`
)
.join(" "),
gridTemplateColumns: `repeat(${columns}, 1fr)`,
};
}, [grid, columns]);
const slots = useMemo(() => {
const slots = new Array<ReactNode>(items.length);
for (let i = 0; i < items.length; i++)
slots[i] = (
<div className={styles.slot} key={i} style={{ gridArea: `s${i}` }} />
);
return slots;
}, [items.length]);
// Render nothing if the grid has yet to be generated
if (grid === null) {
return <div ref={gridRef} className={styles.grid} />;
}
return (
<div ref={gridRef} className={styles.grid}>
<div
style={slotGridStyle}
ref={setSlotGrid}
className={styles.slotGrid}
data-generation={grid.generation}
>
{slots}
</div>
{tileTransitions((style, tile) =>
children({
...style,
key: tile.item.id,
targetWidth: tile.width,
targetHeight: tile.height,
item: tile.item,
onDragRef: onTileDragRef,
})
)}
</div>
);
};

View File

@@ -19,4 +19,5 @@ limitations under the License.
overflow: hidden;
flex: 1;
touch-action: none;
margin-bottom: var(--footerHeight);
}

View File

@@ -16,7 +16,12 @@ limitations under the License.
import React, { Key, useCallback, useEffect, useRef, useState } from "react";
import { FullGestureState, useDrag, useGesture } from "@use-gesture/react";
import { Interpolation, SpringValue, useSprings } from "@react-spring/web";
import {
SpringRef,
SpringValue,
SpringValues,
useSprings,
} from "@react-spring/web";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/types";
@@ -42,6 +47,17 @@ interface Tile {
presenter: boolean;
}
export interface TileSpring {
opacity: number;
scale: number;
shadow: number;
zIndex: number;
x: number;
y: number;
width: number;
height: number;
}
type LayoutDirection = "vertical" | "horizontal";
export function useVideoGridLayout(hasScreenshareFeeds: boolean): {
@@ -692,20 +708,23 @@ interface DragTileData {
y: number;
}
interface ChildrenProperties extends ReactDOMAttributes {
export interface ChildrenProperties extends ReactDOMAttributes {
key: Key;
style: {
scale: SpringValue<number>;
opacity: SpringValue<number>;
boxShadow: Interpolation<number, string>;
};
width: number;
height: number;
targetWidth: number;
targetHeight: number;
item: TileDescriptor;
opacity: SpringValue<number>;
scale: SpringValue<number>;
shadow: SpringValue<number>;
zIndex: SpringValue<number>;
x: SpringValue<number>;
y: SpringValue<number>;
width: SpringValue<number>;
height: SpringValue<number>;
[index: string]: unknown;
}
interface VideoGridProps {
export interface VideoGridProps {
items: TileDescriptor[];
layout: Layout;
disableAnimations?: boolean;
@@ -896,6 +915,7 @@ export function VideoGrid({
shadow: 0,
scale: 0,
opacity: 0,
zIndex: 0,
},
reset: false,
};
@@ -919,6 +939,7 @@ export function VideoGrid({
shadow: number;
scale: number;
opacity: number;
zIndex?: number;
x?: number;
y?: number;
width?: number;
@@ -965,7 +986,8 @@ export function VideoGrid({
tilePositions,
tiles,
scrollPosition,
]);
// react-spring's types are bugged and can't infer the spring type
]) as unknown as [SpringValues<TileSpring>[], SpringRef<TileSpring>];
const onTap = useCallback(
(tileKey: Key) => {
@@ -1175,21 +1197,16 @@ export function VideoGrid({
return (
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
{springs.map(({ shadow, ...style }, i) => {
{springs.map((style, i) => {
const tile = tiles[i];
const tilePosition = tilePositions[tile.order];
return children({
...bindTile(tile.key),
key: tile.key,
style: {
boxShadow: shadow.to(
(s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px`
),
...style,
},
width: tilePosition.width,
height: tilePosition.height,
key: tile.item.id,
targetWidth: tilePosition.width,
targetHeight: tilePosition.height,
item: tile.item,
});
})}

View File

@@ -16,11 +16,13 @@ limitations under the License.
.videoTile {
position: absolute;
will-change: transform, width, height, opacity, box-shadow;
border-radius: 20px;
contain: strict;
top: 0;
width: var(--tileWidth);
height: var(--tileHeight);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
touch-action: none;
/* HACK: This has no visual effect due to the short duration, but allows the
JS to detect movement via the transform property for audio spatialization */
@@ -28,9 +30,6 @@ limitations under the License.
}
.videoTile * {
touch-action: none;
-moz-user-select: none;
-webkit-user-drag: none;
user-select: none;
}
@@ -143,13 +142,6 @@ limitations under the License.
white-space: nowrap;
}
.videoMutedAvatar {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.videoMutedOverlay {
width: 100%;
height: 100%;
@@ -179,3 +171,9 @@ limitations under the License.
max-width: 360px;
border-radius: 20px;
}
@media (min-width: 800px) {
.videoTile {
border-radius: 20px;
}
}

View File

@@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef } from "react";
import { animated } from "@react-spring/web";
import React, { ForwardedRef, forwardRef } from "react";
import { animated, SpringValue } from "@react-spring/web";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
@@ -36,6 +36,7 @@ interface Props {
mediaRef?: React.RefObject<MediaElement>;
onOptionsPress?: () => void;
localVolume?: number;
hasAudio?: boolean;
maximised?: boolean;
fullscreen?: boolean;
onFullscreen?: () => void;
@@ -43,9 +44,17 @@ interface Props {
showOptions?: boolean;
isLocal?: boolean;
disableSpeakingIndicator?: boolean;
opacity?: SpringValue<number>;
scale?: SpringValue<number>;
shadow?: SpringValue<number>;
zIndex?: SpringValue<number>;
x?: SpringValue<number>;
y?: SpringValue<number>;
width?: SpringValue<number>;
height?: SpringValue<number>;
}
export const VideoTile = forwardRef<HTMLDivElement, Props>(
export const VideoTile = forwardRef<HTMLElement, Props>(
(
{
name,
@@ -58,6 +67,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
mediaRef,
onOptionsPress,
localVolume,
hasAudio,
maximised,
fullscreen,
onFullscreen,
@@ -66,6 +76,14 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
isLocal,
// TODO: disableSpeakingIndicator is not used atm.
disableSpeakingIndicator,
opacity,
scale,
shadow,
zIndex,
x,
y,
width,
height,
...rest
},
ref
@@ -74,6 +92,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
const toolbarButtons: JSX.Element[] = [];
if (connectionState == ConnectionState.Connected && !isLocal) {
if (hasAudio) {
toolbarButtons.push(
<AudioButton
key="localVolume"
@@ -82,6 +101,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
onPress={onOptionsPress}
/>
);
}
if (screenshare) {
toolbarButtons.push(
@@ -118,7 +138,22 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
[styles.screenshare]: screenshare,
[styles.maximised]: maximised,
})}
ref={ref}
style={{
opacity,
scale,
boxShadow: shadow?.to(
(s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px`
),
zIndex,
x,
y,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore React does in fact support assigning custom properties,
// but React's types say no
"--tileWidth": width?.to((w) => `${w}px`),
"--tileHeight": height?.to((h) => `${h}px`),
}}
ref={ref as ForwardedRef<HTMLDivElement>}
{...rest}
>
{toolbarButtons.length > 0 && !maximised && (
@@ -137,7 +172,13 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
</div>
) : (
<div className={classNames(styles.infoBubble, styles.memberName)}>
{audioMuted && !videoMuted && <MicMutedIcon />}
{
/* If the user is speaking, it's safe to say they're unmuted.
Mute state is currently sent over to-device messages, which
aren't quite real-time, so this is an important kludge to make
sure no one appears muted when they've clearly begun talking. */
audioMuted && !videoMuted && !speaking && <MicMutedIcon />
}
{videoMuted && <VideoMutedIcon />}
<span title={caption}>{caption}</span>
</div>

View File

@@ -15,9 +15,11 @@ limitations under the License.
*/
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import React from "react";
import React, { FC, memo, RefObject } from "react";
import { useCallback } from "react";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { SpringValue } from "@react-spring/web";
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
import { useCallFeed } from "./useCallFeed";
import { useSpatialMediaStream } from "./useMediaStream";
@@ -29,8 +31,8 @@ import { TileDescriptor } from "./TileDescriptor";
interface Props {
item: TileDescriptor;
width?: number;
height?: number;
targetWidth: number;
targetHeight: number;
getAvatar: (
roomMember: RoomMember,
width: number,
@@ -42,12 +44,27 @@ interface Props {
maximised: boolean;
fullscreen: boolean;
onFullscreen: (item: TileDescriptor) => void;
opacity?: SpringValue<number>;
scale?: SpringValue<number>;
shadow?: SpringValue<number>;
zIndex?: SpringValue<number>;
x?: SpringValue<number>;
y?: SpringValue<number>;
width?: SpringValue<number>;
height?: SpringValue<number>;
onDragRef?: RefObject<
(
tileId: string,
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => void
>;
}
export function VideoTileContainer({
export const VideoTileContainer: FC<Props> = memo(
({
item,
width,
height,
targetWidth,
targetHeight,
getAvatar,
audioContext,
audioDestination,
@@ -55,18 +72,21 @@ export function VideoTileContainer({
maximised,
fullscreen,
onFullscreen,
onDragRef,
...rest
}: Props) {
}) => {
const {
isLocal,
audioMuted,
videoMuted,
localVolume,
hasAudio,
speaking,
stream,
purpose,
} = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(item.member);
const [tileRef, mediaRef] = useSpatialMediaStream(
stream ?? null,
audioContext,
@@ -79,6 +99,13 @@ export function VideoTileContainer({
// video tile container is displayed.
isLocal || maximised
);
useDrag((state) => onDragRef?.current!(item.id, state), {
target: tileRef,
filterTaps: true,
preventScroll: true,
});
const {
modalState: videoTileSettingsModalState,
modalProps: videoTileSettingsModalProps,
@@ -106,9 +133,12 @@ export function VideoTileContainer({
connectionState={item.connectionState}
ref={tileRef}
mediaRef={mediaRef}
avatar={getAvatar && getAvatar(item.member, width, height)}
avatar={
getAvatar && getAvatar(item.member, targetWidth, targetHeight)
}
onOptionsPress={onOptionsPress}
localVolume={localVolume}
hasAudio={hasAudio}
maximised={maximised}
fullscreen={fullscreen}
onFullscreen={onFullscreenCallback}
@@ -123,3 +153,4 @@ export function VideoTileContainer({
</>
);
}
);

416
src/video-grid/model.ts Normal file
View File

@@ -0,0 +1,416 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import TinyQueue from "tinyqueue";
import { TileDescriptor } from "./TileDescriptor";
/**
* A 1×1 cell in a grid which belongs to a tile.
*/
export interface Cell {
/**
* The item displayed on the tile.
*/
item: TileDescriptor;
/**
* Whether this cell is the origin (top left corner) of the tile.
*/
origin: boolean;
/**
* The width, in columns, of the tile.
*/
columns: number;
/**
* The height, in rows, of the tile.
*/
rows: number;
}
export interface Grid {
columns: number;
/**
* The cells of the grid, in left-to-right top-to-bottom order.
* undefined = empty.
*/
cells: (Cell | undefined)[];
}
/**
* Gets the paths that tiles should travel along in the grid to reach a
* particular destination.
* @param dest The destination index.
* @param g The grid.
* @returns An array in which each cell holds the index of the next cell to move
* to to reach the destination, or null if it is the destination.
*/
export function getPaths(dest: number, g: Grid): (number | null)[] {
const destRow = row(dest, g);
const destColumn = column(dest, g);
// This is Dijkstra's algorithm
const distances = new Array<number>(dest + 1).fill(Infinity);
distances[dest] = 0;
const edges = new Array<number | null | undefined>(dest).fill(undefined);
edges[dest] = null;
const heap = new TinyQueue([dest], (i) => distances[i]);
const visit = (curr: number, via: number) => {
const viaCell = g.cells[via];
const viaLargeTile =
viaCell !== undefined && (viaCell.rows > 1 || viaCell.columns > 1);
// Since it looks nicer to have paths go around large tiles, we impose an
// increased cost for moving through them
const distanceVia = distances[via] + (viaLargeTile ? 8 : 1);
if (distanceVia < distances[curr]) {
distances[curr] = distanceVia;
edges[curr] = via;
heap.push(curr);
}
};
while (heap.length > 0) {
const via = heap.pop()!;
const viaRow = row(via, g);
const viaColumn = column(via, g);
// Visit each neighbor
if (viaRow > 0) visit(via - g.columns, via);
if (viaColumn > 0) visit(via - 1, via);
if (viaColumn < (viaRow === destRow ? destColumn : g.columns - 1))
visit(via + 1, via);
if (
viaRow < destRow - 1 ||
(viaRow === destRow - 1 && viaColumn <= destColumn)
)
visit(via + g.columns, via);
}
// The heap is empty, so we've generated all paths
return edges as (number | null)[];
}
function findLastIndex<T>(
array: T[],
predicate: (item: T) => boolean
): number | null {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) return i;
}
return null;
}
const findLast1By1Index = (g: Grid): number | null =>
findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1);
export function row(index: number, g: Grid): number {
return Math.floor(index / g.columns);
}
export function column(index: number, g: Grid): number {
return ((index % g.columns) + g.columns) % g.columns;
}
function inArea(index: number, start: number, end: number, g: Grid): boolean {
const indexColumn = column(index, g);
const indexRow = row(index, g);
return (
indexRow >= row(start, g) &&
indexRow <= row(end, g) &&
indexColumn >= column(start, g) &&
indexColumn <= column(end, g)
);
}
function* cellsInArea(
start: number,
end: number,
g: Grid
): Generator<number, void, unknown> {
const startColumn = column(start, g);
const endColumn = column(end, g);
for (
let i = start;
i <= end;
i =
column(i, g) === endColumn
? i + g.columns + startColumn - endColumn
: i + 1
)
yield i;
}
export function forEachCellInArea(
start: number,
end: number,
g: Grid,
fn: (c: Cell | undefined, i: number) => void
): void {
for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i);
}
function allCellsInArea(
start: number,
end: number,
g: Grid,
fn: (c: Cell | undefined, i: number) => boolean
): boolean {
for (const i of cellsInArea(start, end, g)) {
if (!fn(g.cells[i], i)) return false;
}
return true;
}
const areaEnd = (
start: number,
columns: number,
rows: number,
g: Grid
): number => start + columns - 1 + g.columns * (rows - 1);
/**
* Gets the index of the next gap in the grid that should be backfilled by 1×1
* tiles.
*/
function getNextGap(g: Grid): number | null {
const last1By1Index = findLast1By1Index(g);
if (last1By1Index === null) return null;
for (let i = 0; i < last1By1Index; i++) {
// To make the backfilling process look natural when there are multiple
// gaps, we actually scan each row from right to left
const j =
(row(i, g) === row(last1By1Index, g)
? last1By1Index
: (row(i, g) + 1) * g.columns) -
1 -
column(i, g);
if (g.cells[j] === undefined) return j;
}
return null;
}
/**
* Backfill any gaps in the grid.
*/
export function fillGaps(g: Grid): Grid {
const result: Grid = { ...g, cells: [...g.cells] };
let gap = getNextGap(result);
if (gap !== null) {
const pathsToEnd = getPaths(findLast1By1Index(result)!, result);
do {
let filled = false;
let to = gap;
let from = pathsToEnd[gap];
// First, attempt to fill the gap by moving 1×1 tiles backwards from the
// end of the grid along a set path
while (from !== null) {
const toCell = result.cells[to];
const fromCell = result.cells[from];
// Skip over slots that are already full
if (toCell !== undefined) {
to = pathsToEnd[to]!;
// Skip over large tiles. Also, we might run into gaps along the path
// created during the filling of previous gaps. Skip over those too;
// they'll be picked up on the next iteration of the outer loop.
} else if (
fromCell === undefined ||
fromCell.rows > 1 ||
fromCell.columns > 1
) {
from = pathsToEnd[from];
} else {
result.cells[to] = result.cells[from];
result.cells[from] = undefined;
filled = true;
to = pathsToEnd[to]!;
from = pathsToEnd[from];
}
}
// In case the path approach failed, fall back to taking the very last 1×1
// tile, and just dropping it into place
if (!filled) {
const last1By1Index = findLast1By1Index(result)!;
result.cells[gap] = result.cells[last1By1Index];
result.cells[last1By1Index] = undefined;
}
gap = getNextGap(result);
} while (gap !== null);
}
// TODO: If there are any large tiles on the last row, shuffle them back
// upwards into a full row
// Shrink the array to remove trailing gaps
const finalLength =
(findLastIndex(result.cells, (c) => c !== undefined) ?? -1) + 1;
if (finalLength < result.cells.length)
result.cells = result.cells.slice(0, finalLength);
return result;
}
export function appendItems(items: TileDescriptor[], g: Grid): Grid {
return {
...g,
cells: [
...g.cells,
...items.map((i) => ({
item: i,
origin: true,
columns: 1,
rows: 1,
})),
],
};
}
/**
* Changes the size of a tile, rearranging the grid to make space.
* @param tileId The ID of the tile to modify.
* @param g The grid.
* @returns The updated grid.
*/
export function cycleTileSize(tileId: string, g: Grid): Grid {
const from = g.cells.findIndex((c) => c?.item.id === tileId);
if (from === -1) return g; // Tile removed, no change
const fromWidth = g.cells[from]!.columns;
const fromHeight = g.cells[from]!.rows;
const fromEnd = areaEnd(from, fromWidth, fromHeight, g);
// The target dimensions, which toggle between 1×1 and larger than 1×1
const [toWidth, toHeight] =
fromWidth === 1 && fromHeight === 1
? [Math.min(3, Math.max(2, g.columns - 1)), 2]
: [1, 1];
// If we're expanding the tile, we want to create enough new rows at the
// tile's target position such that every new unit of grid area created during
// the expansion can fit within the new rows.
// We do it this way, since it's easier to backfill gaps in the grid than it
// is to push colliding tiles outwards.
const newRows = Math.max(
0,
Math.ceil((toWidth * toHeight - fromWidth * fromHeight) / g.columns)
);
// This is the grid with the new rows added
const gappyGrid: Grid = {
...g,
cells: new Array(g.cells.length + newRows * g.columns),
};
// The next task is to scan for a spot to place the modified tile. Since we
// might be creating new rows at the target position, this spot can be shorter
// than the target height.
const candidateWidth = toWidth;
const candidateHeight = toHeight - newRows;
// To make the tile appear to expand outwards from its center, we're actually
// scanning for locations to put the *center* of the tile. These numbers are
// the offsets between the tile's origin and its center.
const scanColumnOffset = Math.floor((toWidth - 1) / 2);
const scanRowOffset = Math.floor((toHeight - 1) / 2);
const nextScanLocations = new Set<number>([from]);
const rows = row(g.cells.length - 1, g) + 1;
let to: number | null = null;
// The contents of a given cell are 'displaceable' if it's empty, holds a 1×1
// tile, or is part of the original tile we're trying to reposition
const displaceable = (c: Cell | undefined, i: number): boolean =>
c === undefined ||
(c.columns === 1 && c.rows === 1) ||
inArea(i, from, fromEnd, g);
// Do the scanning
for (const scanLocation of nextScanLocations) {
const start = scanLocation - scanColumnOffset - g.columns * scanRowOffset;
const end = areaEnd(start, candidateWidth, candidateHeight, g);
const startColumn = column(start, g);
const startRow = row(start, g);
const endColumn = column(end, g);
const endRow = row(end, g);
if (
start >= 0 &&
endColumn - startColumn + 1 === candidateWidth &&
allCellsInArea(start, end, g, displaceable)
) {
// This location works!
to = start;
break;
}
// Scan outwards in all directions
if (startColumn > 0) nextScanLocations.add(scanLocation - 1);
if (endColumn < g.columns - 1) nextScanLocations.add(scanLocation + 1);
if (startRow > 0) nextScanLocations.add(scanLocation - g.columns);
if (endRow < rows - 1) nextScanLocations.add(scanLocation + g.columns);
}
// If there is no space in the grid, give up
if (to === null) return g;
const toRow = row(to, g);
// Copy tiles from the original grid to the new one, with the new rows
// inserted at the target location
g.cells.forEach((c, src) => {
if (c?.origin && c.item.id !== tileId) {
const offset =
row(src, g) > toRow + candidateHeight - 1 ? g.columns * newRows : 0;
forEachCellInArea(src, areaEnd(src, c.columns, c.rows, g), g, (c, i) => {
gappyGrid.cells[i + offset] = c;
});
}
});
// Place the tile in its target position, making a note of the tiles being
// overwritten
const displacedTiles: Cell[] = [];
const toEnd = areaEnd(to, toWidth, toHeight, g);
forEachCellInArea(to, toEnd, gappyGrid, (c, i) => {
if (c !== undefined) displacedTiles.push(c);
gappyGrid.cells[i] = {
item: g.cells[from]!.item,
origin: i === to,
columns: toWidth,
rows: toHeight,
};
});
// Place the displaced tiles in the remaining space
for (let i = 0; displacedTiles.length > 0; i++) {
if (gappyGrid.cells[i] === undefined)
gappyGrid.cells[i] = displacedTiles.shift();
}
// Fill any gaps that remain
return fillGaps(gappyGrid);
}

View File

@@ -25,6 +25,7 @@ interface CallFeedState {
videoMuted: boolean;
audioMuted: boolean;
localVolume: number;
hasAudio: boolean;
disposed: boolean | undefined;
stream: MediaStream | undefined;
purpose: SDPStreamMetadataPurpose | undefined;
@@ -38,6 +39,7 @@ function getCallFeedState(callFeed: CallFeed | undefined): CallFeedState {
videoMuted: callFeed ? callFeed.isVideoMuted() : true,
audioMuted: callFeed ? callFeed.isAudioMuted() : true,
localVolume: callFeed ? callFeed.getLocalVolume() : 0,
hasAudio: callFeed ? callFeed.stream.getAudioTracks().length >= 1 : false,
disposed: callFeed ? callFeed.disposed : undefined,
stream: callFeed ? callFeed.stream : undefined,
purpose: callFeed ? callFeed.purpose : undefined,

View File

@@ -158,8 +158,8 @@ export const useSpatialMediaStream = (
audioDestination: AudioNode,
localVolume: number,
mute = false
): [RefObject<HTMLDivElement>, RefObject<MediaElement>] => {
const tileRef = useRef<HTMLDivElement | null>(null);
): [RefObject<HTMLElement>, RefObject<MediaElement>] => {
const tileRef = useRef<HTMLElement | null>(null);
const [spatialAudio] = useSpatialAudio();
// This media stream is only used for the video - the audio goes via the audio

View File

@@ -101,7 +101,7 @@ export const widget: WidgetHelpers | null = (() => {
// We need to do this now rather than later because it has capabilities to
// request, and is responsible for starting the transport (should it be?)
const { roomId, userId, deviceId, baseUrl } = getUrlParams();
const { roomId, userId, deviceId, baseUrl, e2eEnabled } = getUrlParams();
if (!roomId) throw new Error("Room ID must be supplied");
if (!userId) throw new Error("User ID must be supplied");
if (!deviceId) throw new Error("Device ID must be supplied");
@@ -147,6 +147,7 @@ export const widget: WidgetHelpers | null = (() => {
userId,
deviceId,
timelineSupport: true,
useE2eForGroupCall: e2eEnabled,
}
);
const clientPromise = client.startClient().then(() => client);

View File

@@ -0,0 +1,283 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
appendItems,
column,
cycleTileSize,
fillGaps,
forEachCellInArea,
Grid,
row,
} from "../../src/video-grid/model";
import { TileDescriptor } from "../../src/video-grid/TileDescriptor";
/**
* Builds a grid from a string specifying the contents of each cell as a letter.
*/
function mkGrid(spec: string): Grid {
const secondNewline = spec.indexOf("\n", 1);
const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
const cells = spec.match(/[a-z ]/g) ?? [];
const areas = new Set(cells);
areas.delete(" "); // Space represents an empty cell, not an area
const grid: Grid = { columns, cells: new Array(cells.length) };
for (const area of areas) {
const start = cells.indexOf(area);
const end = cells.lastIndexOf(area);
const rows = row(end, grid) - row(start, grid) + 1;
const columns = column(end, grid) - column(start, grid) + 1;
forEachCellInArea(start, end, grid, (_c, i) => {
grid.cells[i] = {
item: { id: area } as unknown as TileDescriptor,
origin: i === start,
rows,
columns,
};
});
}
return grid;
}
/**
* Turns a grid into a string showing the contents of each cell as a letter.
*/
function showGrid(g: Grid): string {
let result = "\n";
g.cells.forEach((c, i) => {
if (i > 0 && i % g.columns == 0) result += "\n";
result += c?.item.id ?? " ";
});
return result;
}
function testFillGaps(title: string, input: string, output: string): void {
test(`fillGaps ${title}`, () => {
expect(showGrid(fillGaps(mkGrid(input)))).toBe(output);
});
}
testFillGaps(
"does nothing on an empty grid",
`
`,
`
`
);
testFillGaps(
"does nothing if there are no gaps",
`
ab
cd
ef`,
`
ab
cd
ef`
);
testFillGaps(
"fills a gap",
`
a b
cde
f`,
`
cab
fde`
);
testFillGaps(
"fills multiple gaps",
`
a bc
defgh
ijkl
mno`,
`
aebch
difgl
monjk`
);
testFillGaps(
"fills a big gap",
`
abcd
e f
g h
ijkl`,
`
abcd
elhf
gkji`
);
testFillGaps(
"only moves 1×1 tiles",
`
aa
bc`,
`
bc
aa`
);
testFillGaps(
"prefers moving around large tiles",
`
a bc
ddde
dddf
ghij
k`,
`
abce
dddf
dddj
kghi`
);
testFillGaps(
"moves through large tiles if necessary",
`
a bc
dddd
efgh
i`,
`
afbc
dddd
iegh`
);
function testCycleTileSize(
title: string,
tileId: string,
input: string,
output: string
): void {
test(`cycleTileSize ${title}`, () => {
expect(showGrid(cycleTileSize(tileId, mkGrid(input)))).toBe(output);
});
}
testCycleTileSize(
"does nothing if the tile is not present",
"z",
`
abcd
efgh`,
`
abcd
efgh`
);
testCycleTileSize(
"expands a tile to 2×2 in a 3 column layout",
"c",
`
abc
def
ghi`,
`
acc
bcc
def
ghi`
);
testCycleTileSize(
"expands a tile to 3×3 in a 4 column layout",
"g",
`
abcd
efgh`,
`
abcd
eggg
fggg
h`
);
testCycleTileSize(
"restores a tile to 1×1",
"b",
`
abbc
dbbe
fghi
jk`,
`
abhc
djge
fik`
);
testCycleTileSize(
"expands a tile even in a crowded grid",
"c",
`
abb
cbb
dde
ddf
ghi
klm`,
`
abb
gbb
dde
ddf
cci
cch
klm`
);
testCycleTileSize(
"does nothing if the tile has no room to expand",
"c",
`
abb
cbb
dde
ddf`,
`
abb
cbb
dde
ddf`
);
test("appendItems appends 1×1 tiles", () => {
const grid1 = `
aab
aac
d`;
const grid2 = `
aab
aac
def`;
const newItems = ["e", "f"].map(
(i) => ({ id: i } as unknown as TileDescriptor)
);
expect(showGrid(appendItems(newItems, mkGrid(grid1)))).toBe(grid2);
});

156
yarn.lock
View File

@@ -1795,7 +1795,7 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10":
"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13":
version "1.4.14"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
@@ -1821,9 +1821,14 @@
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz":
version "3.2.8"
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856"
"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.3":
version "0.1.0-alpha.4"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.4.tgz#1b20294e0354c3dcc9c7dc810d883198a4042f04"
integrity sha512-mdaDKrw3P5ZVCpq0ioW0pV6ihviDEbS8ZH36kpt9stLKHwwDSopPogE6CkQhi0B1jn1yBUtOYi32mBV/zcOR7g==
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz":
version "3.2.14"
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz#acd96c00a881d0f462e1f97a56c73742c8dbc984"
"@mdx-js/mdx@^1.6.22":
version "1.6.22"
@@ -2380,6 +2385,28 @@
"@sentry/utils" "6.19.7"
tslib "^1.9.3"
"@sentry/bundler-plugin-core@0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-0.3.0.tgz#de35a908c01a383611274572156845db6e46d30b"
integrity sha512-484beABAjdJa6thVgzOC0hBdgVpC0kg5r3S88U0zx3b/i6CTwUemHwamh6+RZ/gu8ChmooWb6NznaBAlpHgxCA==
dependencies:
"@sentry/cli" "^2.10.0"
"@sentry/node" "^7.19.0"
"@sentry/tracing" "^7.19.0"
magic-string "0.27.0"
unplugin "0.10.1"
"@sentry/cli@^2.10.0":
version "2.11.0"
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.11.0.tgz#a324cd1ae98e7d206f5ed739dc40c9728eb0fce8"
integrity sha512-qfCf/R0VhmlWcdfu2rntejqbIgovx7FQTwFreQpbISlB/JS9xHF8KEEJXZTdDFoPCi2H9KHg4CPUsCNAKbAdMA==
dependencies:
https-proxy-agent "^5.0.0"
node-fetch "^2.6.7"
progress "^2.0.3"
proxy-from-env "^1.1.0"
which "^2.0.2"
"@sentry/core@6.19.7":
version "6.19.7"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.19.7.tgz#156aaa56dd7fad8c89c145be6ad7a4f7209f9785"
@@ -2391,6 +2418,15 @@
"@sentry/utils" "6.19.7"
tslib "^1.9.3"
"@sentry/core@7.28.1":
version "7.28.1"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.28.1.tgz#c712ce17469b18b01606108817be24a99ed2116e"
integrity sha512-7wvnuvn/mrAfcugWoCG/3pqDIrUgH5t+HisMJMGw0h9Tc33KqrmqMDCQVvjlrr2pWrw/vuUCFdm8CbUHJ832oQ==
dependencies:
"@sentry/types" "7.28.1"
"@sentry/utils" "7.28.1"
tslib "^1.9.3"
"@sentry/hub@6.19.7":
version "6.19.7"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.19.7.tgz#58ad7776bbd31e9596a8ec46365b45cd8b9cfd11"
@@ -2409,6 +2445,19 @@
"@sentry/types" "6.19.7"
tslib "^1.9.3"
"@sentry/node@^7.19.0":
version "7.28.1"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.28.1.tgz#fc53675a048c29c86e5a8cd3ba570c454f492c18"
integrity sha512-n7AbpJqZJjWPpKNGc55mP7AdQ+XSomS9MZJuZ+Xt2AU52aVwGPI4z9aHUJFSDGaMHHiu/toyPnoUES+XZf6/hw==
dependencies:
"@sentry/core" "7.28.1"
"@sentry/types" "7.28.1"
"@sentry/utils" "7.28.1"
cookie "^0.4.1"
https-proxy-agent "^5.0.0"
lru_map "^0.3.3"
tslib "^1.9.3"
"@sentry/react@^6.13.3":
version "6.19.7"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.19.7.tgz#58cc2d6da20f7d3b0df40638dfbbbc86c9c85caf"
@@ -2432,11 +2481,26 @@
"@sentry/utils" "6.19.7"
tslib "^1.9.3"
"@sentry/tracing@^7.19.0":
version "7.28.1"
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-7.28.1.tgz#d276e4d17a79190a88112696c73de12c209607a1"
integrity sha512-uWspnuz+7FyW8ES5lRaVA7O/YJSzMlSkvBFtgzaoKmdaueokU/sRLwlCsrdgwavG1wpm79df7R1iiSeqhaXDlw==
dependencies:
"@sentry/core" "7.28.1"
"@sentry/types" "7.28.1"
"@sentry/utils" "7.28.1"
tslib "^1.9.3"
"@sentry/types@6.19.7":
version "6.19.7"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.19.7.tgz#c6b337912e588083fc2896eb012526cf7cfec7c7"
integrity sha512-jH84pDYE+hHIbVnab3Hr+ZXr1v8QABfhx39KknxqKWr2l0oEItzepV0URvbEhB446lk/S/59230dlUUIBGsXbg==
"@sentry/types@7.28.1":
version "7.28.1"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.28.1.tgz#9018b4c152b475de9bedd267237393d3c9b1253d"
integrity sha512-DvSplMVrVEmOzR2M161V5+B8Up3vR71xMqJOpWTzE9TqtFJRGPtqT/5OBsNJJw1+/j2ssMcnKwbEo9Q2EGeS6g==
"@sentry/types@^7.2.0":
version "7.13.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.13.0.tgz#398e33e5c92ea0ce91e2c86e3ab003fe00c471a2"
@@ -2450,6 +2514,21 @@
"@sentry/types" "6.19.7"
tslib "^1.9.3"
"@sentry/utils@7.28.1":
version "7.28.1"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.28.1.tgz#0a7b6aa4b09e91e4d1aded2a8c8dbaf818cee96e"
integrity sha512-75/jzLUO9HH09iC9TslNimGbxOP3jgn89P+q7uR+rp2fJfRExHVeKJZQdK0Ij4/SmE7TJ3Uh2r154N0INZEx1g==
dependencies:
"@sentry/types" "7.28.1"
tslib "^1.9.3"
"@sentry/vite-plugin@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-0.3.0.tgz#37d6a99ab7bf307b6ac3a5dc85ca16c52c2fff9c"
integrity sha512-clu0yfVk9ejk3l22NHrFUPqHzR9ukYccm7Q5qBKgMyLXnWGLRf4WahmzzuW9XHDu4s3tYVjV/rTTxGxLPT9dMQ==
dependencies:
"@sentry/bundler-plugin-core" "0.3.0"
"@sinclair/typebox@^0.24.1":
version "0.24.51"
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f"
@@ -5143,7 +5222,7 @@ cheerio@^1.0.0-rc.2:
parse5 "^7.0.0"
parse5-htmlparser2-tree-adapter "^7.0.0"
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1, chokidar@^3.4.2:
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1, chokidar@^3.4.2, chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@@ -5532,6 +5611,11 @@ cookie@0.5.0:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
cookie@^0.4.1:
version "0.4.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
copy-concurrently@^1.0.0:
version "1.0.5"
resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0"
@@ -8649,7 +8733,7 @@ https-browserify@^1.0.0:
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
integrity sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==
https-proxy-agent@^5.0.1:
https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
@@ -10199,11 +10283,23 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
lru_map@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd"
integrity sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==
lz-string@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
integrity sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==
magic-string@0.27.0:
version "0.27.0"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3"
integrity sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.4.13"
make-dir@^2.0.0, make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@@ -10266,11 +10362,12 @@ matrix-events-sdk@0.0.1:
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd"
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#2c8eece5ca5333c6e6a14e8ed53f359ed0e9e9bf":
version "21.2.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2c8eece5ca5333c6e6a14e8ed53f359ed0e9e9bf"
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#8cbbdaa239e449848e8874f041ef1879c1956696":
version "23.4.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8cbbdaa239e449848e8874f041ef1879c1956696"
dependencies:
"@babel/runtime" "^7.12.5"
"@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.3"
another-json "^0.2.0"
bs58 "^5.0.0"
content-type "^1.0.4"
@@ -10278,9 +10375,9 @@ matrix-events-sdk@0.0.1:
matrix-events-sdk "0.0.1"
matrix-widget-api "^1.0.0"
p-retry "4"
qs "^6.9.6"
sdp-transform "^2.14.1"
unhomoglyph "^1.0.6"
uuid "9"
matrix-widget-api@^1.0.0:
version "1.1.1"
@@ -11848,6 +11945,11 @@ process@^0.11.10:
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
progress@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
promise-inflight@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
@@ -11918,6 +12020,11 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
prr@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
@@ -11992,7 +12099,7 @@ qs@6.10.3:
dependencies:
side-channel "^1.0.4"
qs@^6.10.0, qs@^6.9.6:
qs@^6.10.0:
version "6.11.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
@@ -13610,6 +13717,11 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3:
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
tinyqueue@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08"
integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==
tmpl@1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
@@ -14051,6 +14163,16 @@ unpipe@1.0.0, unpipe@~1.0.0:
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
unplugin@0.10.1:
version "0.10.1"
resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-0.10.1.tgz#e00dc951c1901aef4124121057102a8c290e28b3"
integrity sha512-y1hdBitiLOJvCmer0/IGrMGmHplsm2oFRGWleoAJTRQ8aMHxHOe9gLntYlh1WNLKufBuQ2sOTrHF+KWH4xE8Ag==
dependencies:
acorn "^8.8.0"
chokidar "^3.5.3"
webpack-sources "^3.2.3"
webpack-virtual-modules "^0.4.5"
unset-value@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
@@ -14183,6 +14305,11 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid@9:
version "9.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
uuid@^3.3.2:
version "3.4.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
@@ -14487,6 +14614,11 @@ webpack-virtual-modules@^0.2.2:
dependencies:
debug "^3.0.0"
webpack-virtual-modules@^0.4.5:
version "0.4.6"
resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.4.6.tgz#3e4008230731f1db078d9cb6f68baf8571182b45"
integrity sha512-5tyDlKLqPfMqjT3Q9TAqf2YqjwmnUleZwzJi1A5qXnlBCdj2AtOJ6wAWdglTIDOPgOiOrXeBeFcsQ8+aGQ6QbA==
webpack@4:
version "4.46.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.46.0.tgz#bf9b4404ea20a073605e0a011d188d77cb6ad542"
@@ -14607,7 +14739,7 @@ which-typed-array@^1.1.2:
has-tostringtag "^1.0.0"
is-typed-array "^1.1.9"
which@^2.0.1:
which@^2.0.1, which@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==