Compare commits

...

211 Commits

Author SHA1 Message Date
Robin
b8774ad682 Merge pull request #2578 from robintown/spotlight-buttons
Fix spotlight tile regressions
2024-08-23 12:38:38 -04:00
Robin
30a54f3795 Fix spotlight tile regressions
The buttons were scrolling with the view instead of always being visible in a fixed location on the tile, and the indicators were not adopting the correct width.
2024-08-23 12:31:16 -04:00
Robin
66b79f57bb Merge pull request #2571 from element-hq/hughns/rust-crypto
Use Rust crypto implementation
2024-08-23 11:18:43 -04:00
Robin
61a24262de Merge pull request #2570 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2024-08-20 13:41:49 -04:00
renovate[bot]
0955d7bcc3 Update all non-major dependencies 2024-08-20 17:40:09 +00:00
Hugh Nimmo-Smith
36ce21d7ac Show crypto version in developer settings 2024-08-19 10:40:09 +01:00
Hugh Nimmo-Smith
eddc590235 Use rust crypto
Taken from d25cf28d00
2024-08-19 10:27:46 +01:00
Robin
61bc4dcc14 Merge pull request #2569 from robintown/horizontal-overflow
Fix long call names overflowing the interface
2024-08-16 16:41:15 -04:00
Robin
e2c4eae67b Make sure that the call interface can't scroll horizontally 2024-08-16 15:16:33 -04:00
Robin
1da3fe0731 Fix long call names overflowing the interface
They are now properly truncated with an ellipsis.
2024-08-16 15:15:51 -04:00
Hugh Nimmo-Smith
f562cc1e7f Show user's Matrix ID and device ID in developer settings tab (#2559) 2024-08-16 15:37:57 +01:00
Hugh Nimmo-Smith
69b762b9ed Bump js-sdk for sender key reliability improvements (#2567)
Diff from current version: 9176d3a671...467908703b
2024-08-15 11:49:19 +02:00
fkwp
ff55b1d189 Merge pull request #2564 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.5.0
2024-08-14 17:08:56 +02:00
fkwp
d796ebe3fa Merge pull request #2565 from element-hq/renovate/github-actions
Update docker/build-push-action action to v6.7.0
2024-08-14 17:08:16 +02:00
renovate[bot]
b4bc41ba02 Update docker/build-push-action action to v6.7.0 2024-08-14 15:07:05 +00:00
renovate[bot]
a072dfae9c Update dependency livekit-client to v2.5.0 2024-08-14 15:07:00 +00:00
fkwp
0eba3ef75f Merge pull request #2557 from element-hq/renovate/github-actions
Update GitHub Actions
2024-08-12 15:22:05 +02:00
renovate[bot]
2b9bf1fbe6 Update GitHub Actions 2024-08-12 13:18:51 +00:00
Doug
8769f8966d Clarify web server compatibility (#2555) 2024-08-12 08:06:05 -04:00
Robin
4e7b29e142 Merge pull request #2554 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2024-08-11 22:23:17 -04:00
renovate[bot]
977ba92dba Update all non-major dependencies 2024-08-12 02:12:14 +00:00
Robin
64e7047b12 Merge pull request #2552 from robintown/spotlight-left
Don't keep someone in the spotlight if they've left the call
2024-08-09 13:40:13 -04:00
Robin
ed99af0be6 Improve readability 2024-08-09 13:38:59 -04:00
Robin
52058716f6 Don't keep someone in the spotlight if they've left the call 2024-08-09 13:08:37 -04:00
Robin
29df87d22c Merge pull request #2548 from robintown/hide-controls
Show controls on tap/hover on small screens
2024-08-09 11:52:01 -04:00
Robin
6443e911dc Make the breakpoint a bit smaller 2024-08-09 11:09:45 -04:00
Robin
aa6b7056ae Show controls on tap/hover on small screens
This changes the mobile landscape view to automatically hide the controls, giving more visibility to the video underneath, and show them on tap/hover.
2024-08-09 11:09:45 -04:00
Robin
c20737ba4c Merge pull request #2546 from robintown/spotlight-duplication
Avoid duplicating the video of someone in the spotlight
2024-08-09 09:11:13 -04:00
Robin
6f03653532 Merge pull request #2545 from robintown/breakpoint
Consider any sufficiently short window 'flat'
2024-08-08 13:22:41 -04:00
Robin
2ec0aaa0de Merge pull request #2547 from robintown/t-grid
Avoid T-shaped layouts in 4 person calls
2024-08-08 13:22:20 -04:00
Robin
9b4ad24f10 Avoid T-shaped layouts in 4 person calls
The code path for when all tiles can fit on screen was failing to realize that it could sometimes get by with fewer columns. This resulted in wasted space for 4 person calls at some window sizes.
2024-08-08 12:46:38 -04:00
Robin
5069b008e2 Avoid duplicating the video of someone in the spotlight
We've gotten feedback that it's distracting whenever the same video is shown in two places on screen. This fixes the spotlight case by showing only the avatar of anyone who is already visible in the spotlight. It also makes sense to hide the speaking indicators in spotlight layouts, I think, because this information is redundant to the spotlight tile.
2024-08-08 12:16:32 -04:00
Robin
6d8e45aea8 Consider any sufficiently short window 'flat'
This is because our layouts for flat windows are good at adapting to both small width and small height, while our layouts for narrow windows aren't so good at adapting to a small height.
2024-08-08 11:30:57 -04:00
Andrew Ferrazzutti
f0f9b929a1 Update js-sdk to use non-legacy calls if found (#2540) 2024-08-07 13:00:19 -04:00
Robin
9b5072cc57 Merge pull request #2541 from robintown/local-on-local
Don't show local media on top of itself
2024-08-07 09:18:29 -04:00
Hugh Nimmo-Smith
b13fa85465 Add note on how to add a new translation key (#2536)
* Add note on how to add a new translation key

* Lint

* Nit
2024-08-07 10:40:44 +01:00
Robin
bf5128cfee Don't show local media on top of itself
If you were the only one in the call, you could get a broken-looking view in which the local tile is shown in the spotlight, and it's also shown in the PiP. This is redundant.
2024-08-06 17:12:13 -04:00
Robin
f928e63c7b Merge pull request #2539 from robintown/duplicate-tiles-crash
Fix a crash when the duplicate tiles option is empty
2024-08-06 16:24:58 -04:00
Robin
eef92249f7 Fix a crash when the duplicate tiles option is empty
We need to handle the case where the value is NaN because the field is empty.
2024-08-06 10:56:15 -04:00
Robin
04ad44f900 Merge pull request #2534 from element-hq/renovate/postcss-preset-env-10.x
Update dependency postcss-preset-env to v10
2024-08-06 10:11:20 -04:00
Robin
90072aa2bb Merge pull request #2538 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v1.8.0
2024-08-06 10:10:35 -04:00
Robin
ab42fe97cb Merge pull request #2514 from robintown/mobile-layouts
Improve the layouts on small mobile calls
2024-08-06 10:10:29 -04:00
Robin
f4cf3d8c62 Adjust the breakpoint 2024-08-06 10:08:56 -04:00
renovate[bot]
1782a0eaf3 Update dependency @vector-im/compound-design-tokens to v1.8.0 2024-08-06 01:14:19 +00:00
renovate[bot]
5bf46eb8f8 Update all non-major dependencies (#2535)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-08-05 12:30:45 +02:00
renovate[bot]
b4973bbc6b Update dependency postcss-preset-env to v10 2024-08-04 00:40:54 +00:00
Robin
eaf3fb13c1 Merge pull request #2448 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-web to v6
2024-08-02 15:38:17 -04:00
Robin
b503056673 Adapt to breaking changes in Compound 2024-08-02 15:27:49 -04:00
renovate[bot]
86e3c346a4 Update dependency @vector-im/compound-web to v6 2024-08-02 19:23:22 +00:00
Andrew Ferrazzutti
7449e1f6e4 Don't refer to MSC3779 explicitly in comment (#2533)
because other MSCs (like 3757) may allow @-prefixed state keys
2024-08-02 15:15:54 +00:00
Robin
aadf6c05ac Merge pull request #2530 from robintown/no-you
Show your own name on your tile
2024-08-02 10:45:32 -04:00
Robin
39ee8d838e Merge pull request #2531 from robintown/iterate-margins
Add back some margins to the interface
2024-08-02 08:42:03 -04:00
Andrew Ferrazzutti
1f10245adc Bump matrix-widget-api (#2529) 2024-08-02 08:37:55 -04:00
Robin
c1de41106f Merge pull request #2532 from robintown/renovate-warning
Fix Renovate warning
2024-08-01 17:19:37 -04:00
Robin
e12bad952a Fix Renovate warning
Apparently Renovate doesn't really like it when you use a group: preset inside packageRules, instead of at the top level of the config. We do want to apply schedule:weekly only to the "all non-major dependencies" group though, so we need to write the group definition out by hand.
2024-08-01 16:41:19 -04:00
Robin
7abb56e406 Add back some margins to the interface
There were a couple of cases where the lack of margins after the new layout changes just looked odd. Specifically, when the header is hidden (as in embedded mode), there would be no margin at the top of the window. Also the floating tile would run directly up against the sides of the window.
2024-08-01 16:33:10 -04:00
Robin
00d8100dfe Show your own name on your tile
Instead of the word "You".
2024-08-01 15:48:22 -04:00
Robin
eb051ab318 Replace the mobile one-on-one layout with an edge-to-edge spotlight 2024-08-01 13:49:49 -04:00
Robin
942e28f3c2 Improve the layouts on small mobile calls
Due to an oversight of mine, 2440037639 actually removed the ability to see the one-on-one layout on mobile. This restores mobile one-on-one calls to working order and also avoids showing the spotlight tile unless there are more than a few participants.
2024-08-01 13:49:49 -04:00
Robin
0bfec65405 Refactor layout selection into smaller chunks 2024-08-01 13:49:49 -04:00
Robin
f89342713a Merge pull request #2528 from robintown/remote-spotlight
More strongly prefer putting a remote speaker in the spotlight
2024-08-01 13:24:57 -04:00
Robin
5a0b81b57f More strongly prefer putting a remote speaker in the spotlight
If no one had spoken yet, we were still showing the local user in the spotlight. We should instead eagerly switch to showing an arbitrary remote participant in this case.
2024-08-01 12:48:47 -04:00
Timo
f9323d8b2c Add future related widget capabilities (#2505)
* add future related widget capabilities

* Update js sdk
2024-08-01 11:41:47 -04:00
renovate[bot]
c68d536d80 Update LiveKit components (#2525)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-08-01 16:30:24 +02:00
renovate[bot]
fde7dbedaa Update dependency matrix-widget-api to v1.8.1 (#2527)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-08-01 16:29:50 +02:00
fkwp
7e240e96b7 Merge pull request #2526 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.4.2
2024-08-01 16:04:53 +02:00
renovate[bot]
f84800363f Update dependency livekit-client to v2.4.2 2024-08-01 13:26:05 +00:00
fkwp
f9e12c8ff3 Merge pull request #2524 from element-hq/renovate/github-actions
Update GitHub Actions
2024-08-01 09:28:05 +02:00
renovate[bot]
6abd1fbca1 Update GitHub Actions 2024-08-01 01:04:00 +00:00
Timo
599a4708cb Backport deviceSetup await (#2522) 2024-07-31 13:21:37 +02:00
Timo
f53ea75c94 Add DeviceMute widget action io.element.device_mute. (#2482)
* Add DeviceMute widget action `io.element.device_mute`.
This allows to send mute requests ("toWidget") and get the current mute state as a response.
And it will update the client about each change of mute states.

* review + better explanation

* review

* add comments

* use `useCallback`
2024-07-30 13:30:33 +02:00
Robin
2b67a9cfbe Merge pull request #2486 from robintown/delete-fullscreen
Delete the unused full screen code
2024-07-29 09:28:07 -04:00
fkwp
d582a7cc29 Merge pull request #2519 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.4.1
2024-07-29 11:35:26 +02:00
renovate[bot]
8757f07982 Update dependency livekit-client to v2.4.1 2024-07-29 09:32:35 +00:00
fkwp
5b8910d265 Merge pull request #2518 from element-hq/renovate/livekit-components
Update LiveKit components
2024-07-29 11:31:06 +02:00
renovate[bot]
a03ab6c9fa Update LiveKit components 2024-07-29 01:38:23 +00:00
Robin
a3ce333352 Only show the expand button in spotlight layout (#2510)
It has no effect in any layout other than spotlight, and we've decided to hide it rather than spending effort to make it do something.
2024-07-26 12:57:49 +02:00
Robin
d5faa5ea90 Don't show the speaker in the spotlight in large grids (#2511)
We've concluded that this behavior is actually more distracting than it is helpful, and we want to try out what it's like to just have the importance ordering and visual cues help you find who's speaking.
2024-07-26 12:51:34 +02:00
Robin
5becd2e175 Fix a crash when using the duplicate tiles option (#2512) 2024-07-26 12:51:09 +02:00
Robin
3b38a5322c Give tiles a minimum area rather than a minimum width and height (#2513)
This seems to result in more sensible cropping and allocation of space across the board, in my testing.
2024-07-26 12:50:44 +02:00
Robin
d062871f41 Don't consider microphone mute state in importance ordering (#2515)
We're finding that if we reorder participants based on whether their mic is muted, this just creates a lot of distracting layout shifts. People who speak are automatically promoted into the speaker category, so there's little value in additionally caring about mute state.
2024-07-26 11:27:22 +02:00
renovate[bot]
6b64bdfdb5 Update dependency @vector-im/compound-design-tokens to v1.7.0 (#2516)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-26 06:37:46 +02:00
Robin
2de4705fa7 Merge pull request #2509 from robintown/icon-imports
Import Compound icons in the new recommended way
2024-07-25 14:26:41 -04:00
Robin
12e233970c Import Compound icons in the new recommended way
The Compound design tokens package is now set up to generate React components for every icon, so we no longer need to use our more error-prone method of importing the SVGs.
2024-07-25 13:15:45 -04:00
Robin
10b915c707 Merge pull request #2501 from robintown/layout-reactivity
Make layout reactivity less brittle
2024-07-25 12:51:39 -04:00
Robin
5544695f21 Use clearer names 2024-07-25 12:50:28 -04:00
Timo
72de8e066c fix grammar (#2506) 2024-07-25 14:33:37 +02:00
Robin
63afda05bc Merge pull request #2502 from robintown/shortcut-a11y
Improve accessibility of keyboard shortcuts
2024-07-25 08:24:13 -04:00
Timo
b05c4234b7 Remove hide header condition (#2493) 2024-07-25 11:32:05 +02:00
renovate[bot]
80ddb7495d Update dependency eslint-plugin-unicorn to v55 (#2503)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-25 10:35:26 +02:00
Robin
380f49fccc Improve accessibility of keyboard shortcuts
Ensure that they don't interfere with say, using spacebar to press a button, and also ensure that they won't do surprising things like scroll the page at the same time.
2024-07-24 18:42:21 -04:00
Robin
447bac3280 Make layout reactivity less brittle
Follow-up to ea2d98179c

This took a couple of iterations to find something that works without creating update loops, but I think that by automatically informing Grid whenever a layout component is re-rendered, we'll have a much easier time ensuring that our layouts are fully reactive.
2024-07-24 17:03:27 -04:00
Robin
c74cebcc4b Merge pull request #2492 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2024-07-24 14:17:48 -04:00
renovate[bot]
cd0aa0ced6 Update all non-major dependencies 2024-07-24 18:13:38 +00:00
Robin
9cbd146e24 Merge pull request #2491 from element-hq/renovate/matrix-widget-api
Update dependency matrix-widget-api to v1.7.0
2024-07-24 11:14:46 -04:00
Robin
509bb4f1b0 Use LTS Node in CI 2024-07-24 11:12:36 -04:00
Robin
3be3a32f3d Update TypeScript target to match matrix-js-sdk
And work around https://github.com/microsoft/TypeScript/issues/55132
2024-07-24 11:07:46 -04:00
Robin
17adfc5777 Upgrade matrix-js-sdk 2024-07-24 10:17:47 -04:00
fkwp
4eb1be678d Merge pull request #2499 from element-hq/renovate/livekit-components
Update LiveKit components
2024-07-24 15:27:53 +02:00
renovate[bot]
b34e7d00e9 Update LiveKit components 2024-07-24 12:57:28 +00:00
fkwp
78f4c2a650 Merge pull request #2498 from element-hq/fkwp/cleanup_config
Fkwp/cleanup config
2024-07-24 13:04:11 +02:00
Timo
a3773c0a9a prettier 2024-07-24 13:00:13 +02:00
fkwp
2b92ce8af2 enable new m.call.member format 2024-07-24 12:19:36 +02:00
fkwp
5564e2fde6 cleanup config files 2024-07-24 10:18:16 +02:00
Robin
35e2d2c432 Merge pull request #2494 from robintown/spotlight-fix
Quick and dirty fix to spotlight reactivity
2024-07-22 10:55:20 -04:00
Robin
ea2d98179c Quick and dirty fix to spotlight reactivity 2024-07-22 10:52:20 -04:00
renovate[bot]
d83a104dda Update dependency matrix-widget-api to v1.7.0 2024-07-19 14:32:06 +00:00
fkwp
58f274eabf Merge pull request #2490 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.4.0
2024-07-19 16:30:31 +02:00
renovate[bot]
632ad07304 Update dependency livekit-client to v2.4.0 2024-07-19 14:28:43 +00:00
Robin
4173fd113b Merge pull request #2485 from element-hq/new-call-layouts
New call layouts
2024-07-19 09:08:51 -04:00
renovate[bot]
56b5f2845d Update dependency @vector-im/compound-design-tokens to v1.6.1 (#2487)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-19 13:33:24 +02:00
Timo
afee9eaa26 Don't update mute when reaching the user count threshold. (#2474)
* Dont update mute during call.
2024-07-18 18:14:29 +02:00
Robin
364b78abda Delete the unused full screen code
We no longer allow individual tiles to be put in full screen, because we're seeing what it's like to just stretch the spotlight tile edge-to-edge and keep the margins minimal.
2024-07-18 11:48:06 -04:00
Robin
507b1fc52d Merge branch 'livekit' into new-call-layouts 2024-07-18 11:38:35 -04:00
Robin
6812c35a40 Merge pull request #2463 from robintown/rest-of-the-layouts
Implement most of the remaining layout changes
2024-07-18 11:34:47 -04:00
Robin
377b7ff5de Explain each layout 2024-07-18 11:33:20 -04:00
Robin
4955535374 Use more consistent names for layout types 2024-07-18 11:24:18 -04:00
Robin
0664f978e3 Merge branch 'new-call-layouts' into rest-of-the-layouts 2024-07-18 11:21:56 -04:00
Robin
bcc06d86ff Merge pull request #2417 from robintown/one-on-one-layout
New one-on-one layout
2024-07-18 11:09:11 -04:00
Robin
7526826b0c Improve aspect ratios on mobile 2024-07-18 11:01:21 -04:00
Robin
b4e0df75c0 Merge branch 'new-call-layouts' into one-on-one-layout 2024-07-18 10:28:17 -04:00
Robin
d561a41666 Merge pull request #2416 from robintown/grid-spotlight-speaker
Show speaker in the spotlight in large grids
2024-07-18 10:17:31 -04:00
Timo
d53ad9a8f3 Update sample config with livekit (rebase on livekit) (#2483)
* Update sample cfg with livekit config

* matching ports in readme and example

---------

Co-authored-by: xmj <xmj@chaot.net>
2024-07-18 16:01:10 +02:00
Robin
e04affe93e Justify the use of a participant count threshold 2024-07-18 10:00:26 -04:00
Robin
24870deead Merge pull request #2382 from robintown/spotlight-layout
New spotlight layout
2024-07-18 08:50:31 -04:00
Robin
7fcd7125c1 Merge branch 'new-call-layouts' into spotlight-layout 2024-07-18 08:48:50 -04:00
Robin
1efa594430 Use Array.some where it's appropriate 2024-07-17 16:06:48 -04:00
Robin
caea4b250e Merge pull request #2381 from robintown/observable-hooks
Replace react-rxjs with observable-hooks
2024-07-17 15:56:31 -04:00
Robin
0a8c6c1454 Merge branch 'new-call-layouts' into observable-hooks 2024-07-17 15:55:50 -04:00
Robin
d4a2617f7b Merge pull request #2380 from robintown/pin-always-show
Add toggle to always show yourself
2024-07-17 15:45:29 -04:00
Robin
e05c6f1bdf Merge pull request #2369 from robintown/duplicate-tiles
Add a developer option to duplicate tiles
2024-07-17 15:41:53 -04:00
Robin
2bc56dbff2 Use fewer ML-style variable names 2024-07-17 15:40:02 -04:00
Robin
a59875dab5 Explain what each sorting bin means 2024-07-17 15:37:41 -04:00
Robin
8c21e8f277 Use a more descriptive string 2024-07-17 14:55:45 -04:00
Robin
d8634eed3d Merge pull request #2484 from element-hq/renovate/react-i18next-15.x
Update dependency react-i18next to v15
2024-07-17 13:14:11 -04:00
renovate[bot]
be4b70c1e1 Update dependency react-i18next to v15 2024-07-17 16:51:55 +00:00
renovate[bot]
e79cded57f Update all non-major dependencies (#2479)
* Update all non-major dependencies

* prettier fixes

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Timo <toger5@hotmail.de>
2024-07-17 10:07:26 +02:00
Robin
2440037639 Implement most of the remaining layout changes
Includes the mobile UX optimizations and the tweaks we've made to cut down on wasted space, but does not yet include the change to embed the spotlight tile within the grid.
2024-07-12 15:50:17 -04:00
Robin
a16f235277 Fix crash in spotlight mode while connecting
Because we were hiding even the local participant during initial connection, there would be no participants, and therefore nothing to put in the spotlight. The designs don't really tell us what the connecting state should look like, so I've taken the liberty of restoring it to its former glory of showing the local participant immediately.
2024-07-12 15:49:45 -04:00
Robin
45c89a2298 Delete the legacy grid system 2024-07-12 15:49:43 -04:00
Robin
7979493371 Implement the new one-on-one layout 2024-07-12 15:47:56 -04:00
Robin
e0b10d89b5 Add model for one-on-one layout 2024-07-12 15:47:56 -04:00
Robin
183d2d9050 Show speaker in the spotlight in large grids 2024-07-12 15:47:35 -04:00
Robin
12b719da95 Make layout reactivity a little more fine-grained 2024-07-12 15:47:00 -04:00
Robin
dfda7539d6 Only switch to spotlight for remote screen shares 2024-07-12 15:47:00 -04:00
Robin
7f40ce8dde Fix advance buttons showing up for the spotlight speaker 2024-07-12 15:47:00 -04:00
Robin
ec1b020d4e Add indicators to spotlight tile and make spotlight layout responsive 2024-07-12 15:47:00 -04:00
Robin
54c22f4ab2 Clean up spotlight tile code 2024-07-12 15:47:00 -04:00
Robin
ffbbc74a96 Implement the new spotlight layout 2024-07-12 15:47:00 -04:00
Robin
34c45cb5e2 Get the right grid offset even when offsetParent is a layout element 2024-07-12 15:47:00 -04:00
Robin
af0bd795b5 Replace react-rxjs with observable-hooks
react-rxjs is the library we've been using to connect our React components to view models and consume observables. However, after spending some time with react-rxjs, I feel that it's a very heavy-handed solution. It requires us to sprinkle <Subscribe /> and <RemoveSubscribe /> components all throughout the code, and makes React go through an extra render cycle whenever we mount a component that binds to a view model. What I really want is a lightweight React hook that just gets the current value out of a plain observable, without any extra setup. Luckily the observable-hooks library with its useObservableEagerState hook seems to do just that—and it's more actively maintained, too!
2024-07-12 15:46:33 -04:00
Robin
0d485ef97f Use always show flag in importance ordering 2024-07-12 15:43:24 -04:00
Robin
5647619b36 Add always show toggle to the UI 2024-07-12 15:43:24 -04:00
Robin
8a414012a0 Add always show flag to view model 2024-07-12 15:43:24 -04:00
Robin
e33fbd77d1 Split local and remote user media into different classes 2024-07-12 15:43:24 -04:00
Robin
fdc6d4a1b6 Add a developer option to duplicate tiles
This is useful for testing how the UI behaves with different numbers of participants.
2024-07-12 14:55:29 -04:00
Robin
a534356dd9 Merge pull request #2368 from robintown/settings-refactor
Refactor settings to use observables
2024-07-12 14:50:46 -04:00
Robin
f847692953 Merge pull request #2325 from robintown/unified-grid
Unified grid layout
2024-07-12 14:50:35 -04:00
Robin
486430d1f0 Merge pull request #2478 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v1.6.0
2024-07-12 14:19:29 -04:00
Robin
599d6fd007 Address review feedback 2024-07-12 14:15:27 -04:00
Robin
14fc1481f3 Address some review feedback 2024-07-12 14:01:32 -04:00
renovate[bot]
e6ddf40b1b Update dependency @vector-im/compound-design-tokens to v1.6.0 2024-07-12 16:55:33 +00:00
fkwp
9f521a79f7 Merge pull request #2477 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.3.2
2024-07-11 10:22:54 +02:00
fkwp
83784a717a Merge pull request #2476 from element-hq/renovate/livekit-components
Update dependency @livekit/components-react to v2.3.6
2024-07-11 10:22:18 +02:00
renovate[bot]
0729deee79 Update dependency livekit-client to v2.3.2 2024-07-11 08:18:46 +00:00
renovate[bot]
77c3114cf8 Update dependency @livekit/components-react to v2.3.6 2024-07-11 08:18:27 +00:00
Robin
82a56c8204 Merge pull request #2471 from element-hq/renovate/major-vitest-monorepo
Update dependency vitest to v2
2024-07-09 10:42:23 -04:00
renovate[bot]
b39896d8c6 Update dependency @vector-im/compound-design-tokens to v1.4.0 (#2472)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-09 11:34:44 +02:00
renovate[bot]
79b3fdb645 Update dependency vitest to v2 2024-07-08 13:01:11 +00:00
renovate[bot]
0f877cd021 Update dependency tinyqueue to v3 (#2468)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-08 09:12:26 +02:00
renovate[bot]
db2acc75b2 Update LiveKit components (#2469)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-08 09:11:56 +02:00
renovate[bot]
a5dbfbf2c1 Update all non-major dependencies (#2470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-08 09:11:25 +02:00
Andrew Ferrazzutti
34c7d02de2 Add config to send session membership state events (#2460)
If not set, legacy call membership state events are sent instead.
Even if set, legacy events are sent in rooms with active legacy calls.

---------

Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2024-07-05 21:10:16 +09:00
Andrew Ferrazzutti
ca45067158 Let Element Call widget set session memberships (#2459)
Make Element Call widgets request permission to set device-specific
session membership state events.
2024-07-05 04:59:48 +09:00
Andrew Ferrazzutti
5a6eb7c573 Make widgets request the room creation event (#2457)
This allows the widget to check the room version, so it can know about
version-specific auth rules (namely MSC3779).
2024-07-05 04:57:45 +09:00
Robin
41083c0f9e Refactor settings to use observables
Also removing some unused settings along the way.
2024-07-03 15:29:32 -04:00
Robin
20602c122b Implement the new unified grid layout
Here I've implemented an MVP for the new unified grid layout, which scales smoothly up to arbitrarily many participants. It doesn't yet have a special 1:1 layout, so in spotlight mode and 1:1s, we will still fall back to the legacy grid systems.

Things that happened along the way:
- The part of VideoTile that is common to both spotlight and grid tiles, I refactored into MediaView
- VideoTile renamed to GridTile
- Added SpotlightTile for the new, glassy spotlight designs
- NewVideoGrid renamed to Grid, and refactored to be even more generic
- I extracted the media name logic into a custom React hook
- Deleted the BigGrid experiment
2024-07-03 15:29:08 -04:00
Robin
5ad2a27a92 Merge pull request #2462 from element-hq/renovate/github-actions
Update docker/build-push-action action to v6.3.0
2024-07-03 15:25:13 -04:00
renovate[bot]
68daaa45f9 Update docker/build-push-action action to v6.3.0 2024-07-03 11:29:17 +00:00
renovate[bot]
c40ea35937 Update all non-major dependencies (#2461)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-02 10:41:19 +02:00
Timo
d27f433175 Skip error screen for the issue, that the homeserver does not support the room summary endpoint. (#2453)
* Add try inner try block to the room summary fetching and only throw after fetching and a "blind join" fails.
(blind join: call room.join without knowing if the room is public)

Co-authored-by: Robin <robin@robin.town>

---------

Co-authored-by: Robin <robin@robin.town>
2024-06-25 08:44:02 +00:00
Robin
8a6101cd14 Merge pull request #2456 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2024-06-24 10:37:18 -04:00
Robin
4db7c2bc68 Fix type errors 2024-06-24 10:31:50 -04:00
renovate[bot]
18740fc686 Update all non-major dependencies 2024-06-24 13:04:28 +00:00
Robin
0c39398493 Merge pull request #2447 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v1.3.0
2024-06-21 10:39:03 -04:00
Robin
949145f04b Merge pull request #2446 from element-hq/renovate/major-testing-library-monorepo
Update dependency @testing-library/react to v16
2024-06-21 10:32:30 -04:00
Robin
8578dcadf2 Add missing peer dependencies 2024-06-21 10:30:39 -04:00
renovate[bot]
959db44eca Update dependency @testing-library/react to v16 2024-06-21 10:30:28 -04:00
Robin
a031c0e128 Merge pull request #2445 from element-hq/renovate/major-sentry-javascript-monorepo
Update dependency @sentry/react to v8
2024-06-21 10:25:04 -04:00
Robin
591833505f Adapt to breaking changes 2024-06-21 10:23:30 -04:00
Robin
f7ad5074d8 Merge pull request #2452 from element-hq/renovate/i18next-parser-9.x
Update dependency i18next-parser to v9
2024-06-21 10:14:39 -04:00
Robin
e0aef74bf5 Merge pull request #2454 from element-hq/renovate/uuid-10.x
Update dependency uuid to v10
2024-06-21 10:13:01 -04:00
renovate[bot]
b2378bf899 Update dependency i18next-parser to v9 2024-06-21 14:12:34 +00:00
Robin
255f6b1814 Merge pull request #2451 from element-hq/renovate/i18next-browser-languagedetector-8.x
Update dependency i18next-browser-languagedetector to v8
2024-06-21 10:11:59 -04:00
Robin
4c491b5363 Merge pull request #2450 from element-hq/renovate/eslint-plugin-unicorn-54.x
Update dependency eslint-plugin-unicorn to v54
2024-06-21 10:11:23 -04:00
Robin
61c808d4cf Merge pull request #2455 from element-hq/renovate/github-actions
Pin dependencies
2024-06-21 10:00:40 -04:00
Robin
13ef3183e2 Tell Renovate that we're trying to pin actions to specific tags
It thought that we were just trying to follow the latest commit on these actions, when in reality we want to follow the latest tag and pin its commit hash.
2024-06-21 09:57:48 -04:00
renovate[bot]
afd4fdcea2 Pin dependencies 2024-06-21 13:21:08 +00:00
Robin
982181ccd4 Merge pull request #2444 from robintown/more-renovate
Refine Renovate config further
2024-06-21 09:10:16 -04:00
renovate[bot]
30629ebba2 Update dependency uuid to v10 2024-06-21 10:07:33 +00:00
renovate[bot]
7f6a32d21a Update dependency i18next-browser-languagedetector to v8 2024-06-21 08:46:24 +00:00
renovate[bot]
320ade0a50 Update dependency eslint-plugin-unicorn to v54 2024-06-21 04:03:53 +00:00
renovate[bot]
8c6fee3150 Update dependency @vector-im/compound-design-tokens to v1.3.0 2024-06-21 00:35:09 +00:00
renovate[bot]
5c6acaf915 Update dependency @sentry/react to v8 2024-06-20 21:46:07 +00:00
Robin
c46549b2b6 Refine Renovate config further
By getting it to pin GitHub Actions to specific commits, which hardens our workflows, and fixing a warning about matchPackageNames
2024-06-20 16:00:52 -04:00
fkwp
97a58f6db7 Merge pull request #2442 from element-hq/renovate/docker-build-push-action-digest
Update docker/build-push-action digest to 31159d4
2024-06-20 21:48:42 +02:00
fkwp
b6288579c9 Merge pull request #2443 from element-hq/renovate/docker-setup-buildx-action-digest
Update docker/setup-buildx-action digest to abe89fb
2024-06-20 21:48:25 +02:00
renovate[bot]
44bf987cdc Update docker/setup-buildx-action digest to abe89fb 2024-06-20 19:45:16 +00:00
renovate[bot]
a7d55824bb Update docker/build-push-action digest to 31159d4 2024-06-20 19:45:13 +00:00
Robin
8fa038c61f Merge pull request #2441 from robintown/renovate-noise
Reduce noise coming from Renovate updates
2024-06-20 15:44:38 -04:00
Robin
869d9b43cb Reduce noise coming from Renovate updates
What I've tried to do here is to group most dependency updates together and put them on a weekly schedule. Some of our more sensitive dependencies such as LiveKit and Compound have been put into separate groups, so we still receive frequent updates for them.
2024-06-20 15:43:21 -04:00
96 changed files with 7235 additions and 8209 deletions

View File

@@ -38,15 +38,6 @@ module.exports = {
"jsx-a11y/media-has-caption": "off",
// We should use the js-sdk logger, never console directly.
"no-console": ["error"],
"no-restricted-imports": [
"error",
{
name: "@react-rxjs/core",
importNames: ["Subscribe", "RemoveSubscribe"],
message:
"These components are easy to misuse, please use the 'subscribe' component wrapper instead",
},
],
"react/display-name": "error",
},
settings: {

View File

@@ -23,10 +23,10 @@ jobs:
packages: write
steps:
- name: Check it out
uses: actions/checkout@v4
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- name: 📥 Download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ inputs.artifact_run_id }}
@@ -34,7 +34,7 @@ jobs:
path: dist
- name: Log in to container registry
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -42,16 +42,16 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@f7b4ed12385588c3f9bc252f0a2b520d83b52d48
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: ${{ inputs.docker_tags}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@5138f76647652447004da686b2411557eaf65f33
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
- name: Build and push Docker image
uses: docker/build-push-action@f6010ea70151369b06f0194be1051fbbdff851b2
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out test private repo
uses: actions/checkout@v4
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
with:
repository: element-hq/static-call-participant
ref: refs/heads/main

View File

@@ -21,11 +21,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- name: Yarn cache
uses: actions/setup-node@v4
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
with:
cache: "yarn"
node-version: "lts/*"
- name: Install dependencies
run: "yarn install"
- name: Build
@@ -38,7 +39,7 @@ jobs:
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
NODE_OPTIONS: "--max-old-space-size=4096"
- name: Upload Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4
with:
name: build-output
path: dist

View File

@@ -7,11 +7,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- name: Yarn cache
uses: actions/setup-node@v4
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
with:
cache: "yarn"
node-version: "lts/*"
- name: Install dependencies
run: "yarn install"
- name: Prettier

View File

@@ -34,7 +34,7 @@ jobs:
environment: Netlify
steps:
- name: 📝 Create Deployment
uses: bobheadxi/deployments@v1
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
id: deployment
with:
step: start
@@ -46,7 +46,7 @@ jobs:
Exercise caution. Use test accounts.
- name: 📥 Download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ inputs.artifact_run_id }}
@@ -58,11 +58,11 @@ jobs:
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
- name: Add config file
run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/element_io_preview.json" > webapp/config.json
run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json
- name: ☁️ Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@v3.0
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
with:
publish-dir: webapp
deploy-message: "Deploy from GitHub Actions"
@@ -73,7 +73,7 @@ jobs:
timeout-minutes: 1
- name: 🚦 Update deployment status
uses: bobheadxi/deployments@v1
uses: bobheadxi/deployments@648679e8e4915b27893bd7dbc35cb504dc915bc8 # v1
if: always()
with:
step: finish

View File

@@ -14,7 +14,7 @@ jobs:
pr_data_json: ${{ steps.prdetails.outputs.data }}
steps:
- id: prdetails
uses: matrix-org/pr-details-action@v1.3
uses: matrix-org/pr-details-action@15bde5285d7850ba276cc3bd8a03733e3f24622a # v1.3
continue-on-error: true
with:
owner: ${{ github.event.workflow_run.head_repository.owner.login }}

View File

@@ -39,7 +39,7 @@ jobs:
id: current-time
run: echo "unix_time=$(date +'%s')" >> $GITHUB_OUTPUT
- name: 📥 Download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -51,7 +51,7 @@ jobs:
run: |
tar --numeric-owner --transform "s/dist/element-call-${TARBALL_VERSION}/" -cvzf element-call-${TARBALL_VERSION}.tar.gz dist
- name: Upload
uses: actions/upload-artifact@552bf3722c16e81001aea7db72d8cedf64eb5f68
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
env:
GITHUB_TOKEN: ${{ github.token }}
with:

View File

@@ -9,16 +9,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- name: Yarn cache
uses: actions/setup-node@v4
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
with:
cache: "yarn"
node-version: "lts/*"
- name: Install dependencies
run: "yarn install"
- name: Vitest
run: "yarn run test"
- name: Upload to codecov
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4
with:
flags: unittests

View File

@@ -13,11 +13,12 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
with:
cache: "yarn"
node-version: "lts/*"
- name: Install Deps
run: "yarn install --frozen-lockfile"
@@ -26,7 +27,7 @@ jobs:
run: "rm -R public/locales"
- name: Download translation files
uses: localazy/download@v1.1.0
uses: localazy/download@0a79880fb66150601e3b43606fab69c88123c087 # v1.1.0
with:
groups: "-p includeSourceLang:true"
@@ -38,7 +39,7 @@ jobs:
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v6.0.5
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/localazy-download

View File

@@ -14,9 +14,9 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- name: Upload
uses: localazy/upload@v1
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
with:
write_key: ${{ secrets.LOCALAZY_WRITE_KEY }}

View File

@@ -22,7 +22,7 @@ yarn
yarn build
```
If all went well, you can now find the build output under `dist` as a series of static files. These can be hosted using any web server of your choice.
If all went well, you can now find the build output under `dist` as a series of static files. These can be hosted using any web server that can be configured with custom routes (see below).
You may also wish to add a configuration file (Element Call uses the domain it's hosted on as a Homeserver URL by default,
but you can change this in the config file). This goes in `public/config.json` - you can use the sample as a starting point:
@@ -131,7 +131,7 @@ advertises one in the client well-known, this will not be used.)
```json
"livekit": {
"livekit_service_url": "http://localhost:8881"
"livekit_service_url": "http://localhost:7881"
},
```
@@ -141,6 +141,28 @@ Run backend components:
yarn backend
```
### Add a new translation key
To add a new translation key you can do these steps:
1. Add the new key entry to the code where the new key is used: `t("some_new_key")`
1. Run `yarn i18n` to extract the new key and update the translation files. This will add a skeleton entry to the `public/locales/en-GB/app.json` file:
```jsonc
{
...
"some_new_key": "",
...
}
```
1. Update the skeleton entry in the `public/locales/en-GB/app.json` file with the English translation:
```jsonc
{
...
"some_new_key": "Some new key",
...
}
```
## Documentation
Usage and other technical details about the project can be found here:

View File

@@ -5,5 +5,11 @@
"server_name": "call.ems.host"
}
},
"livekit": {
"livekit_service_url": "http://localhost:7881"
},
"features": {
"feature_use_device_session_member_events": true
},
"eula": "https://static.element.io/legal/online-EULA.pdf"
}

View File

@@ -8,6 +8,9 @@
"livekit": {
"livekit_service_url": "https://livekit-jwt.call.element.dev"
},
"features": {
"feature_use_device_session_member_events": true
},
"posthog": {
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
"api_host": "https://posthog-element-call.element.io"

View File

@@ -1,19 +0,0 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://call.ems.host",
"server_name": "call.ems.host"
}
},
"posthog": {
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
"api_host": "https://posthog-element-call.element.io"
},
"sentry": {
"environment": "main-branch-cd",
"DSN": "https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41"
},
"rageshake": {
"submit_url": "https://element.io/bugreports/submit"
}
}

View File

@@ -20,15 +20,14 @@
},
"dependencies": {
"@juggle/resize-observer": "^3.3.1",
"@livekit/components-core": "^0.10.0",
"@livekit/components-core": "^0.11.0",
"@livekit/components-react": "^2.0.0",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
"@opentelemetry/api": "^1.4.0",
"@opentelemetry/context-zone": "^1.9.1",
"@opentelemetry/exporter-jaeger": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.48.0",
"@opentelemetry/instrumentation-document-load": "^0.38.0",
"@opentelemetry/instrumentation-user-interaction": "^0.38.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
"@opentelemetry/instrumentation-document-load": "^0.39.0",
"@opentelemetry/instrumentation-user-interaction": "^0.39.0",
"@opentelemetry/sdk-trace-web": "^1.9.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
@@ -41,45 +40,45 @@
"@react-aria/tabs": "^3.1.0",
"@react-aria/tooltip": "^3.1.3",
"@react-aria/utils": "^3.10.0",
"@react-rxjs/core": "^0.10.7",
"@react-spring/web": "^9.4.4",
"@react-stately/collections": "^3.3.4",
"@react-stately/select": "^3.1.3",
"@react-stately/tooltip": "^3.0.5",
"@react-stately/tree": "^3.2.0",
"@sentry/react": "^7.0.0",
"@sentry/react": "^8.0.0",
"@sentry/tracing": "^7.0.0",
"@types/lodash": "^4.14.199",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^1.0.0",
"@vector-im/compound-web": "^3.0.0",
"@vector-im/compound-web": "^6.0.0",
"@vitejs/plugin-basic-ssl": "^1.0.1",
"@vitejs/plugin-react": "^4.0.1",
"buffer": "^6.0.3",
"classnames": "^2.3.1",
"events": "^3.3.0",
"i18next": "^23.0.0",
"i18next-browser-languagedetector": "^7.0.0",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.0.0",
"livekit-client": "^2.0.2",
"lodash": "^4.17.21",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#238eea0ef5c82d0a11b8d5cc5c04104d6c94c4c1",
"matrix-widget-api": "^1.3.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#467908703bc67fa3e23d978f5549e2709d4acf74",
"matrix-widget-api": "^1.8.2",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4",
"postcss-preset-env": "^9.0.0",
"postcss-preset-env": "^10.0.0",
"posthog-js": "^1.29.0",
"react": "18",
"react-dom": "18",
"react-i18next": "^14.0.0",
"react-i18next": "^15.0.0",
"react-router-dom": "^5.2.0",
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sdp-transform": "^2.14.1",
"tinyqueue": "^2.0.3",
"tinyqueue": "^3.0.0",
"unique-names-generator": "^4.6.0",
"uuid": "9",
"uuid": "10",
"vaul": "^0.9.0"
},
"devDependencies": {
@@ -90,17 +89,19 @@
"@react-spring/rafz": "^9.7.3",
"@react-types/dialog": "^3.5.5",
"@sentry/vite-plugin": "^2.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.1",
"@types/content-type": "^1.1.5",
"@types/dom-screen-wake-lock": "^1.0.1",
"@types/dompurify": "^3.0.2",
"@types/grecaptcha": "^3.0.4",
"@types/node": "^20.0.0",
"@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3",
"@types/request": "^2.48.8",
"@types/sdp-transform": "^2.4.5",
"@types/uuid": "9",
"@types/uuid": "10",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"babel-loader": "^9.0.0",
@@ -114,8 +115,8 @@
"eslint-plugin-matrix-org": "^1.2.1",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0",
"eslint-plugin-unicorn": "^51.0.0",
"i18next-parser": "^8.0.0",
"eslint-plugin-unicorn": "^55.0.0",
"i18next-parser": "^9.0.0",
"jsdom": "^24.0.0",
"prettier": "^3.0.0",
"sass": "^1.42.1",
@@ -124,6 +125,6 @@
"vite": "^5.0.0",
"vite-plugin-html-template": "^1.1.0",
"vite-plugin-svgr": "^4.0.0",
"vitest": "^1.2.2"
"vitest": "^2.0.0"
}
}

View File

@@ -41,6 +41,7 @@
"analytics": "Analytics",
"audio": "Audio",
"avatar": "Avatar",
"back": "Back",
"camera": "Camera",
"copied": "Copied!",
"display_name": "Display name",
@@ -49,6 +50,7 @@
"home": "Home",
"loading": "Loading…",
"microphone": "Microphone",
"next": "Next",
"options": "Options",
"password": "Password",
"profile": "Profile",
@@ -57,6 +59,8 @@
"username": "Username",
"video": "Video"
},
"crypto_version": "Crypto version: {{version}}",
"device_id": "Device ID: {{id}}",
"disconnected_banner": "Connectivity to the server has been lost.",
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
@@ -97,6 +101,7 @@
"login_auth_links_prompt": "Not registered yet?",
"login_subheading": "To continue to Element",
"login_title": "Login",
"matrix_id": "Matrix ID: {{id}}",
"microphone_off": "Microphone off",
"microphone_on": "Microphone on",
"mute_microphone_button_label": "Mute microphone",
@@ -130,6 +135,7 @@
"developer_settings_label": "Developer Settings",
"developer_settings_label_description": "Expose developer settings in the settings window.",
"developer_tab_title": "Developer",
"duplicate_tiles_label": "Number of additional tile copies per participant",
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
"feedback_tab_description_label": "Your feedback",
"feedback_tab_h4": "Submit feedback",
@@ -138,7 +144,6 @@
"feedback_tab_title": "Feedback",
"more_tab_title": "More",
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
"show_connection_stats_label": "Show connection stats",
"speaker_device_selection_label": "Speaker"
},
"star_rating_input_label_one": "{{count}} stars",
@@ -152,14 +157,13 @@
"unauthenticated_view_eula_caption": "By clicking \"Go\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
"unauthenticated_view_login_button": "Login to your account",
"unmute_microphone_button_label": "Unmute microphone",
"version": "Version: {{version}}",
"version": "{{productName}} version: {{version}}",
"video_tile": {
"always_show": "Always show",
"change_fit_contain": "Fit to frame",
"exit_full_screen": "Exit full screen",
"full_screen": "Full screen",
"mute_for_me": "Mute for me",
"sfu_participant_local": "You",
"volume": "Volume"
},
"waiting_for_participants": "Waiting for other participants…"
}
}

View File

@@ -3,9 +3,45 @@
"extends": ["config:base"],
"packageRules": [
{
"description": "Disable renoavte for packages we want to monitor ourselves",
"matchPackagePatterns": ["matrix-js-sdk"],
"groupName": "all non-major dependencies",
"groupSlug": "all-minor-patch",
"matchUpdateTypes": ["minor", "patch"],
"extends": ["schedule:weekly"]
},
{
"groupName": "GitHub Actions",
"matchDepTypes": ["action"],
"pinDigests": true,
"extends": ["schedule:monthly"]
},
{
"description": "Disable Renovate for packages we want to monitor ourselves",
"groupName": "manually updated packages",
"matchDepNames": ["matrix-js-sdk"],
"enabled": false
},
{
"groupName": "matrix-widget-api",
"matchDepNames": ["matrix-widget-api"]
},
{
"groupName": "Compound",
"matchPackagePrefixes": ["@vector-im/compound-"],
"schedule": "before 5am on Tuesday and Friday"
},
{
"groupName": "LiveKit client",
"matchDepNames": ["livekit-client"]
},
{
"groupName": "LiveKit components",
"matchPackagePrefixes": ["@livekit/components-"]
},
{
"groupName": "Vaul",
"matchDepNames": ["vaul"],
"extends": ["schedule:monthly"],
"prHeader": "Please review modals on mobile for visual regressions."
}
],
"semanticCommits": "disabled",

View File

@@ -23,17 +23,6 @@ declare global {
webkitFullscreenElement: HTMLElement | null;
}
interface Window {
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
OLM_OPTIONS: Record<string, string>;
}
// TypeScript doesn't know about the experimental setSinkId method, so we
// declare it ourselves
interface MediaElement extends HTMLVideoElement {
setSinkId: (id: string) => void;
}
interface HTMLElement {
// Safari only supports this prefixed, so tell the type system about it
webkitRequestFullscreen: () => void;

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -90,6 +90,7 @@ limitations under the License.
.nameLine {
grid-area: name;
flex-grow: 1;
min-width: 0;
display: flex;
align-items: center;
gap: var(--cpd-space-1x);
@@ -97,8 +98,6 @@ limitations under the License.
.nameLine > h1 {
margin: 0;
/* XXX I can't actually get this ellipsis overflow to trigger, because
constraint propagation in a nested flexbox layout is a massive pain */
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,11 +15,11 @@ limitations under the License.
*/
import classNames from "classnames";
import { FC, HTMLAttributes, ReactNode } from "react";
import { FC, HTMLAttributes, ReactNode, forwardRef } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web";
import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg?react";
import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Header.module.css";
import Logo from "./icons/Logo.svg?react";
@@ -32,13 +32,21 @@ interface HeaderProps extends HTMLAttributes<HTMLElement> {
className?: string;
}
export const Header: FC<HeaderProps> = ({ children, className, ...rest }) => {
return (
<header className={classNames(styles.header, className)} {...rest}>
{children}
</header>
);
};
export const Header = forwardRef<HTMLElement, HeaderProps>(
({ children, className, ...rest }, ref) => {
return (
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
);
},
);
Header.displayName = "Header";
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;

View File

@@ -27,7 +27,7 @@ import {
} from "@radix-ui/react-dialog";
import { Drawer } from "vaul";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import CloseIcon from "@vector-im/compound-design-tokens/icons/close.svg?react";
import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { Heading, Glass } from "@vector-im/compound-web";

View File

@@ -36,3 +36,8 @@ if (/android/i.test(navigator.userAgent)) {
} else {
platform = "desktop";
}
export const isFirefox = (): boolean => {
const { userAgent } = navigator;
return userAgent.includes("Firefox");
};

View File

@@ -20,7 +20,6 @@ import { MatrixClient } from "matrix-js-sdk";
import { Buffer } from "buffer";
import { widget } from "../widget";
import { getSetting, setSetting, getSettingKey } from "../settings/useSetting";
import {
CallEndedTracker,
CallStartedTracker,
@@ -35,7 +34,7 @@ import {
} from "./PosthogEvents";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
import { localStorageBus } from "../useLocalStorage";
import { optInAnalytics } from "../settings/settings";
/* Posthog analytics tracking.
*
@@ -131,7 +130,7 @@ export class PosthogAnalytics {
const { analyticsID } = getUrlParams();
// if the embedding platform (element web) already got approval to communicating with posthog
// element call can also send events to posthog
setSetting("opt-in-analytics", Boolean(analyticsID));
optInAnalytics.setValue(Boolean(analyticsID));
}
this.posthog.init(posthogConfig.project_api_key, {
@@ -151,9 +150,7 @@ export class PosthogAnalytics {
);
this.enabled = false;
}
this.startListeningToSettingsChanges();
const optInAnalytics = getSetting("opt-in-analytics", false);
this.updateAnonymityAndIdentifyUser(optInAnalytics);
this.startListeningToSettingsChanges(); // Triggers maybeIdentifyUser
}
private sanitizeProperties = (
@@ -336,8 +333,7 @@ export class PosthogAnalytics {
}
public onLoginStatusChanged(): void {
const optInAnalytics = getSetting("opt-in-analytics", false);
this.updateAnonymityAndIdentifyUser(optInAnalytics);
this.maybeIdentifyUser();
}
private updateSuperProperties(): void {
@@ -360,20 +356,12 @@ export class PosthogAnalytics {
return this.eventSignup.getSignupEndTime() > new Date(0);
}
private async updateAnonymityAndIdentifyUser(
pseudonymousOptIn: boolean,
): Promise<void> {
// Update this.anonymity based on the user's analytics opt-in settings
const anonymity = pseudonymousOptIn
? Anonymity.Pseudonymous
: Anonymity.Disabled;
this.setAnonymity(anonymity);
private async maybeIdentifyUser(): Promise<void> {
// We may not yet have a Matrix client at this point, if not, bail. This should get
// triggered again by onLoginStatusChanged once we do have a client.
if (!window.matrixclient) return;
if (anonymity === Anonymity.Pseudonymous) {
if (this.anonymity === Anonymity.Pseudonymous) {
this.setRegistrationType(
window.matrixclient.isGuest() || window.passwordlessUser
? RegistrationType.Guest
@@ -389,7 +377,7 @@ export class PosthogAnalytics {
}
}
if (anonymity !== Anonymity.Disabled) {
if (this.anonymity !== Anonymity.Disabled) {
this.updateSuperProperties();
}
}
@@ -419,8 +407,9 @@ export class PosthogAnalytics {
// * When the user changes their preferences on this device
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
localStorageBus.on(getSettingKey("opt-in-analytics"), (optInAnalytics) => {
this.updateAnonymityAndIdentifyUser(optInAnalytics);
optInAnalytics.value.subscribe((optIn) => {
this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
this.maybeIdentifyUser();
});
}

View File

@@ -20,14 +20,16 @@ import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@vector-im/compound-web";
import MicOnSolidIcon from "@vector-im/compound-design-tokens/icons/mic-on-solid.svg?react";
import MicOffSolidIcon from "@vector-im/compound-design-tokens/icons/mic-off-solid.svg?react";
import VideoCallSolidIcon from "@vector-im/compound-design-tokens/icons/video-call-solid.svg?react";
import VideoCallOffSolidIcon from "@vector-im/compound-design-tokens/icons/video-call-off-solid.svg?react";
import EndCallIcon from "@vector-im/compound-design-tokens/icons/end-call.svg?react";
import ShareScreenSolidIcon from "@vector-im/compound-design-tokens/icons/share-screen-solid.svg?react";
import SettingsSolidIcon from "@vector-im/compound-design-tokens/icons/settings-solid.svg?react";
import ChevronDownIcon from "@vector-im/compound-design-tokens/icons/chevron-down.svg?react";
import {
MicOnSolidIcon,
MicOffSolidIcon,
VideoCallSolidIcon,
VideoCallOffSolidIcon,
EndCallIcon,
ShareScreenSolidIcon,
SettingsSolidIcon,
ChevronDownIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Button.module.css";
@@ -149,7 +151,7 @@ export const MicButton: FC<{
return (
<Tooltip label={label}>
<Button variant="toolbar" {...rest} on={muted}>
<Icon aria-label={label} />
<Icon aria-hidden width={24} height={24} />
</Button>
</Tooltip>
);
@@ -169,7 +171,7 @@ export const VideoButton: FC<{
return (
<Tooltip label={label}>
<Button variant="toolbar" {...rest} on={muted}>
<Icon aria-label={label} />
<Icon aria-hidden width={24} height={24} />
</Button>
</Tooltip>
);
@@ -189,7 +191,7 @@ export const ScreenshareButton: FC<{
return (
<Tooltip label={label}>
<Button variant="toolbar" {...rest} on={enabled}>
<ShareScreenSolidIcon aria-label={label} />
<ShareScreenSolidIcon aria-hidden width={24} height={24} />
</Button>
</Tooltip>
);
@@ -209,7 +211,7 @@ export const HangupButton: FC<{
className={classNames(styles.hangupButton, className)}
{...rest}
>
<EndCallIcon aria-label={t("hangup_button_label")} />
<EndCallIcon aria-hidden width={24} height={24} />
</Button>
</Tooltip>
);
@@ -225,7 +227,7 @@ export const SettingsButton: FC<{
return (
<Tooltip label={t("common.settings")}>
<Button variant="toolbar" {...rest}>
<SettingsSolidIcon aria-label={t("common.settings")} />
<SettingsSolidIcon aria-hidden width={24} height={24} />
</Button>
</Tooltip>
);

View File

@@ -17,7 +17,7 @@ limitations under the License.
import { ComponentPropsWithoutRef, FC } from "react";
import { Button } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import UserAddIcon from "@vector-im/compound-design-tokens/icons/user-add.svg?react";
import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
export const InviteButton: FC<
Omit<ComponentPropsWithoutRef<"button">, "children">

View File

@@ -65,11 +65,21 @@ export interface ConfigOptions {
};
/**
* Allow to join a group calls without audio and video.
* TEMPORARY: Is a feature that's not proved and experimental
* TEMPORARY experimental features.
*/
features?: {
feature_group_calls_without_video_and_audio: boolean;
/**
* Allow to join group calls without audio and video.
*/
feature_group_calls_without_video_and_audio?: boolean;
/**
* Send device-specific call session membership state events instead of
* legacy user-specific call membership state events.
* This setting has no effect when the user joins an active call with
* legacy state events. For compatibility, Element Call will always join
* active legacy calls with legacy state events.
*/
feature_use_device_session_member_events?: boolean;
};
/**

150
src/grid/CallLayout.ts Normal file
View File

@@ -0,0 +1,150 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { BehaviorSubject, Observable } from "rxjs";
import { ComponentType } from "react";
import { MediaViewModel, UserMediaViewModel } from "../state/MediaViewModel";
import { LayoutProps } from "./Grid";
export interface Bounds {
width: number;
height: number;
}
export interface Alignment {
inline: "start" | "end";
block: "start" | "end";
}
export const defaultSpotlightAlignment: Alignment = {
inline: "end",
block: "end",
};
export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };
export interface CallLayoutInputs {
/**
* The minimum bounds of the layout area.
*/
minBounds: Observable<Bounds>;
/**
* The alignment of the floating spotlight tile, if present.
*/
spotlightAlignment: BehaviorSubject<Alignment>;
/**
* The alignment of the small picture-in-picture tile, if present.
*/
pipAlignment: BehaviorSubject<Alignment>;
}
export interface GridTileModel {
type: "grid";
vm: UserMediaViewModel;
}
export interface SpotlightTileModel {
type: "spotlight";
vms: MediaViewModel[];
maximised: boolean;
}
export type TileModel = GridTileModel | SpotlightTileModel;
export interface CallLayoutOutputs<Model> {
/**
* Whether the scrolling layer of the layout should appear on top.
*/
scrollingOnTop: boolean;
/**
* The visually fixed (non-scrolling) layer of the layout.
*/
fixed: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
/**
* The layer of the layout that can overflow and be scrolled.
*/
scrolling: ComponentType<LayoutProps<Model, TileModel, HTMLDivElement>>;
}
/**
* A layout system for media tiles.
*/
export type CallLayout<Model> = (
inputs: CallLayoutInputs,
) => CallLayoutOutputs<Model>;
export interface GridArrangement {
tileWidth: number;
tileHeight: number;
gap: number;
columns: number;
}
const tileMaxAspectRatio = 17 / 9;
const tileMinAspectRatio = 4 / 3;
/**
* Determine the ideal arrangement of tiles into a grid of a particular size.
*/
export function arrangeTiles(
width: number,
minHeight: number,
tileCount: number,
): GridArrangement {
// The goal here is to determine the grid size and padding that maximizes
// use of screen space for n tiles without making those tiles too small or
// too cropped (having an extreme aspect ratio)
const gap = width < 800 ? 16 : 20;
const area = width * minHeight;
// Magic numbers that make tiles scale up nicely as the window gets larger
const tileArea = Math.pow(Math.sqrt(area) / 8 + 125, 2);
const tilesPerPage = Math.min(tileCount, area / tileArea);
let columns = Math.min(
// Don't create more columns than we have items for
tilesPerPage,
// The ideal number of columns is given by a packing of equally-sized
// squares into a grid.
// width / column = height / row.
// columns * rows = number of squares.
// ∴ columns = sqrt(width / height * number of squares).
// Except we actually want 16:9-ish tiles rather than squares, so we
// divide the width-to-height ratio by the target aspect ratio.
Math.round(
Math.sqrt((width / minHeight / tileMinAspectRatio) * tilesPerPage),
),
);
let rows = tilesPerPage / columns;
// If all the tiles could fit on one page, we want to ensure that they do by
// not leaving fractional rows hanging off the bottom
if (tilesPerPage === tileCount) {
rows = Math.ceil(rows);
// We may now be able to fit the tiles into fewer columns
columns = Math.ceil(tileCount / rows);
}
let tileWidth = (width - (columns + 1) * gap) / columns;
let tileHeight = (minHeight - (rows - 1) * gap) / rows;
// Impose a minimum and maximum aspect ratio on the tiles
const tileAspectRatio = tileWidth / tileHeight;
if (tileAspectRatio > tileMaxAspectRatio)
tileWidth = tileHeight * tileMaxAspectRatio;
else if (tileAspectRatio < tileMinAspectRatio)
tileHeight = tileWidth / tileMinAspectRatio;
return { tileWidth, tileHeight, gap, columns };
}

View File

@@ -1,11 +1,11 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2023-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
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
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,
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.videoGrid {
position: relative;
overflow: hidden;
flex: 1;
touch-action: none;
.grid {
contain: layout style;
}
.slot {
contain: strict;
}

514
src/grid/Grid.tsx Normal file
View File

@@ -0,0 +1,514 @@
/*
Copyright 2023-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
SpringRef,
TransitionFn,
animated,
useTransition,
} from "@react-spring/web";
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
import {
CSSProperties,
ComponentProps,
ComponentType,
Dispatch,
FC,
LegacyRef,
ReactNode,
SetStateAction,
createContext,
forwardRef,
memo,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useMeasure from "react-use-measure";
import classNames from "classnames";
import styles from "./Grid.module.css";
import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { useInitial } from "../useInitial";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
interface Tile<Model> {
id: string;
model: Model;
onDrag: DragCallback | undefined;
}
type PlacedTile<Model> = Tile<Model> & Rect;
interface TileSpring {
opacity: number;
scale: number;
zIndex: number;
x: number;
y: number;
width: number;
height: number;
}
interface TileSpringUpdate extends Partial<TileSpring> {
from?: Partial<TileSpring>;
reset?: boolean;
immediate?: boolean | ((key: string) => boolean);
delay?: (key: string) => number;
}
interface DragState {
tileId: string;
tileX: number;
tileY: number;
cursorX: number;
cursorY: number;
}
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
id: string;
model: Model;
onDrag?: DragCallback;
style?: CSSProperties;
className?: string;
}
interface Offset {
x: number;
y: number;
}
/**
* Gets the offset of one element relative to an ancestor.
*/
function offset(element: HTMLElement, relativeTo: Element): Offset {
if (
!(element.offsetParent instanceof HTMLElement) ||
element.offsetParent === relativeTo
) {
return { x: element.offsetLeft, y: element.offsetTop };
} else {
const o = offset(element.offsetParent, relativeTo);
o.x += element.offsetLeft;
o.y += element.offsetTop;
return o;
}
}
interface LayoutContext {
setGeneration: Dispatch<SetStateAction<number | null>>;
}
const LayoutContext = createContext<LayoutContext | null>(null);
/**
* Enables Grid to react to layout changes. You must call this in your Layout
* component or else Grid will not be reactive.
*/
export function useUpdateLayout(): void {
const context = useContext(LayoutContext);
if (context === null)
throw new Error("useUpdateLayout called outside a Grid layout context");
// On every render, tell Grid that the layout may have changed
useEffect(() =>
context.setGeneration((prev) => (prev === null ? 0 : prev + 1)),
);
}
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref: LegacyRef<R>;
model: LayoutModel;
/**
* Component creating an invisible "slot" for a tile to go in.
*/
Slot: ComponentType<SlotProps<TileModel>>;
}
export interface TileProps<Model, R extends HTMLElement> {
ref: LegacyRef<R>;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
/**
* The width this tile will have once its animations have settled.
*/
targetWidth: number;
/**
* The height this tile will have once its animations have settled.
*/
targetHeight: number;
model: Model;
}
interface Drag {
/**
* The X coordinate of the dragged tile in grid space.
*/
x: number;
/**
* The Y coordinate of the dragged tile in grid space.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the grid width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the grid height.
*/
yRatio: number;
}
export type DragCallback = (drag: Drag) => void;
interface LayoutMemoProps<LayoutModel, TileModel, R extends HTMLElement>
extends LayoutProps<LayoutModel, TileModel, R> {
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, R>>;
}
interface Props<
LayoutModel,
TileModel,
LayoutRef extends HTMLElement,
TileRef extends HTMLElement,
> {
/**
* Data with which to populate the layout.
*/
model: LayoutModel;
/**
* A component which creates an invisible layout grid of "slots" for tiles to
* go in. The root element must have a data-generation attribute which
* increments whenever the layout may have changed.
*/
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, LayoutRef>>;
/**
* The component used to render each tile in the layout.
*/
Tile: ComponentType<TileProps<TileModel, TileRef>>;
className?: string;
style?: CSSProperties;
}
/**
* A grid of animated tiles.
*/
export function Grid<
LayoutModel,
TileModel,
LayoutRef extends HTMLElement,
TileRef extends HTMLElement,
>({
model,
Layout,
Tile,
className,
style,
}: Props<LayoutModel, TileModel, LayoutRef, TileRef>): ReactNode {
// Overview: This component places tiles by rendering an invisible layout grid
// of "slots" for tiles to go in. Once rendered, it uses the DOM API to get
// the dimensions of each slot, feeding these numbers back into react-spring
// to let the actual tiles move freely atop the layout.
// To tell us when the layout has changed, the layout system increments its
// data-generation attribute, which we watch with a MutationObserver.
const [gridRef1, gridBounds] = useMeasure();
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null);
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
const prefersReducedMotion = usePrefersReducedMotion();
const Slot: FC<SlotProps<TileModel>> = useMemo(
() =>
function Slot({ id, model, onDrag, style, className, ...props }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
tiles.set(id, { id, model, onDrag });
return (): void => void tiles.delete(id);
}, [id, model, onDrag]);
return (
<div
ref={ref}
className={classNames(className, styles.slot)}
data-id={id}
style={style}
{...props}
/>
);
},
[tiles],
);
// We must memoize the Layout component to break the update loop where a
// render of Grid causes a re-render of Layout, which in turn re-renders Grid
const LayoutMemo = useMemo(
() =>
memo(
forwardRef<
LayoutRef,
LayoutMemoProps<LayoutModel, TileModel, LayoutRef>
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode {
return <Layout {...props} ref={ref} />;
}),
),
[],
);
const context: LayoutContext = useMemo(() => ({ setGeneration }), []);
// Combine the tile definitions and slots together to create placed tiles
const placedTiles = useMemo(() => {
const result: PlacedTile<TileModel>[] = [];
if (gridRoot !== null && layoutRoot !== null) {
const slots = layoutRoot.getElementsByClassName(
styles.slot,
) as HTMLCollectionOf<HTMLElement>;
for (const slot of slots) {
const id = slot.getAttribute("data-id")!;
if (slot.offsetWidth > 0 && slot.offsetHeight > 0)
result.push({
...tiles.get(id)!,
...offset(slot, gridRoot),
width: slot.offsetWidth,
height: slot.offsetHeight,
});
}
}
return result;
// The rects may change due to the grid resizing or updating to a new
// generation, but eslint can't statically verify this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridRoot, layoutRoot, tiles, gridBounds, generation]);
// Drag state is stored in a ref rather than component state, because we use
// react-spring's imperative API during gestures to improve responsiveness
const dragState = useRef<DragState | null>(null);
const [tileTransitions, springRef] = useTransition(
placedTiles,
() => ({
key: ({ id }: Tile<TileModel>): string => id,
from: ({
x,
y,
width,
height,
}: PlacedTile<TileModel>): TileSpringUpdate => ({
opacity: 0,
scale: 0,
zIndex: 1,
x,
y,
width,
height,
immediate: prefersReducedMotion,
}),
enter: { opacity: 1, scale: 1, immediate: prefersReducedMotion },
update: ({
id,
x,
y,
width,
height,
}: PlacedTile<TileModel>): TileSpringUpdate | null =>
id === dragState.current?.tileId
? null
: {
x,
y,
width,
height,
immediate: prefersReducedMotion,
},
leave: { opacity: 0, scale: 0, immediate: prefersReducedMotion },
config: { mass: 0.7, tension: 252, friction: 25 },
}),
// react-spring's types are bugged and can't infer the spring type
) as unknown as [
TransitionFn<PlacedTile<TileModel>, TileSpring>,
SpringRef<TileSpring>,
];
// Because we're using react-spring in imperative mode, we're responsible for
// firing animations manually whenever the tiles array updates
useEffect(() => {
springRef.start();
}, [placedTiles, springRef]);
const animateDraggedTile = (
endOfGesture: boolean,
callback: DragCallback,
): void => {
const { tileId, tileX, tileY } = dragState.current!;
const tile = placedTiles.find((t) => t.id === tileId)!;
springRef.current
.find((c) => (c.item as Tile<TileModel>).id === tileId)
?.start(
endOfGesture
? {
scale: 1,
zIndex: 1,
x: tile.x,
y: tile.y,
width: tile.width,
height: tile.height,
immediate:
prefersReducedMotion || ((key): boolean => key === "zIndex"),
// Allow the tile's position to settle before pushing its
// z-index back down
delay: (key): number => (key === "zIndex" ? 500 : 0),
}
: {
scale: 1.1,
zIndex: 2,
x: tileX,
y: tileY,
immediate:
prefersReducedMotion ||
((key): boolean =>
key === "zIndex" || key === "x" || key === "y"),
},
);
if (endOfGesture)
callback({
x: tileX,
y: tileY,
xRatio: tileX / (gridBounds.width - tile.width),
yRatio: tileY / (gridBounds.height - tile.height),
});
};
// Callback for useDrag. We could call useDrag here, but the default
// pattern of spreading {...bind()} across the children to bind the gesture
// ends up breaking memoization and ruining this component's performance.
// Instead, we pass this callback to each tile via a ref, to let them bind the
// gesture using the much more sensible ref-based method.
const onTileDrag = (
tileId: string,
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
tap,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
initial: [initialX, initialY],
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delta: [dx, dy],
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
last,
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
): void => {
if (!tap) {
const tileController = springRef.current.find(
(c) => (c.item as Tile<TileModel>).id === tileId,
)!;
const callback = tiles.get(tileController.item.id)!.onDrag;
if (callback != null) {
if (dragState.current === null) {
const tileSpring = tileController.get();
dragState.current = {
tileId,
tileX: tileSpring.x,
tileY: tileSpring.y,
cursorX: initialX - gridBounds.x,
cursorY: initialY - gridBounds.y + scrollOffset.current,
};
}
dragState.current.tileX += dx;
dragState.current.tileY += dy;
dragState.current.cursorX += dx;
dragState.current.cursorY += dy;
animateDraggedTile(last, callback);
if (last) dragState.current = null;
}
}
};
const onTileDragRef = useRef(onTileDrag);
onTileDragRef.current = onTileDrag;
const scrollOffset = useRef(0);
useScroll(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
({ xy: [, y], delta: [, dy] }) => {
scrollOffset.current = y;
if (dragState.current !== null) {
dragState.current.tileY += dy;
dragState.current.cursorY += dy;
animateDraggedTile(false, tiles.get(dragState.current.tileId)!.onDrag!);
}
},
{ target: gridRoot ?? undefined },
);
return (
<div
ref={gridRef}
className={classNames(className, styles.grid)}
style={style}
>
<LayoutContext.Provider value={context}>
<LayoutMemo
ref={setLayoutRoot}
Layout={Layout}
model={model}
Slot={Slot}
/>
</LayoutContext.Provider>
{tileTransitions((spring, { id, model, onDrag, width, height }) => (
<TileWrapper
key={id}
id={id}
onDrag={onDrag ? onTileDragRef : null}
targetWidth={width}
targetHeight={height}
model={model}
Tile={Tile}
{...spring}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,61 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.fixed,
.scrolling {
block-size: 100%;
}
.scrolling {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-content: center;
gap: var(--gap);
}
.scrolling > .slot {
width: var(--width);
height: var(--height);
}
.fixed {
position: relative;
}
.fixed > .slot {
position: absolute;
inline-size: 404px;
block-size: 233px;
inset-block: 0;
inset-inline: var(--cpd-space-3x);
}
.fixed > .slot[data-block-alignment="start"] {
inset-block-end: unset;
}
.fixed > .slot[data-block-alignment="end"] {
inset-block-start: unset;
}
.fixed > .slot[data-inline-alignment="start"] {
inset-inline-end: unset;
}
.fixed > .slot[data-inline-alignment="end"] {
inset-inline-start: unset;
}

129
src/grid/GridLayout.tsx Normal file
View File

@@ -0,0 +1,129 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { CSSProperties, forwardRef, useCallback, useMemo } from "react";
import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
import styles from "./GridLayout.module.css";
import { useInitial } from "../useInitial";
import {
CallLayout,
GridTileModel,
TileModel,
arrangeTiles,
} from "./CallLayout";
import { DragCallback, useUpdateLayout } from "./Grid";
interface GridCSSProperties extends CSSProperties {
"--gap": string;
"--width": string;
"--height": string;
}
/**
* An implementation of the "grid" layout, in which all participants are shown
* together in a scrolling grid.
*/
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
minBounds,
spotlightAlignment,
}) => ({
scrollingOnTop: false,
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
useUpdateLayout();
const alignment = useObservableEagerState(
useInitial(() =>
spotlightAlignment.pipe(
distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
),
),
),
);
const tileModel: TileModel | undefined = useMemo(
() =>
model.spotlight && {
type: "spotlight",
vms: model.spotlight,
maximised: false,
},
[model.spotlight],
);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) =>
spotlightAlignment.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[],
);
return (
<div ref={ref} className={styles.fixed}>
{tileModel && (
<Slot
className={styles.slot}
id="spotlight"
model={tileModel}
onDrag={onDragSpotlight}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
)}
</div>
);
}),
// The scrolling part of the layout is where all the grid tiles live
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
useUpdateLayout();
const { width, height: minHeight } = useObservableEagerState(minBounds);
const { gap, tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, minHeight, model.grid.length),
[width, minHeight, model.grid.length],
);
const tileModels: GridTileModel[] = useMemo(
() => model.grid.map((vm) => ({ type: "grid", vm })),
[model.grid],
);
return (
<div
ref={ref}
className={styles.scrolling}
style={
{
width,
"--gap": `${gap}px`,
"--width": `${Math.floor(tileWidth)}px`,
"--height": `${Math.floor(tileHeight)}px`,
} as GridCSSProperties
}
>
{tileModels.map((m) => (
<Slot key={m.vm.id} className={styles.slot} id={m.vm.id} model={m} />
))}
</div>
);
}),
});

View File

@@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.layer {
block-size: 100%;
display: grid;
place-items: center;
}
.container {
position: relative;
}
.local {
position: absolute;
inline-size: 180px;
block-size: 135px;
inset: var(--cpd-space-4x);
}
.spotlight {
position: absolute;
inline-size: 404px;
block-size: 233px;
}
.slot[data-block-alignment="start"] {
inset-block-end: unset;
}
.slot[data-block-alignment="end"] {
inset-block-start: unset;
}
.slot[data-inline-alignment="start"] {
inset-inline-end: unset;
}
.slot[data-inline-alignment="end"] {
inset-inline-start: unset;
}

View File

@@ -0,0 +1,88 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { forwardRef, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
import { CallLayout, GridTileModel, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneLayout.module.css";
import { DragCallback, useUpdateLayout } from "./Grid";
/**
* An implementation of the "one-on-one" layout, in which the remote participant
* is shown at maximum size, overlaid by a small view of the local participant.
*/
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
minBounds,
pipAlignment,
}) => ({
scrollingOnTop: false,
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
useUpdateLayout();
return <div ref={ref} />;
}),
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds);
const pipAlignmentValue = useObservableEagerState(pipAlignment);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
);
const remoteTileModel: GridTileModel = useMemo(
() => ({ type: "grid", vm: model.remote }),
[model.remote],
);
const localTileModel: GridTileModel = useMemo(
() => ({ type: "grid", vm: model.local }),
[model.local],
);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
pipAlignment.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[],
);
return (
<div ref={ref} className={styles.layer}>
<Slot
id={remoteTileModel.vm.id}
model={remoteTileModel}
className={styles.container}
style={{ width: tileWidth, height: tileHeight }}
>
<Slot
className={classNames(styles.slot, styles.local)}
id={localTileModel.vm.id}
model={localTileModel}
onDrag={onDragLocalTile}
data-block-alignment={pipAlignmentValue.block}
data-inline-alignment={pipAlignmentValue.inline}
/>
</Slot>
</div>
);
}),
});

View File

@@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.layer {
block-size: 100%;
}
.spotlight {
block-size: 100%;
inline-size: 100%;
}
.pip {
position: absolute;
inline-size: 135px;
block-size: 160px;
inset: var(--cpd-space-4x);
}
@media (min-width: 600px) {
.pip {
inline-size: 180px;
block-size: 135px;
}
}
.pip[data-block-alignment="start"] {
inset-block-end: unset;
}
.pip[data-block-alignment="end"] {
inset-block-start: unset;
}
.pip[data-inline-alignment="start"] {
inset-inline-end: unset;
}
.pip[data-inline-alignment="end"] {
inset-inline-start: unset;
}

View File

@@ -0,0 +1,91 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { forwardRef, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import { SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
import { CallLayout, GridTileModel, SpotlightTileModel } from "./CallLayout";
import { DragCallback, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css";
/**
* An implementation of the "expanded spotlight" layout, in which the spotlight
* tile stretches edge-to-edge and is overlaid by a picture-in-picture tile.
*/
export const makeSpotlightExpandedLayout: CallLayout<
SpotlightExpandedLayoutModel
> = ({ pipAlignment }) => ({
scrollingOnTop: true,
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
{ model, Slot },
ref,
) {
useUpdateLayout();
const spotlightTileModel: SpotlightTileModel = useMemo(
() => ({ type: "spotlight", vms: model.spotlight, maximised: true }),
[model.spotlight],
);
return (
<div ref={ref} className={styles.layer}>
<Slot
className={styles.spotlight}
id="spotlight"
model={spotlightTileModel}
/>
</div>
);
}),
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
{ model, Slot },
ref,
) {
useUpdateLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment);
const pipTileModel: GridTileModel | undefined = useMemo(
() => model.pip && { type: "grid", vm: model.pip },
[model.pip],
);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
pipAlignment.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[],
);
return (
<div ref={ref} className={styles.layer}>
{pipTileModel && (
<Slot
className={styles.pip}
id="pip"
model={pipTileModel}
onDrag={onDragPip}
data-block-alignment={pipAlignmentValue.block}
data-inline-alignment={pipAlignmentValue.inline}
/>
)}
</div>
);
}),
});

View File

@@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.layer {
block-size: 100%;
display: grid;
--gap: 20px;
gap: var(--gap);
--grid-slot-width: 180px;
grid-template-columns: 1fr var(--grid-slot-width);
grid-template-rows: minmax(1fr, auto);
padding-inline: var(--gap);
}
.spotlight {
container: spotlight / size;
display: grid;
place-items: center;
}
/* CSS makes us put a condition here, even though all we want to do is
unconditionally select the container so we can use cq units */
@container spotlight (width > 0) {
.spotlight > .slot {
inline-size: min(100cqi, 100cqb * (17 / 9));
block-size: min(100cqb, 100cqi / (4 / 3));
}
}
.grid {
display: flex;
flex-wrap: wrap;
gap: var(--gap);
justify-content: center;
align-content: center;
}
.grid > .slot {
inline-size: 180px;
block-size: 135px;
}

View File

@@ -0,0 +1,92 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { forwardRef, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { CallLayout, GridTileModel, TileModel } from "./CallLayout";
import { SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightLandscapeLayout.module.css";
import { useUpdateLayout } from "./Grid";
/**
* An implementation of the "spotlight landscape" layout, in which the spotlight
* tile takes up most of the space on the left, and the grid of participants is
* shown as a scrolling rail on the right.
*/
export const makeSpotlightLandscapeLayout: CallLayout<
SpotlightLandscapeLayoutModel
> = ({ minBounds }) => ({
scrollingOnTop: false,
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
{ model, Slot },
ref,
) {
useUpdateLayout();
useObservableEagerState(minBounds);
const tileModel: TileModel = useMemo(
() => ({
type: "spotlight",
vms: model.spotlight,
maximised: false,
}),
[model.spotlight],
);
return (
<div ref={ref} className={styles.layer}>
<div className={styles.spotlight}>
<Slot className={styles.slot} id="spotlight" model={tileModel} />
</div>
<div className={styles.grid} />
</div>
);
}),
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling(
{ model, Slot },
ref,
) {
useUpdateLayout();
useObservableEagerState(minBounds);
const tileModels: GridTileModel[] = useMemo(
() => model.grid.map((vm) => ({ type: "grid", vm })),
[model.grid],
);
return (
<div ref={ref} className={styles.layer}>
<div
className={classNames(styles.spotlight, {
[styles.withIndicators]: model.spotlight.length > 1,
})}
/>
<div className={styles.grid}>
{tileModels.map((m) => (
<Slot
key={m.vm.id}
className={styles.slot}
id={m.vm.id}
model={m}
/>
))}
</div>
</div>
);
}),
});

View File

@@ -0,0 +1,56 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.layer {
block-size: 100%;
display: grid;
--gap: 20px;
gap: var(--gap);
margin-inline: 0;
display: block;
}
.spotlight {
container: spotlight / size;
display: grid;
place-items: center;
inline-size: 100%;
aspect-ratio: 16 / 9;
margin-block-end: var(--cpd-space-4x);
}
.spotlight.withIndicators {
margin-block-end: calc(2 * var(--cpd-space-4x) + 2px);
}
.spotlight > .slot {
inline-size: 100%;
block-size: 100%;
}
.grid {
display: flex;
flex-wrap: wrap;
gap: var(--grid-gap);
justify-content: center;
align-content: start;
padding-inline: var(--grid-gap);
}
.grid > .slot {
inline-size: var(--grid-tile-width);
block-size: var(--grid-tile-height);
}

View File

@@ -0,0 +1,118 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { CSSProperties, forwardRef, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import {
CallLayout,
GridTileModel,
TileModel,
arrangeTiles,
} from "./CallLayout";
import { SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightPortraitLayout.module.css";
import { useUpdateLayout } from "./Grid";
interface GridCSSProperties extends CSSProperties {
"--grid-gap": string;
"--grid-tile-width": string;
"--grid-tile-height": string;
}
/**
* An implementation of the "spotlight portrait" layout, in which the spotlight
* tile is shown across the top of the screen, and the grid of participants
* scrolls behind it.
*/
export const makeSpotlightPortraitLayout: CallLayout<
SpotlightPortraitLayoutModel
> = ({ minBounds }) => ({
scrollingOnTop: false,
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
{ model, Slot },
ref,
) {
useUpdateLayout();
const tileModel: TileModel = useMemo(
() => ({
type: "spotlight",
vms: model.spotlight,
maximised: true,
}),
[model.spotlight],
);
return (
<div ref={ref} className={styles.layer}>
<div className={styles.spotlight}>
<Slot className={styles.slot} id="spotlight" model={tileModel} />
</div>
</div>
);
}),
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
{ model, Slot },
ref,
) {
useUpdateLayout();
const { width } = useObservableEagerState(minBounds);
const { gap, tileWidth, tileHeight } = arrangeTiles(
width,
// TODO: We pretend that the minimum height is the width, because the
// actual minimum height is difficult to calculate
width,
model.grid.length,
);
const tileModels: GridTileModel[] = useMemo(
() => model.grid.map((vm) => ({ type: "grid", vm })),
[model.grid],
);
return (
<div
ref={ref}
className={styles.layer}
style={
{
"--grid-gap": `${gap}px`,
"--grid-tile-width": `${Math.floor(tileWidth)}px`,
"--grid-tile-height": `${Math.floor(tileHeight)}px`,
} as GridCSSProperties
}
>
<div
className={classNames(styles.spotlight, {
[styles.withIndicators]: model.spotlight.length > 1,
})}
/>
<div className={styles.grid}>
{tileModels.map((m) => (
<Slot
key={m.vm.id}
className={styles.slot}
id={m.vm.id}
model={m}
/>
))}
</div>
</div>
);
}),
});

View File

@@ -1,5 +1,5 @@
/*
Copyright 2023 New Vector Ltd
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,15 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.bigGrid {
display: grid;
grid-auto-rows: 130px;
gap: var(--cpd-space-2x);
.tile.draggable {
cursor: grab;
}
@media (min-width: 800px) {
.bigGrid {
grid-auto-rows: 135px;
gap: var(--cpd-space-5x);
}
.tile.draggable:active {
cursor: grabbing;
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2023 New Vector Ltd
Copyright 2023-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,83 +14,76 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { memo, ReactNode, RefObject, useRef } from "react";
import { ComponentType, memo, RefObject, useRef } from "react";
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
import { SpringValue, to } from "@react-spring/web";
import { SpringValue } from "@react-spring/web";
import classNames from "classnames";
import { ChildrenProperties } from "./VideoGrid";
import { TileProps } from "./Grid";
import styles from "./TileWrapper.module.css";
interface Props<T> {
interface Props<M, R extends HTMLElement> {
id: string;
onDragRef: RefObject<
onDrag: RefObject<
(
tileId: string,
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
) => void
>;
> | null;
targetWidth: number;
targetHeight: number;
data: T;
model: M;
Tile: ComponentType<TileProps<M, R>>;
opacity: SpringValue<number>;
scale: SpringValue<number>;
shadow: SpringValue<number>;
shadowSpread: SpringValue<number>;
zIndex: SpringValue<number>;
x: SpringValue<number>;
y: SpringValue<number>;
width: SpringValue<number>;
height: SpringValue<number>;
children: (props: ChildrenProperties<T>) => ReactNode;
}
const TileWrapper_ = memo(
<T,>({
<M, R extends HTMLElement>({
id,
onDragRef,
onDrag,
targetWidth,
targetHeight,
data,
model,
Tile,
opacity,
scale,
shadow,
shadowSpread,
zIndex,
x,
y,
width,
height,
children,
}: Props<T>) => {
const ref = useRef<HTMLElement | null>(null);
}: Props<M, R>) => {
const ref = useRef<R | null>(null);
useDrag((state) => onDragRef?.current!(id, state), {
useDrag((state) => onDrag?.current!(id, state), {
target: ref,
filterTaps: true,
preventScroll: true,
});
return (
<>
{children({
ref,
style: {
opacity,
scale,
zIndex,
x,
y,
width,
height,
boxShadow: to(
[shadow, shadowSpread],
(s, ss) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px ${ss}px`,
),
},
targetWidth,
targetHeight,
data,
})}
</>
<Tile
ref={ref}
className={classNames(styles.tile, { [styles.draggable]: onDrag })}
style={{
opacity,
scale,
zIndex,
x,
y,
width,
height,
}}
targetWidth={targetWidth}
targetHeight={targetHeight}
model={model}
/>
);
},
);
@@ -104,4 +97,6 @@ TileWrapper_.displayName = "TileWrapper";
// We pretend this component is a simple function rather than a
// NamedExoticComponent, because that's the only way we can fit in a type
// parameter
export const TileWrapper = TileWrapper_ as <T>(props: Props<T>) => JSX.Element;
export const TileWrapper = TileWrapper_ as <M, R extends HTMLElement>(
props: Props<M, R>,
) => JSX.Element;

View File

@@ -38,9 +38,12 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { Caption } from "../typography/Typography";
import { Form } from "../form/Form";
import { useOptInAnalytics } from "../settings/useSetting";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { E2eeType } from "../e2ee/e2eeType";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
} from "../settings/settings";
interface Props {
client: MatrixClient;
@@ -49,7 +52,7 @@ interface Props {
export const RegisteredView: FC<Props> = ({ client }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics();
const [optInAnalytics] = useSetting(optInAnalyticsSetting);
const history = useHistory();
const { t } = useTranslation();
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =

View File

@@ -41,15 +41,18 @@ import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { useOptInAnalytics } from "../settings/useSetting";
import { Config } from "../config/Config";
import { E2eeType } from "../e2ee/e2eeType";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
} from "../settings/settings";
export const UnauthenticatedView: FC = () => {
const { setClient } = useClient();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics();
const [optInAnalytics] = useSetting(optInAnalyticsSetting);
const { recaptchaKey, register } = useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);

View File

@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { BrowserTracing } from "@sentry/browser";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
@@ -33,8 +32,6 @@ enum LoadState {
}
class DependencyLoadStates {
// TODO: decide where olm should be initialized (see TODO comment below)
// olm: LoadState = LoadState.None;
public config: LoadState = LoadState.None;
public sentry: LoadState = LoadState.None;
public openTelemetry: LoadState = LoadState.None;
@@ -55,7 +52,7 @@ export class Initializer {
public static initBeforeReact(): void {
// this maybe also needs to return a promise in the future,
// if we have to do async inits before showing the loading screen
// but this should be avioded if possible
// but this should be avoided if possible
//i18n
const languageDetector = new LanguageDetector();
@@ -119,7 +116,7 @@ export class Initializer {
}
Initializer.internalInstance = new Initializer();
Initializer.internalInstance.initPromise = new Promise<void>((resolve) => {
// initStep calls itself recursivly until everything is initialized in the correct order.
// initStep calls itself recursively until everything is initialized in the correct order.
// Then the promise gets resolved.
Initializer.internalInstance.initStep(resolve);
});
@@ -129,18 +126,6 @@ export class Initializer {
private loadStates = new DependencyLoadStates();
private initStep(resolve: (value: void | PromiseLike<void>) => void): void {
// TODO: Olm is initialized with the client currently (see `initClient()` and `olm.ts`)
// we need to decide if we want to init it here or keep it in initClient
// if (this.loadStates.olm === LoadState.None) {
// this.loadStates.olm = LoadState.Loading;
// // TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
// window.OLM_OPTIONS = {};
// Olm.init({ locateFile: () => olmWasmPath }).then(() => {
// this.loadStates.olm = LoadState.Loaded;
// this.initStep(resolve);
// });
// }
// config
if (this.loadStates.config === LoadState.None) {
this.loadStates.config = LoadState.Loading;
@@ -160,10 +145,7 @@ export class Initializer {
dsn: Config.get().sentry?.DSN,
environment: Config.get().sentry?.environment,
integrations: [
new BrowserTracing({
routingInstrumentation:
Sentry.reactRouterV5Instrumentation(history),
}),
Sentry.reactRouterV5BrowserTracingIntegration({ history }),
],
tracesSampleRate: 1.0,
});

View File

@@ -29,11 +29,12 @@ import { Observable } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger";
import {
isFirefox,
useAudioInput,
useAudioOutput,
useVideoInput,
} from "../settings/useSetting";
useSetting,
audioInput as audioInputSetting,
audioOutput as audioOutputSetting,
videoInput as videoInputSetting,
} from "../settings/settings";
import { isFirefox } from "../Platform";
export interface MediaDevice {
available: MediaDeviceInfo[];
@@ -145,43 +146,36 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
// for ouput devices because the selector wont be shown on FF.
const useOutputNames = usingNames && !isFirefox();
const [audioInputSetting, setAudioInputSetting] = useAudioInput();
const [audioOutputSetting, setAudioOutputSetting] = useAudioOutput();
const [videoInputSetting, setVideoInputSetting] = useVideoInput();
const [storedAudioInput, setStoredAudioInput] = useSetting(audioInputSetting);
const [storedAudioOutput, setStoredAudioOutput] =
useSetting(audioOutputSetting);
const [storedVideoInput, setStoredVideoInput] = useSetting(videoInputSetting);
const audioInput = useMediaDevice(
"audioinput",
audioInputSetting,
usingNames,
);
const audioInput = useMediaDevice("audioinput", storedAudioInput, usingNames);
const audioOutput = useMediaDevice(
"audiooutput",
audioOutputSetting,
storedAudioOutput,
useOutputNames,
alwaysUseDefaultAudio,
);
const videoInput = useMediaDevice(
"videoinput",
videoInputSetting,
usingNames,
);
const videoInput = useMediaDevice("videoinput", storedVideoInput, usingNames);
useEffect(() => {
if (audioInput.selectedId !== undefined)
setAudioInputSetting(audioInput.selectedId);
}, [setAudioInputSetting, audioInput.selectedId]);
setStoredAudioInput(audioInput.selectedId);
}, [setStoredAudioInput, audioInput.selectedId]);
useEffect(() => {
// Skip setting state for ff output. Redundent since it is set to always return 'undefined'
// but makes it clear while debugging that this is not happening on FF. + perf ;)
if (audioOutput.selectedId !== undefined && !isFirefox())
setAudioOutputSetting(audioOutput.selectedId);
}, [setAudioOutputSetting, audioOutput.selectedId]);
setStoredAudioOutput(audioOutput.selectedId);
}, [setStoredAudioOutput, audioOutput.selectedId]);
useEffect(() => {
if (videoInput.selectedId !== undefined)
setVideoInputSetting(videoInput.selectedId);
}, [setVideoInputSetting, videoInput.selectedId]);
setStoredVideoInput(videoInput.selectedId);
}, [setStoredVideoInput, videoInput.selectedId]);
const startUsingDeviceNames = useCallback(
() => setNumCallersUsingNames((n) => n + 1),

View File

@@ -34,7 +34,6 @@ import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room";
import IndexedDBWorker from "./IndexedDBWorker?worker";
import { generateUrlSearchParams, getUrlParams } from "./UrlParams";
import { loadOlm } from "./olm";
import { Config } from "./config/Config";
import { E2eeType } from "./e2ee/e2eeType";
import { EncryptionSystem, saveKeyForRoom } from "./e2ee/sharedKeyManagement";
@@ -97,8 +96,6 @@ export async function initClient(
clientOptions: ICreateClientOpts,
restore: boolean,
): Promise<MatrixClient> {
await loadOlm();
let indexedDB: IDBFactory | undefined;
try {
indexedDB = window.indexedDB;
@@ -198,7 +195,7 @@ export async function initClient(
await client.store.startup();
}
await client.initCrypto();
await client.initRustCrypto();
client.setGlobalErrorOnUnknownDevices(false);
// Once startClient is called, syncs are run asynchronously.
// Also, sync completion is communicated only via events.

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Observable, defer, finalize, tap } from "rxjs";
import { Observable, defer, finalize, scan, startWith, tap } from "rxjs";
const nothing = Symbol("nothing");
@@ -35,3 +35,15 @@ export function finalizeValue<T>(callback: (finalValue: T) => void) {
);
});
}
/**
* RxJS operator that accumulates a state from a source of events. This is like
* scan, except it emits an initial value immediately before any events arrive.
*/
export function accumulate<State, Event>(
initial: State,
update: (state: State, event: Event) => State,
) {
return (events: Observable<Event>): Observable<State> =>
events.pipe(scan(update, initial), startWith(initial));
}

View File

@@ -40,7 +40,6 @@ import {
SummaryStatsReport,
CallFeedReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { setSpan } from "@opentelemetry/api/build/esm/trace/context-utils";
import { ElementCallOpenTelemetry } from "./otel";
import { ObjectFlattener } from "./ObjectFlattener";
@@ -446,7 +445,7 @@ export class OTelGroupCallMembership {
const type = OTelStatsReportType.SummaryReport;
const data = ObjectFlattener.flattenSummaryStatsReportObject(statsReport);
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
const ctx = setSpan(
const ctx = opentelemetry.trace.setSpan(
opentelemetry.context.active(),
this.callMembershipSpan,
);

View File

@@ -118,7 +118,7 @@ export function useProfile(client: MatrixClient | undefined): UseProfile {
displayName,
avatarUrl: removeAvatar
? undefined
: mxcAvatarUrl ?? prev.avatarUrl,
: (mxcAvatarUrl ?? prev.avatarUrl),
loading: false,
success: true,
}));

View File

@@ -17,7 +17,7 @@ limitations under the License.
import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Text } from "@vector-im/compound-web";
import PopOutIcon from "@vector-im/compound-design-tokens/icons/pop-out.svg?react";
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { logger } from "matrix-js-sdk/src/logger";
import { Modal } from "../Modal";

View File

@@ -51,11 +51,11 @@ export const CallEndedView: FC<Props> = ({
const history = useHistory();
const { displayName } = useProfile(client);
const [surveySubmitted, setSurverySubmitted] = useState(false);
const [surveySubmitted, setSurveySubmitted] = useState(false);
const [starRating, setStarRating] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [submitDone, setSubmitDone] = useState(false);
const submitSurvery: FormEventHandler<HTMLFormElement> = useCallback(
const submitSurvey: FormEventHandler<HTMLFormElement> = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
@@ -75,7 +75,7 @@ export const CallEndedView: FC<Props> = ({
setTimeout(() => {
if (isPasswordlessUser) {
// setting this renders the callEndedView with the invitation to create an account
setSurverySubmitted(true);
setSurveySubmitted(true);
} else if (!confineToRoom) {
// if the user already has an account immediately go back to the home screen
history.push("/");
@@ -113,7 +113,7 @@ export const CallEndedView: FC<Props> = ({
We'd love to hear your feedback so we can improve your experience.
</p>
</Trans>
<form onSubmit={submitSurvery}>
<form onSubmit={submitSurvey}>
<FieldRow>
<StarRatingInput starCount={5} onChange={setStarRating} required />
</FieldRow>

View File

@@ -1,5 +1,5 @@
/*
Copyright 2023 New Vector Ltd
Copyright 2023-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -17,8 +17,10 @@ limitations under the License.
import { FC } from "react";
import { Tooltip } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import LockSolidIcon from "@vector-im/compound-design-tokens/icons/lock-solid.svg?react";
import LockOffIcon from "@vector-im/compound-design-tokens/icons/lock-off.svg?react";
import {
LockSolidIcon,
LockOffIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./EncryptionLock.module.css";
@@ -32,13 +34,13 @@ export const EncryptionLock: FC<Props> = ({ encrypted }) => {
const label = encrypted ? t("common.encrypted") : t("common.unencrypted");
return (
<Tooltip label={label} side="right" isTriggerInteractive={false}>
<Tooltip label={label} placement="right" isTriggerInteractive={false}>
<Icon
width={16}
height={16}
className={styles.lock}
data-encrypted={encrypted}
aria-label={label}
aria-hidden
/>
</Tooltip>
);

View File

@@ -80,17 +80,12 @@ export const GroupCallView: FC<Props> = ({
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const isJoined = useMatrixRTCSessionJoinState(rtcSession);
// The mute state reactively gets updated once the participant count reaches the threshold.
// The user then still is able to unmute again.
// The more common case is that the user is muted from the start (participant count is already over the threshold).
const autoMuteHappened = useRef(false);
// This should use `useEffectEvent` (only available in experimental versions)
useEffect(() => {
if (autoMuteHappened.current) return;
if (memberships.length >= MUTE_PARTICIPANT_COUNT) {
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
muteStates.audio.setEnabled?.(false);
autoMuteHappened.current = true;
}
}, [autoMuteHappened, memberships, muteStates.audio]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
window.rtcSession = rtcSession;
@@ -195,7 +190,7 @@ export const GroupCallView: FC<Props> = ({
const onJoin = async (
ev: CustomEvent<IWidgetApiRequest>,
): Promise<void> => {
defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
await enterRTCSession(rtcSession, perParticipantE2EE);
await widget!.api.transport.reply(ev.detail, {});
};
@@ -204,9 +199,12 @@ export const GroupCallView: FC<Props> = ({
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
} else if (widget && !preload && skipLobby) {
// No lobby and no preload: we enter the rtc session right away
defaultDeviceSetup({ audioInput: null, videoInput: null });
enterRTCSession(rtcSession, perParticipantE2EE);
const join = async (): Promise<void> => {
await defaultDeviceSetup({ audioInput: null, videoInput: null });
await enterRTCSession(rtcSession, perParticipantE2EE);
};
// No lobby and no preload: we enter the RTC Session right away.
join();
}
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021 New Vector Ltd
Copyright 2021-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -19,36 +19,31 @@ limitations under the License.
flex-direction: column;
height: 100%;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
}
.controlsOverlay {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
overflow-inline: hidden;
/* There used to be a contain: strict here, but due to some bugs in Firefox,
this was causing the Z-ordering of modals to glitch out. It can be added back
if those issues appear to be resolved. */
.header {
position: sticky;
flex-shrink: 0;
inset-block-start: 0;
z-index: 1;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
}
.centerMessage {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
flex-direction: column;
}
.centerMessage p {
display: block;
margin-bottom: 0;
.header.filler {
block-size: var(--cpd-space-6x);
background: none;
}
.footer {
position: sticky;
inset-block-end: 0;
z-index: 1;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas: "logo buttons layout";
@@ -63,6 +58,24 @@ limitations under the License.
);
}
.footer.overlay {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
opacity: 1;
transition: opacity 0.15s;
}
.footer.overlay.hidden {
opacity: 0;
pointer-events: none;
}
.footer.overlay:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
.logo {
grid-area: logo;
justify-self: start;
@@ -101,11 +114,37 @@ limitations under the License.
}
}
.footerThin {
padding-top: var(--cpd-space-3x);
padding-bottom: var(--cpd-space-5x);
.fixedGrid {
position: absolute;
inline-size: 100%;
align-self: center;
}
.footerHidden {
display: none;
.scrollingGrid {
position: relative;
flex-grow: 1;
inline-size: 100%;
align-self: center;
}
.fixedGrid,
.scrollingGrid {
/* Disable pointer events so the overlay doesn't block interaction with
elements behind it */
pointer-events: none;
}
.fixedGrid > :not(:first-child),
.scrollingGrid > :not(:first-child) {
pointer-events: initial;
}
.tile {
position: absolute;
inset-block-start: 0;
}
.tile.maximised {
position: relative;
flex-grow: 1;
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 - 2023 New Vector Ltd
Copyright 2022 - 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,31 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ResizeObserver } from "@juggle/resize-observer";
import {
RoomAudioRenderer,
RoomContext,
useLocalParticipant,
useTracks,
} from "@livekit/components-react";
import { usePreventScroll } from "@react-aria/overlays";
import { ConnectionState, Room, Track } from "livekit-client";
import { ConnectionState, Room } from "livekit-client";
import { MatrixClient } from "matrix-js-sdk/src/client";
import {
FC,
ReactNode,
Ref,
PointerEvent,
PropsWithoutRef,
TouchEvent,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import useMeasure from "react-use-measure";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames";
import { useStateObservable } from "@react-rxjs/core";
import { BehaviorSubject, of } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -51,21 +51,16 @@ import {
SettingsButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { useVideoGridLayout, VideoGrid } from "../video-grid/VideoGrid";
import { useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { ElementWidgetActions, widget } from "../widget";
import styles from "./InCallView.module.css";
import { VideoTile } from "../video-grid/VideoTile";
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
import { GridTile } from "../tile/GridTile";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { useLiveKit } from "../livekit/useLiveKit";
import { useFullscreen } from "./useFullscreen";
import { useLayoutStates } from "../video-grid/Layout";
import { useWakeLock } from "../useWakeLock";
import { useMergedRefs } from "../useMergedRefs";
import { MuteStates } from "./MuteStates";
@@ -74,13 +69,28 @@ import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle";
import { ECConnectionState } from "../livekit/useECConnectionState";
import { useOpenIDSFU } from "../livekit/openIDSFU";
import { useCallViewModel } from "../state/CallViewModel";
import { subscribe } from "../state/subscribe";
import { GridMode, Layout, useCallViewModel } from "../state/CallViewModel";
import { Grid, TileProps } from "../grid/Grid";
import { useObservable } from "../state/useObservable";
import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout";
import {
CallLayoutOutputs,
TileModel,
defaultPipAlignment,
defaultSpotlightAlignment,
} from "../grid/CallLayout";
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const maxTapDurationMs = 400;
export interface ActiveCallProps
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
@@ -126,309 +136,427 @@ export interface InCallViewProps {
onShareClick: (() => void) | null;
}
export const InCallView: FC<InCallViewProps> = subscribe(
({
client,
matrixInfo,
rtcSession,
export const InCallView: FC<InCallViewProps> = ({
client,
matrixInfo,
rtcSession,
livekitRoom,
muteStates,
participantCount,
onLeave,
hideHeader,
connState,
onShareClick,
}) => {
usePreventScroll();
useWakeLock();
useEffect(() => {
if (connState === ConnectionState.Disconnected) {
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
onLeave(new Error("Disconnected from call server"));
}
}, [connState, onLeave]);
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure();
const boundsValid = bounds.height > 0;
// Merge the refs so they can attach to the same element
const containerRef = useMergedRefs(containerRef1, containerRef2);
const { hideScreensharing, showControls } = useUrlParams();
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
room: livekitRoom,
});
const toggleMicrophone = useCallback(
() => muteStates.audio.setEnabled?.((e) => !e),
[muteStates],
);
const toggleCamera = useCallback(
() => muteStates.video.setEnabled?.((e) => !e),
[muteStates],
);
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
useCallViewKeyboardShortcuts(
containerRef1,
toggleMicrophone,
toggleCamera,
(muted) => muteStates.audio.setEnabled?.(!muted),
);
const mobile = boundsValid && bounds.width <= 660;
const reducedControls = boundsValid && bounds.width <= 340;
const noControls = reducedControls && bounds.height <= 400;
const vm = useCallViewModel(
rtcSession.room,
livekitRoom,
muteStates,
participantCount,
onLeave,
hideHeader,
otelGroupCallMembership,
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
connState,
onShareClick,
}) => {
const { t } = useTranslation();
usePreventScroll();
useWakeLock();
);
const windowMode = useObservableEagerState(vm.windowMode);
const layout = useObservableEagerState(vm.layout);
const gridMode = useObservableEagerState(vm.gridMode);
const showHeader = useObservableEagerState(vm.showHeader);
const showFooter = useObservableEagerState(vm.showFooter);
useEffect(() => {
if (connState === ConnectionState.Disconnected) {
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
onLeave(new Error("Disconnected from call server"));
}
}, [connState, onLeave]);
// Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported
// in Safari: https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#browser_compatibility
// Instead we have to watch for sufficiently fast touch events.
const touchStart = useRef<number | null>(null);
const onTouchStart = useCallback(() => (touchStart.current = Date.now()), []);
const onTouchEnd = useCallback(() => {
const start = touchStart.current;
if (start !== null && Date.now() - start <= maxTapDurationMs)
vm.tapScreen();
touchStart.current = null;
}, [vm]);
const onTouchCancel = useCallback(() => (touchStart.current = null), []);
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
const boundsValid = bounds.height > 0;
// Merge the refs so they can attach to the same element
const containerRef = useMergedRefs(containerRef1, containerRef2);
// We also need to tell the layout toggle to prevent touch events from
// bubbling up, or else the controls will be dismissed before a change event
// can be registered on the toggle
const onLayoutToggleTouchEnd = useCallback(
(e: TouchEvent) => e.stopPropagation(),
[],
);
const screenSharingTracks = useTracks(
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
{
room: livekitRoom,
},
);
const { layout, setLayout } = useVideoGridLayout(
screenSharingTracks.length > 0,
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (e.pointerType === "mouse") vm.hoverScreen();
},
[vm],
);
const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen],
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[setSettingsModalOpen],
);
const openProfile = useCallback(() => {
setSettingsTab("profile");
setSettingsModalOpen(true);
}, [setSettingsTab, setSettingsModalOpen]);
const [headerRef, headerBounds] = useMeasure();
const [footerRef, footerBounds] = useMeasure();
const gridBounds = useMemo(
() => ({
width: bounds.width,
height:
bounds.height -
headerBounds.height -
(windowMode === "flat" ? 0 : footerBounds.height),
}),
[
bounds.width,
bounds.height,
headerBounds.height,
footerBounds.height,
windowMode,
],
);
const gridBoundsObservable = useObservable(gridBounds);
const spotlightAlignment = useInitial(
() => new BehaviorSubject(defaultSpotlightAlignment),
);
const pipAlignment = useInitial(
() => new BehaviorSubject(defaultPipAlignment),
);
const setGridMode = useCallback(
(mode: GridMode) => vm.setGridMode(mode),
[vm],
);
useEffect(() => {
widget?.api.transport.send(
gridMode === "grid"
? ElementWidgetActions.TileLayout
: ElementWidgetActions.SpotlightLayout,
{},
);
}, [gridMode]);
const { hideScreensharing, showControls } = useUrlParams();
useEffect(() => {
if (widget) {
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
setGridMode("grid");
widget!.api.transport.reply(ev.detail, {});
};
const onSpotlightLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
setGridMode("spotlight");
widget!.api.transport.reply(ev.detail, {});
};
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
room: livekitRoom,
});
const toggleMicrophone = useCallback(
() => muteStates.audio.setEnabled?.((e) => !e),
[muteStates],
);
const toggleCamera = useCallback(
() => muteStates.video.setEnabled?.((e) => !e),
[muteStates],
);
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
useCallViewKeyboardShortcuts(
containerRef1,
toggleMicrophone,
toggleCamera,
(muted) => muteStates.audio.setEnabled?.(!muted),
);
useEffect(() => {
widget?.api.transport.send(
layout === "grid"
? ElementWidgetActions.TileLayout
: ElementWidgetActions.SpotlightLayout,
{},
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
widget.lazyActions.on(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout,
);
}, [layout]);
useEffect(() => {
if (widget) {
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
setLayout("grid");
widget!.api.transport.reply(ev.detail, {});
};
const onSpotlightLayout = (
ev: CustomEvent<IWidgetApiRequest>,
): void => {
setLayout("spotlight");
widget!.api.transport.reply(ev.detail, {});
};
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
widget.lazyActions.on(
return (): void => {
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
widget!.lazyActions.off(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout,
);
};
}
}, [setGridMode]);
return (): void => {
widget!.lazyActions.off(
ElementWidgetActions.TileLayout,
onTileLayout,
);
widget!.lazyActions.off(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout,
);
};
}
}, [setLayout]);
const mobile = boundsValid && bounds.width <= 660;
const reducedControls = boundsValid && bounds.width <= 340;
const noControls = reducedControls && bounds.height <= 400;
const vm = useCallViewModel(
rtcSession.room,
livekitRoom,
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
connState,
);
const items = useStateObservable(vm.tiles);
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
useFullscreen(items);
// The maximised participant: either the participant that the user has
// manually put in fullscreen, or the focused (active) participant if the
// window is too small to show everyone
const maximisedParticipant = useMemo(
() =>
fullscreenItem ??
(noControls
? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null
: null),
[fullscreenItem, noControls, items],
);
const Grid =
items.length > 12 && layout === "grid" ? NewVideoGrid : VideoGrid;
const prefersReducedMotion = usePrefersReducedMotion();
// This state is lifted out of NewVideoGrid so that layout states can be
// restored after a layout switch or upon exiting fullscreen
const layoutStates = useLayoutStates();
const renderContent = (): JSX.Element => {
if (items.length === 0) {
return (
<div className={styles.centerMessage}>
<p>{t("waiting_for_participants")}</p>
</div>
const Tile = useMemo(
() =>
forwardRef<
HTMLDivElement,
PropsWithoutRef<TileProps<TileModel, HTMLDivElement>>
>(function Tile(
{ className, style, targetWidth, targetHeight, model },
ref,
) {
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
const onToggleExpanded = useObservableEagerState(
vm.toggleSpotlightExpanded,
);
}
if (maximisedParticipant) {
return (
<VideoTile
vm={maximisedParticipant.data}
maximised={true}
fullscreen={maximisedParticipant === fullscreenItem}
onToggleFullscreen={toggleFullscreen}
targetHeight={bounds.height}
targetWidth={bounds.width}
key={maximisedParticipant.id}
showSpeakingIndicator={false}
const showVideo = useObservableEagerState(
useMemo(
() =>
model.type === "grid" ? vm.showGridVideo(model.vm) : of(true),
[model],
),
);
const showSpeakingIndicatorsValue = useObservableEagerState(
vm.showSpeakingIndicators,
);
const showSpotlightIndicatorsValue = useObservableEagerState(
vm.showSpotlightIndicators,
);
return model.type === "grid" ? (
<GridTile
ref={ref}
vm={model.vm}
onOpenProfile={openProfile}
targetWidth={targetWidth}
targetHeight={targetHeight}
className={classNames(className, styles.tile)}
style={style}
showVideo={showVideo}
showSpeakingIndicators={showSpeakingIndicatorsValue}
/>
) : (
<SpotlightTile
ref={ref}
vms={model.vms}
maximised={model.maximised}
expanded={spotlightExpanded}
onToggleExpanded={onToggleExpanded}
targetWidth={targetWidth}
targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue}
className={classNames(className, styles.tile)}
style={style}
/>
);
}
}),
[vm, openProfile],
);
return (
<Grid
items={items}
layout={layout}
disableAnimations={prefersReducedMotion || isSafari}
layoutStates={layoutStates}
>
{({ data: vm, ...props }): ReactNode => (
<VideoTile
vm={vm}
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleFullscreen}
showSpeakingIndicator={items.length > 2}
onOpenProfile={openProfile}
{...props}
ref={props.ref as Ref<HTMLDivElement>}
/>
)}
</Grid>
);
const layouts = useMemo(() => {
const inputs = {
minBounds: gridBoundsObservable,
spotlightAlignment,
pipAlignment,
};
return {
grid: makeGridLayout(inputs),
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
"one-on-one": makeOneOnOneLayout(inputs),
};
}, [gridBoundsObservable, spotlightAlignment, pipAlignment]);
const rageshakeRequestModalProps = useRageshakeRequestModal(
rtcSession.room.roomId,
);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen],
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[setSettingsModalOpen],
);
const openProfile = useCallback(() => {
setSettingsTab("profile");
setSettingsModalOpen(true);
}, [setSettingsTab, setSettingsModalOpen]);
const toggleScreensharing = useCallback(async () => {
exitFullscreen();
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
audio: true,
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
});
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
let footer: JSX.Element | null;
if (noControls) {
footer = null;
} else {
const buttons: JSX.Element[] = [];
buttons.push(
<MicButton
key="1"
muted={!muteStates.audio.enabled}
onPress={toggleMicrophone}
disabled={muteStates.audio.setEnabled === null}
data-testid="incall_mute"
/>,
<VideoButton
key="2"
muted={!muteStates.video.enabled}
onPress={toggleCamera}
disabled={muteStates.video.setEnabled === null}
data-testid="incall_videomute"
/>,
);
if (!reducedControls) {
if (canScreenshare && !hideScreensharing) {
buttons.push(
<ScreenshareButton
key="3"
enabled={isScreenShareEnabled}
onPress={toggleScreensharing}
data-testid="incall_screenshare"
/>,
);
}
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
}
buttons.push(
<HangupButton
key="6"
onPress={function (): void {
onLeave();
}}
data-testid="incall_leave"
/>,
);
footer = (
<div
className={classNames(
showControls
? styles.footer
: hideHeader
? [styles.footer, styles.footerHidden]
: [styles.footer, styles.footerThin],
)}
>
{!mobile && !hideHeader && (
<div className={styles.logo}>
<LogoMark width={24} height={24} aria-hidden />
<LogoType
width={80}
height={11}
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
/>
</div>
)}
{showControls && <div className={styles.buttons}>{buttons}</div>}
{!mobile && !hideHeader && showControls && (
<LayoutToggle
className={styles.layout}
layout={layout}
setLayout={setLayout}
/>
)}
</div>
const renderContent = (): JSX.Element => {
if (layout.type === "pip") {
return (
<SpotlightTile
className={classNames(styles.tile, styles.maximised)}
vms={layout.spotlight!}
maximised
expanded
onToggleExpanded={null}
targetWidth={gridBounds.height}
targetHeight={gridBounds.width}
showIndicators={false}
/>
);
}
return (
<div className={styles.inRoom} ref={containerRef}>
{!hideHeader && maximisedParticipant === null && (
<Header>
const layers = layouts[layout.type] as CallLayoutOutputs<Layout>;
const fixedGrid = (
<Grid
key="fixed"
className={styles.fixedGrid}
style={{
insetBlockStart: headerBounds.bottom,
height: gridBounds.height,
}}
model={layout}
Layout={layers.fixed}
Tile={Tile}
/>
);
const scrollingGrid = (
<Grid
key="scrolling"
className={styles.scrollingGrid}
model={layout}
Layout={layers.scrolling}
Tile={Tile}
/>
);
// The grid tiles go *under* the spotlight in the portrait layout, but
// *over* the spotlight in the expanded layout
return layout.type === "spotlight-expanded" ? (
<>
{fixedGrid}
{scrollingGrid}
</>
) : (
<>
{scrollingGrid}
{fixedGrid}
</>
);
};
const rageshakeRequestModalProps = useRageshakeRequestModal(
rtcSession.room.roomId,
);
const toggleScreensharing = useCallback(async () => {
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
audio: true,
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
});
}, [localParticipant, isScreenShareEnabled]);
let footer: JSX.Element | null;
if (noControls) {
footer = null;
} else {
const buttons: JSX.Element[] = [];
buttons.push(
<MicButton
key="1"
muted={!muteStates.audio.enabled}
onPress={toggleMicrophone}
disabled={muteStates.audio.setEnabled === null}
data-testid="incall_mute"
/>,
<VideoButton
key="2"
muted={!muteStates.video.enabled}
onPress={toggleCamera}
disabled={muteStates.video.setEnabled === null}
data-testid="incall_videomute"
/>,
);
if (!reducedControls) {
if (canScreenshare && !hideScreensharing) {
buttons.push(
<ScreenshareButton
key="3"
enabled={isScreenShareEnabled}
onPress={toggleScreensharing}
data-testid="incall_screenshare"
/>,
);
}
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
}
buttons.push(
<HangupButton
key="6"
onPress={function (): void {
onLeave();
}}
data-testid="incall_leave"
/>,
);
footer = (
<div
ref={footerRef}
className={classNames(styles.footer, {
[styles.overlay]: windowMode === "flat",
[styles.hidden]: !showFooter || (!showControls && hideHeader),
})}
>
{!mobile && !hideHeader && (
<div className={styles.logo}>
<LogoMark width={24} height={24} aria-hidden />
<LogoType
width={80}
height={11}
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
/>
</div>
)}
{showControls && <div className={styles.buttons}>{buttons}</div>}
{!mobile && showControls && (
<LayoutToggle
className={styles.layout}
layout={gridMode}
setLayout={setGridMode}
onTouchEnd={onLayoutToggleTouchEnd}
/>
)}
</div>
);
}
return (
<div
className={styles.inRoom}
ref={containerRef}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
onPointerMove={onPointerMove}
onPointerOut={onPointerOut}
>
{showHeader &&
(hideHeader ? (
// Cosmetic header to fill out space while still affecting the bounds
// of the grid
<div
className={classNames(styles.header, styles.filler)}
ref={headerRef}
/>
) : (
<Header className={styles.header} ref={headerRef}>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
@@ -444,24 +572,19 @@ export const InCallView: FC<InCallViewProps> = subscribe(
)}
</RightNav>
</Header>
)}
<div className={styles.controlsOverlay}>
<RoomAudioRenderer />
{renderContent()}
{footer}
</div>
{!noControls && (
<RageshakeRequestModal {...rageshakeRequestModalProps} />
)}
<SettingsModal
client={client}
roomId={rtcSession.room.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
tab={settingsTab}
onTabChange={setSettingsTab}
/>
</div>
);
},
);
))}
<RoomAudioRenderer />
{renderContent()}
{footer}
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
<SettingsModal
client={client}
roomId={rtcSession.room.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
tab={settingsTab}
onTabChange={setSettingsTab}
/>
</div>
);
};

View File

@@ -18,8 +18,10 @@ import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Room } from "matrix-js-sdk";
import { Button, Text } from "@vector-im/compound-web";
import LinkIcon from "@vector-im/compound-design-tokens/icons/link.svg?react";
import CheckIcon from "@vector-im/compound-design-tokens/icons/check.svg?react";
import {
LinkIcon,
CheckIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import useClipboard from "react-use-clipboard";
import { Modal } from "../Modal";

View File

@@ -1,5 +1,5 @@
/*
Copyright 2023 New Vector Ltd
Copyright 2023-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -21,22 +21,15 @@ limitations under the License.
background: var(--cpd-color-bg-canvas-default);
box-shadow: 0px 0px 40px 0px rgba(0, 0, 0, 0.5);
display: flex;
position: relative;
}
.toggle input {
appearance: none;
/*
* Safari puts a margin on these, which is not removed via appearance: none
* mobile safari also has them take up space in the DOM, so set width 0
*/
/* Safari puts a margin on these, which is not removed via appearance: none */
margin: 0;
width: 0;
outline: none !important;
}
.toggle label {
display: block;
padding: calc(2.5 * var(--cpd-space-1x));
block-size: var(--cpd-space-11x);
inline-size: var(--cpd-space-11x);
cursor: pointer;
border-radius: var(--cpd-radius-pill-effect);
color: var(--cpd-color-icon-primary);
@@ -44,41 +37,52 @@ limitations under the License.
box-shadow: var(--small-drop-shadow);
}
.toggle svg {
display: block;
position: absolute;
padding: calc(2.5 * var(--cpd-space-1x));
pointer-events: none;
color: var(--cpd-color-icon-primary);
}
.toggle svg:nth-child(2) {
inset-inline-start: 2px;
}
.toggle svg:nth-child(4) {
inset-inline-end: 2px;
}
@media (hover: hover) {
.toggle label:hover {
.toggle input:hover {
background: var(--cpd-color-bg-action-secondary-hovered);
box-shadow: none;
}
}
.toggle label:active {
.toggle input:active {
background: var(--cpd-color-bg-action-secondary-hovered);
box-shadow: none;
}
.toggle input:checked + label {
color: var(--cpd-color-icon-on-solid-primary);
.toggle input:checked {
background: var(--cpd-color-bg-action-primary-rest);
}
.toggle input:checked + svg {
color: var(--cpd-color-icon-on-solid-primary);
}
@media (hover: hover) {
.toggle input:checked + label:hover {
.toggle input:checked:hover {
background: var(--cpd-color-bg-action-primary-hovered);
}
}
.toggle input:checked + label:active {
.toggle input:checked:active {
background: var(--cpd-color-bg-action-primary-hovered);
}
.toggle label > svg {
display: block;
}
.toggle label:last-child {
margin-inline-start: 5px;
}
.toggle input:focus-visible + label {
outline: auto;
.toggle input:first-child {
margin-inline-end: 5px;
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2023 New Vector Ltd
Copyright 2023-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,11 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ChangeEvent, FC, useCallback, useId } from "react";
import { ChangeEvent, FC, TouchEvent, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@vector-im/compound-web";
import SpotlightIcon from "@vector-im/compound-design-tokens/icons/spotlight.svg?react";
import GridIcon from "@vector-im/compound-design-tokens/icons/grid.svg?react";
import {
SpotlightIcon,
GridIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import styles from "./LayoutToggle.module.css";
@@ -29,9 +31,15 @@ interface Props {
layout: Layout;
setLayout: (layout: Layout) => void;
className?: string;
onTouchEnd?: (e: TouchEvent) => void;
}
export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
export const LayoutToggle: FC<Props> = ({
layout,
setLayout,
className,
onTouchEnd,
}) => {
const { t } = useTranslation();
const onChange = useCallback(
@@ -39,37 +47,30 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
[setLayout],
);
const spotlightId = useId();
const gridId = useId();
return (
<div className={classNames(styles.toggle, className)}>
<input
id={spotlightId}
type="radio"
name="layout"
value="spotlight"
checked={layout === "spotlight"}
onChange={onChange}
/>
<Tooltip label={t("layout_spotlight_label")}>
<label htmlFor={spotlightId}>
<SpotlightIcon aria-label={t("layout_spotlight_label")} />
</label>
<input
type="radio"
name="layout"
value="spotlight"
checked={layout === "spotlight"}
onChange={onChange}
onTouchEnd={onTouchEnd}
/>
</Tooltip>
<input
id={gridId}
type="radio"
name="layout"
value="grid"
checked={layout === "grid"}
onChange={onChange}
/>
<SpotlightIcon aria-hidden width={24} height={24} />
<Tooltip label={t("layout_grid_label")}>
<label htmlFor={gridId}>
<GridIcon aria-label={t("layout_grid_label")} />
</label>
<input
type="radio"
name="layout"
value="grid"
checked={layout === "grid"}
onChange={onChange}
onTouchEnd={onTouchEnd}
/>
</Tooltip>
<GridIcon aria-hidden width={24} height={24} />
</div>
);
};

View File

@@ -14,10 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Dispatch, SetStateAction, useMemo } from "react";
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
} from "react";
import { IWidgetApiRequest } from "matrix-widget-api";
import { MediaDevice, useMediaDevices } from "../livekit/MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import { ElementWidgetActions, widget } from "../widget";
/**
* If there already are this many participants in the call, we automatically mute
@@ -53,7 +61,7 @@ function useMuteState(
): MuteState {
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
(prev) =>
device.available.length > 0 ? prev ?? enabledByDefault() : undefined,
device.available.length > 0 ? (prev ?? enabledByDefault()) : undefined,
[device],
);
return useMemo(
@@ -74,5 +82,62 @@ export function useMuteStates(): MuteStates {
const audio = useMuteState(devices.audioInput, () => true);
const video = useMuteState(devices.videoInput, () => true);
useEffect(() => {
widget?.api.transport.send(ElementWidgetActions.DeviceMute, {
audio_enabled: audio.enabled,
video_enabled: video.enabled,
});
}, [audio, video]);
const onMuteStateChangeRequest = useCallback(
(ev: CustomEvent<IWidgetApiRequest>) => {
// First copy the current state into our new state.
const newState = {
audio_enabled: audio.enabled,
video_enabled: video.enabled,
};
// Update new state if there are any requested changes from the widget action
// in `ev.detail.data`.
if (
ev.detail.data.audio_enabled != null &&
typeof ev.detail.data.audio_enabled === "boolean"
) {
audio.setEnabled?.(ev.detail.data.audio_enabled);
newState.audio_enabled = ev.detail.data.audio_enabled;
}
if (
ev.detail.data.video_enabled != null &&
typeof ev.detail.data.video_enabled === "boolean"
) {
video.setEnabled?.(ev.detail.data.video_enabled);
newState.video_enabled = ev.detail.data.video_enabled;
}
// Always reply with the new (now "current") state.
// This allows to also use this action to just get the unaltered current state
// by using a fromWidget request with: `ev.detail.data = {}`
widget!.api.transport.reply(ev.detail, newState);
},
[audio, video],
);
useEffect(() => {
// We setup a event listener for the widget action ElementWidgetActions.DeviceMute.
if (widget) {
// only setup the listener in widget mode
widget.lazyActions.on(
ElementWidgetActions.DeviceMute,
onMuteStateChangeRequest,
);
return (): void => {
// return a call to `off` so that we always clean up our listener.
widget?.lazyActions.off(
ElementWidgetActions.DeviceMute,
onMuteStateChangeRequest,
);
};
}
}, [onMuteStateChangeRequest]);
return useMemo(() => ({ audio, video }), [audio, video]);
}

View File

@@ -17,7 +17,7 @@ limitations under the License.
import { FC, useEffect, useState, useCallback, ReactNode } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import CheckIcon from "@vector-im/compound-design-tokens/icons/check.svg?react";
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useClientLegacy } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
@@ -26,7 +26,6 @@ import { GroupCallLoader } from "./GroupCallLoader";
import { GroupCallView } from "./GroupCallView";
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { useOptInAnalytics } from "../settings/useSetting";
import { HomePage } from "../home/HomePage";
import { platform } from "../Platform";
import { AppSelectionModal } from "./AppSelectionModal";
@@ -36,6 +35,10 @@ import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { useProfile } from "../profile/useProfile";
import { useMuteStates } from "./MuteStates";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
} from "../settings/settings";
export const RoomPage: FC = () => {
const {
@@ -80,7 +83,7 @@ export const RoomPage: FC = () => {
registerPasswordlessUser,
]);
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const [optInAnalytics, setOptInAnalytics] = useSetting(optInAnalyticsSetting);
useEffect(() => {
// During the beta, opt into analytics by default
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);

View File

@@ -18,20 +18,12 @@ limitations under the License.
margin-inline: var(--inline-content-inset);
min-block-size: 0;
block-size: 50vh;
}
.preview.content {
margin-inline: 0;
}
.content {
border-radius: var(--cpd-space-4x);
position: relative;
block-size: 100%;
inline-size: 100%;
overflow: hidden;
}
.content video {
.preview > video {
width: 100%;
height: 100%;
object-fit: cover;
@@ -69,12 +61,20 @@ limitations under the License.
);
}
.preview.content .buttonBar {
padding-inline: var(--inline-content-inset);
}
@media (min-aspect-ratio: 1 / 1) {
.preview video {
.preview > video {
aspect-ratio: 16 / 9;
}
}
@media (max-width: 550px) {
.preview {
margin-inline: 0;
border-radius: 0;
block-size: 100%;
}
.buttonBar {
padding-inline: var(--inline-content-inset);
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 - 2023 New Vector Ltd
Copyright 2022 - 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -18,20 +18,15 @@ import { useEffect, useMemo, useRef, FC, ReactNode, useCallback } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { usePreviewTracks } from "@livekit/components-react";
import {
CreateLocalTracksOptions,
LocalVideoTrack,
Track,
} from "livekit-client";
import { LocalVideoTrack, Track } from "livekit-client";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { Glass } from "@vector-im/compound-web";
import { Avatar } from "../Avatar";
import styles from "./VideoPreview.module.css";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { MuteStates } from "./MuteStates";
import { useMediaQuery } from "../useMediaQuery";
import { useInitial } from "../useInitial";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
export type MatrixInfo = {
@@ -63,10 +58,10 @@ export const VideoPreview: FC<Props> = ({
// Capture the audio options as they were when we first mounted, because
// we're not doing anything with the audio anyway so we don't need to
// re-open the devices when they change (see below).
const initialAudioOptions = useRef<CreateLocalTracksOptions["audio"]>();
initialAudioOptions.current ??= muteStates.audio.enabled && {
deviceId: devices.audioInput.selectedId,
};
const initialAudioOptions = useInitial(
() =>
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
);
const localTrackOptions = useMemo(
() => ({
@@ -76,12 +71,16 @@ export const VideoPreview: FC<Props> = ({
// reference the initial values here.
// We also pass in a clone because livekit mutates the object passed in,
// which would cause the devices to be re-opened on the next render.
audio: Object.assign({}, initialAudioOptions.current),
audio: Object.assign({}, initialAudioOptions),
video: muteStates.video.enabled && {
deviceId: devices.videoInput.selectedId,
},
}),
[devices.videoInput.selectedId, muteStates.video.enabled],
[
initialAudioOptions,
devices.videoInput.selectedId,
muteStates.video.enabled,
],
);
const onError = useCallback(
@@ -115,8 +114,8 @@ export const VideoPreview: FC<Props> = ({
};
}, [videoTrack]);
const content = (
<>
return (
<div className={classNames(styles.preview)} ref={previewRef}>
<video
ref={videoEl}
muted
@@ -136,21 +135,6 @@ export const VideoPreview: FC<Props> = ({
</div>
)}
<div className={styles.buttonBar}>{children}</div>
</>
);
return useMediaQuery("(max-width: 550px)") ? (
<div
className={classNames(styles.preview, styles.content)}
ref={previewRef}
>
{content}
</div>
) : (
<Glass className={styles.preview}>
<div className={styles.content} ref={previewRef}>
{content}
</div>
</Glass>
);
};

View File

@@ -1,114 +0,0 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { useCallback, useLayoutEffect, useRef } from "react";
import { useReactiveState } from "../useReactiveState";
import { useEventTarget } from "../useEvents";
import { TileDescriptor } from "../state/CallViewModel";
const isFullscreen = (): boolean =>
Boolean(document.fullscreenElement) ||
Boolean(document.webkitFullscreenElement);
function enterFullscreen(): void {
if (document.body.requestFullscreen) {
document.body.requestFullscreen();
} else if (document.body.webkitRequestFullscreen) {
document.body.webkitRequestFullscreen();
} else {
logger.error("No available fullscreen API!");
}
}
function exitFullscreen(): void {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else {
logger.error("No available fullscreen API!");
}
}
function useFullscreenChange(onFullscreenChange: () => void): void {
useEventTarget(document.body, "fullscreenchange", onFullscreenChange);
useEventTarget(document.body, "webkitfullscreenchange", onFullscreenChange);
}
/**
* Provides callbacks for controlling the full-screen view, which can hold one
* item at a time.
*/
export function useFullscreen<T>(items: TileDescriptor<T>[]): {
fullscreenItem: TileDescriptor<T> | null;
toggleFullscreen: (itemId: string) => void;
exitFullscreen: () => void;
} {
const [fullscreenItem, setFullscreenItem] =
useReactiveState<TileDescriptor<T> | null>(
(prevItem) =>
prevItem == null
? null
: items.find((i) => i.id === prevItem.id) ?? null,
[items],
);
const latestItems = useRef<TileDescriptor<T>[]>(items);
latestItems.current = items;
const latestFullscreenItem = useRef<TileDescriptor<T> | null>(fullscreenItem);
latestFullscreenItem.current = fullscreenItem;
const toggleFullscreen = useCallback(
(itemId: string) => {
setFullscreenItem(
latestFullscreenItem.current === null
? latestItems.current.find((i) => i.id === itemId) ?? null
: null,
);
},
[setFullscreenItem],
);
const exitFullscreenCallback = useCallback(
() => setFullscreenItem(null),
[setFullscreenItem],
);
useLayoutEffect(() => {
// Determine whether we need to change the fullscreen state
if (isFullscreen() !== (fullscreenItem !== null)) {
(fullscreenItem === null ? exitFullscreen : enterFullscreen)();
}
}, [fullscreenItem]);
// Detect when the user exits fullscreen through an external mechanism like
// browser chrome or the escape key
useFullscreenChange(
useCallback(() => {
if (!isFullscreen()) setFullscreenItem(null);
}, [setFullscreenItem]),
);
return {
fullscreenItem,
toggleFullscreen,
exitFullscreen: exitFullscreenCallback,
};
}

View File

@@ -225,16 +225,38 @@ export const useLoadGroupCall = (
});
} else {
// If the room does not exist we first search for it with viaServers
const roomSummary = await client.getRoomSummary(roomId, viaServers);
if (roomSummary.join_rule === JoinRule.Public) {
room = await client.joinRoom(roomSummary.room_id, {
let roomSummary: RoomSummary | undefined = undefined;
try {
roomSummary = await client.getRoomSummary(roomId, viaServers);
} catch (error) {
// If the room summary endpoint is not supported we let it be undefined and treat this case like
// `JoinRule.Public`.
// This is how the logic was done before: "we expect any room id passed to EC
// to be for a public call" Which is definitely not ideal but worth a try if fetching
// the summary crashes.
logger.warn(
`Could not load room summary to decide whether we want to join or knock.
EC will fallback to join as if this would be a public room.
Reach out to your homeserver admin to ask them about supporting the \`/summary\` endpoint (im.nheko.summary):`,
error,
);
}
if (
roomSummary === undefined ||
roomSummary.join_rule === JoinRule.Public
) {
room = await client.joinRoom(roomId, {
viaServers,
});
} else if (roomSummary.join_rule === JoinRule.Knock) {
// bind room summary in this scope so we have it stored in a binding of type `RoomSummary`
// instead of `RoomSummary | undefined`. Because we use it in a promise the linter does not accept
// the type check from the if condition above.
const _roomSummary = roomSummary;
let knock: () => void = () => {};
const userPressedAskToJoinPromise: Promise<void> = new Promise(
(resolve) => {
if (roomSummary.membership !== KnownMembership.Knock) {
if (_roomSummary.membership !== KnownMembership.Knock) {
knock = resolve;
} else {
// resolve immediately if the user already knocked
@@ -242,12 +264,13 @@ export const useLoadGroupCall = (
}
},
);
setState({ kind: "canKnock", roomSummary, knock });
setState({ kind: "canKnock", roomSummary: _roomSummary, knock });
await userPressedAskToJoinPromise;
room = await getRoomByKnocking(
roomSummary.room_id,
viaServers,
() => setState({ kind: "waitForInvite", roomSummary }),
() =>
setState({ kind: "waitForInvite", roomSummary: _roomSummary }),
);
} else {
throw new Error(

View File

@@ -108,10 +108,17 @@ export async function enterRTCSession(
// right now we assume everything is a room-scoped call
const livekitAlias = rtcSession.room.roomId;
const useDeviceSessionMemberEvents =
Config.get().features?.feature_use_device_session_member_events;
rtcSession.joinRoomSession(
await makePreferredLivekitFoci(rtcSession, livekitAlias),
makeActiveFocus(),
{ manageMediaKeys: encryptMedia },
{
manageMediaKeys: encryptMedia,
...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
},
);
}

View File

@@ -54,11 +54,11 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
const avatar = data.get("avatar");
const avatarSize =
typeof avatar == "string" ? avatar.length : avatar?.size ?? 0;
typeof avatar == "string" ? avatar.length : (avatar?.size ?? 0);
const displayName =
typeof displayNameDataEntry == "string"
? displayNameDataEntry
: displayNameDataEntry?.name ?? null;
: (displayNameDataEntry?.name ?? null);
if (!displayName) {
return;

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ChangeEvent, FC, Key, ReactNode } from "react";
import { ChangeEvent, FC, Key, ReactNode, useCallback } from "react";
import { Item } from "@react-stately/collections";
import { Trans, useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk";
@@ -29,14 +29,8 @@ import OverflowIcon from "../icons/Overflow.svg?react";
import UserIcon from "../icons/User.svg?react";
import FeedbackIcon from "../icons/Feedback.svg?react";
import { SelectInput } from "../input/SelectInput";
import {
useOptInAnalytics,
useDeveloperSettingsTab,
useShowConnectionStats,
isFirefox,
} from "./useSetting";
import { FieldRow, InputField } from "../input/Input";
import { Body, Caption } from "../typography/Typography";
import { Caption } from "../typography/Typography";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
@@ -46,6 +40,13 @@ import {
useMediaDeviceNames,
} from "../livekit/MediaDevicesContext";
import { widget } from "../widget";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
developerSettingsTab as developerSettingsTabSetting,
duplicateTiles as duplicateTilesSetting,
} from "./settings";
import { isFirefox } from "../Platform";
type SettingsTab =
| "audio"
@@ -76,11 +77,11 @@ export const SettingsModal: FC<Props> = ({
}) => {
const { t } = useTranslation();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const [developerSettingsTab, setDeveloperSettingsTab] =
useDeveloperSettingsTab();
const [showConnectionStats, setShowConnectionStats] =
useShowConnectionStats();
const [optInAnalytics, setOptInAnalytics] = useSetting(optInAnalyticsSetting);
const [developerSettingsTab, setDeveloperSettingsTab] = useSetting(
developerSettingsTabSetting,
);
const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting);
// Generate a `SelectInput` with a list of devices for a given device kind.
const generateDeviceSelection = (
@@ -238,23 +239,40 @@ export const SettingsModal: FC<Props> = ({
</>
}
>
<FieldRow>
<Body className={styles.fieldRowText}>
{t("version", {
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</Body>
</FieldRow>
<p>
{t("version", {
productName: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</p>
<p>
{t("crypto_version", {
version: client.getCrypto()?.getVersion() || "unknown",
})}
</p>
<p>
{t("matrix_id", {
id: client.getUserId() || "unknown",
})}
</p>
<p>
{t("device_id", {
id: client.getDeviceId() || "unknown",
})}
</p>
<FieldRow>
<InputField
id="showConnectionStats"
name="connection-stats"
label={t("settings.show_connection_stats_label")}
type="checkbox"
checked={showConnectionStats}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setShowConnectionStats(e.target.checked)
}
id="duplicateTiles"
type="number"
label={t("settings.duplicate_tiles_label")}
value={duplicateTiles.toString()}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
const value = event.target.valueAsNumber;
setDuplicateTiles(Number.isNaN(value) ? 0 : value);
},
[setDuplicateTiles],
)}
/>
</FieldRow>
</TabItem>

94
src/settings/settings.ts Normal file
View File

@@ -0,0 +1,94 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { BehaviorSubject, Observable } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
export class Setting<T> {
public constructor(key: string, defaultValue: T) {
this.key = `matrix-setting-${key}`;
const storedValue = localStorage.getItem(this.key);
let initialValue = defaultValue;
if (storedValue !== null) {
try {
initialValue = JSON.parse(storedValue);
} catch (e) {
logger.warn(`Invalid value stored for setting ${key}: ${storedValue}`);
}
}
this._value = new BehaviorSubject(initialValue);
this.value = this._value;
}
private readonly key: string;
private readonly _value: BehaviorSubject<T>;
public readonly value: Observable<T>;
public readonly setValue = (value: T): void => {
this._value.next(value);
localStorage.setItem(this.key, JSON.stringify(value));
};
}
/**
* React hook that returns a settings's current value and a setter.
*/
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
return [useObservableEagerState(setting.value), setting.setValue];
}
// null = undecided
export const optInAnalytics = new Setting<boolean | null>(
"opt-in-analytics",
null,
);
// TODO: This setting can be disabled. Work out an approach to disableable
// settings thats works for Observables in addition to React.
export const useOptInAnalytics = (): [
boolean | null,
((value: boolean | null) => void) | null,
] => {
const setting = useSetting(optInAnalytics);
return PosthogAnalytics.instance.isEnabled() ? setting : [false, null];
};
export const developerSettingsTab = new Setting(
"developer-settings-tab",
false,
);
export const duplicateTiles = new Setting("duplicate-tiles", 0);
export const audioInput = new Setting<string | undefined>(
"audio-input",
undefined,
);
export const audioOutput = new Setting<string | undefined>(
"audio-output",
undefined,
);
export const videoInput = new Setting<string | undefined>(
"video-input",
undefined,
);
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);

View File

@@ -1,104 +0,0 @@
/*
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, useMemo } from "react";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import {
getLocalStorageItem,
setLocalStorageItem,
useLocalStorage,
} from "../useLocalStorage";
type Setting<T> = [T, (value: T) => void];
type DisableableSetting<T> = [T, ((value: T) => void) | null];
export const getSettingKey = (name: string): string => {
return `matrix-setting-${name}`;
};
// Like useState, but reads from and persists the value to localStorage
export const useSetting = <T>(name: string, defaultValue: T): Setting<T> => {
const key = useMemo(() => getSettingKey(name), [name]);
const [item, setItem] = useLocalStorage(key);
const value = useMemo(
() => (item == null ? defaultValue : JSON.parse(item)),
[item, defaultValue],
);
const setValue = useCallback(
(value: T) => {
setItem(JSON.stringify(value));
},
[setItem],
);
return [value, setValue];
};
export const getSetting = <T>(name: string, defaultValue: T): T => {
const item = getLocalStorageItem(getSettingKey(name));
return item === null ? defaultValue : JSON.parse(item);
};
export const setSetting = <T>(name: string, newValue: T): void =>
setLocalStorageItem(getSettingKey(name), JSON.stringify(newValue));
export const isFirefox = (): boolean => {
const { userAgent } = navigator;
return userAgent.includes("Firefox");
};
const canEnableSpatialAudio = (): boolean => {
// Spatial audio means routing audio through audio contexts. On Chrome,
// this bypasses the AEC processor and so breaks echo cancellation.
// We only allow spatial audio to be enabled on Firefox which we know
// passes audio context audio through the AEC algorithm.
// https://bugs.chromium.org/p/chromium/issues/detail?id=687574 is the
// chrome bug for this: once this is fixed and the updated version is deployed
// widely enough, we can allow spatial audio everywhere. It's currently in a
// chrome flag, so we could enable this in Electron if we enabled the chrome flag
// in the Electron wrapper.
return isFirefox();
};
export const useSpatialAudio = (): DisableableSetting<boolean> => {
const settingVal = useSetting("spatial-audio", false);
if (canEnableSpatialAudio()) return settingVal;
return [false, null];
};
// null = undecided
export const useOptInAnalytics = (): DisableableSetting<boolean | null> => {
const settingVal = useSetting<boolean | null>("opt-in-analytics", null);
if (PosthogAnalytics.instance.isEnabled()) return settingVal;
return [false, null];
};
export const useDeveloperSettingsTab = (): Setting<boolean> =>
useSetting("developer-settings-tab", false);
export const useShowConnectionStats = (): Setting<boolean> =>
useSetting("show-connection-stats", false);
export const useAudioInput = (): Setting<string | undefined> =>
useSetting<string | undefined>("audio-input", undefined);
export const useAudioOutput = (): Setting<string | undefined> =>
useSetting<string | undefined>("audio-output", undefined);
export const useVideoInput = (): Setting<string | undefined> =>
useSetting<string | undefined>("video-input", undefined);

View File

@@ -28,29 +28,33 @@ import {
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
import { useEffect, useRef } from "react";
import {
BehaviorSubject,
EMPTY,
Observable,
Subject,
audit,
combineLatest,
concat,
distinctUntilChanged,
filter,
fromEvent,
map,
merge,
mergeAll,
of,
race,
sample,
scan,
shareReplay,
skip,
startWith,
switchAll,
switchMap,
switchScan,
take,
throttleTime,
timer,
zip,
} from "rxjs";
import { StateObservable, state } from "@react-rxjs/core";
import { logger } from "matrix-js-sdk/src/logger";
import { ViewModel } from "./ViewModel";
@@ -61,32 +65,24 @@ import {
} from "../livekit/useECConnectionState";
import { usePrevious } from "../usePrevious";
import {
LocalUserMediaViewModel,
MediaViewModel,
UserMediaViewModel,
RemoteUserMediaViewModel,
ScreenShareViewModel,
UserMediaViewModel,
} from "./MediaViewModel";
import { finalizeValue } from "../observable-utils";
import { accumulate, finalizeValue } from "../observable-utils";
import { ObservableScope } from "./ObservableScope";
import { duplicateTiles } from "../settings/settings";
import { isFirefox } from "../Platform";
// How long we wait after a focus switch before showing the real participant
// list again
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
// Represents something that should get a tile on the layout,
// ie. a user's video feed or a screen share feed.
// TODO: This exposes too much information to the view layer, let's keep this
// information internal to the view model and switch to using Tile<T> instead
export interface TileDescriptor<T> {
id: string;
focused: boolean;
isPresenter: boolean;
isSpeaker: boolean;
hasVideo: boolean;
local: boolean;
largeBaseSize: boolean;
placeNear?: string;
data: T;
}
// This is the number of participants that we think constitutes a "small" call
// on mobile. No spotlight tile should be shown below this threshold.
const smallMobileCallThreshold = 3;
export interface GridLayout {
type: "grid";
@@ -94,18 +90,30 @@ export interface GridLayout {
grid: UserMediaViewModel[];
}
export interface SpotlightLayout {
type: "spotlight";
export interface SpotlightLandscapeLayout {
type: "spotlight-landscape";
spotlight: MediaViewModel[];
grid: UserMediaViewModel[];
}
export interface FullScreenLayout {
type: "full screen";
export interface SpotlightPortraitLayout {
type: "spotlight-portrait";
spotlight: MediaViewModel[];
grid: UserMediaViewModel[];
}
export interface SpotlightExpandedLayout {
type: "spotlight-expanded";
spotlight: MediaViewModel[];
pip?: UserMediaViewModel;
}
export interface OneOnOneLayout {
type: "one-on-one";
local: LocalUserMediaViewModel;
remote: RemoteUserMediaViewModel;
}
export interface PipLayout {
type: "pip";
spotlight: MediaViewModel[];
@@ -117,26 +125,44 @@ export interface PipLayout {
*/
export type Layout =
| GridLayout
| SpotlightLayout
| FullScreenLayout
| SpotlightLandscapeLayout
| SpotlightPortraitLayout
| SpotlightExpandedLayout
| OneOnOneLayout
| PipLayout;
export type GridMode = "grid" | "spotlight";
export type WindowMode = "normal" | "full screen" | "pip";
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
/**
* Sorting bins defining the order in which media tiles appear in the layout.
*/
enum SortingBin {
SelfStart,
/**
* Yourself, when the "always show self" option is on.
*/
SelfAlwaysShown,
/**
* Participants that are sharing their screen.
*/
Presenters,
/**
* Participants that have been speaking recently.
*/
Speakers,
VideoAndAudio,
/**
* Participants with video.
*/
Video,
Audio,
NoMedia,
SelfEnd,
/**
* Participants not sharing any video.
*/
NoVideo,
/**
* Yourself, when the "always show self" option is off.
*/
SelfNotAlwaysShown,
}
class UserMedia {
@@ -151,14 +177,17 @@ class UserMedia {
participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
) {
this.vm = new UserMediaViewModel(id, member, participant, callEncrypted);
this.vm =
participant instanceof LocalParticipant
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
this.speaker = this.vm.speaking.pipeState(
// Require 1 s of continuous speaking to become a speaker, and 10 s of
this.speaker = this.vm.speaking.pipe(
// Require 1 s of continuous speaking to become a speaker, and 60 s of
// continuous silence to stop being considered a speaker
audit((s) =>
merge(
timer(s ? 1000 : 10000),
timer(s ? 1000 : 60000),
// If the speaking flag resets to its original value during this time,
// end the silencing window to stick with that original value
this.vm.speaking.pipe(filter((s1) => s1 !== s)),
@@ -210,7 +239,8 @@ function findMatrixMember(
room: MatrixRoom,
id: string,
): RoomMember | undefined {
if (!id) return undefined;
if (id === "local")
return room.getMember(room.client.getUserId()!) ?? undefined;
const parts = id.split(":");
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
@@ -229,9 +259,9 @@ function findMatrixMember(
// TODO: Move wayyyy more business logic from the call and lobby views into here
export class CallViewModel extends ViewModel {
private readonly rawRemoteParticipants = state(
connectedParticipantsObserver(this.livekitRoom),
);
private readonly rawRemoteParticipants = connectedParticipantsObserver(
this.livekitRoom,
).pipe(shareReplay(1));
// Lists of participants to "hold" on display, even if LiveKit claims that
// they've left
@@ -271,16 +301,13 @@ export class CallViewModel extends ViewModel {
},
).pipe(
mergeAll(),
// Aggregate the hold instructions into a single list showing which
// Accumulate the hold instructions into a single list showing which
// participants are being held
scan(
(holds, instruction) =>
"hold" in instruction
? [instruction.hold, ...holds]
: holds.filter((h) => h !== instruction.unhold),
[] as RemoteParticipant[][],
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
"hold" in instruction
? [instruction.hold, ...holds]
: holds.filter((h) => h !== instruction.unhold),
),
startWith([]),
);
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
@@ -304,33 +331,30 @@ export class CallViewModel extends ViewModel {
},
);
private readonly mediaItems: StateObservable<MediaItem[]> = state(
combineLatest([
this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant),
]).pipe(
scan(
(
prevItems,
[remoteParticipants, { participant: localParticipant }],
) => {
let allGhosts = true;
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value,
]).pipe(
scan(
(
prevItems,
[remoteParticipants, { participant: localParticipant }, duplicateTiles],
) => {
const newItems = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) {
const userMediaId = p === localParticipant ? "local" : p.identity;
const member = findMatrixMember(this.matrixRoom, userMediaId);
if (member === undefined)
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
);
const newItems = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) {
const member = findMatrixMember(this.matrixRoom, p.identity);
allGhosts &&= member === undefined;
// We always start with a local participant with the empty string as
// their ID before we're connected, this is fine and we'll be in
// "all ghosts" mode.
if (p.identity !== "" && member === undefined) {
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
);
}
const userMediaId = p.identity;
// Create as many tiles for this participant as called for by
// the duplicateTiles option
for (let i = 0; i < 1 + duplicateTiles; i++) {
const userMediaId = `${p.identity}:${i}`;
yield [
userMediaId,
prevItems.get(userMediaId) ??
@@ -346,74 +370,107 @@ export class CallViewModel extends ViewModel {
];
}
}
}.bind(this)(),
);
}
}.bind(this)(),
);
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
// If every item is a ghost, that probably means we're still connecting
// and shouldn't bother showing anything yet
return allGhosts ? new Map() : newItems;
},
new Map<string, MediaItem>(),
),
map((ms) => [...ms.values()]),
finalizeValue((ts) => {
for (const t of ts) t.destroy();
}),
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
return newItems;
},
new Map<string, MediaItem>(),
),
map((mediaItems) => [...mediaItems.values()]),
finalizeValue((ts) => {
for (const t of ts) t.destroy();
}),
shareReplay(1),
);
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)),
map((mediaItems) =>
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
),
);
private readonly localUserMedia: Observable<LocalUserMediaViewModel> =
this.mediaItems.pipe(
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
);
private readonly screenShares: Observable<ScreenShare[]> =
this.mediaItems.pipe(
map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
map((mediaItems) =>
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
),
shareReplay(1),
);
private readonly spotlightSpeaker: Observable<UserMedia | null> =
private readonly hasRemoteScreenShares: Observable<boolean> =
this.screenShares.pipe(
map((ms) => ms.some((m) => !m.vm.local)),
distinctUntilChanged(),
);
private readonly spotlightSpeaker: Observable<UserMediaViewModel> =
this.userMedia.pipe(
switchMap((ms) =>
ms.length === 0
switchMap((mediaItems) =>
mediaItems.length === 0
? of([])
: combineLatest(
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))),
mediaItems.map((m) =>
m.vm.speaking.pipe(map((s) => [m, s] as const)),
),
),
),
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
(prev, ms) =>
scan<(readonly [UserMedia, boolean])[], UserMedia, null>(
(prev, mediaItems) => {
// Only remote users that are still in the call should be sticky
const [stickyMedia, stickySpeaking] =
(!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || [];
// Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else
ms.find(([m, s]) => m === prev && s)?.[0] ??
// Otherwise, select anyone who is speaking
ms.find(([, s]) => s)?.[0] ??
// Otherwise, stick with the person who was last speaking
prev ??
// Otherwise, spotlight the local user
ms.find(([m]) => m.vm.local)?.[0] ??
null,
return stickySpeaking
? stickyMedia!
: // Otherwise, select any remote user who is speaking
(mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
// Otherwise, stick with the person who was last speaking
stickyMedia ??
// Otherwise, spotlight an arbitrary remote user
mediaItems.find(([m]) => !m.vm.local)?.[0] ??
// Otherwise, spotlight the local user
mediaItems.find(([m]) => m.vm.local)![0]);
},
null,
),
distinctUntilChanged(),
throttleTime(800, undefined, { leading: true, trailing: true }),
map((speaker) => speaker.vm),
shareReplay(1),
throttleTime(1600, undefined, { leading: true, trailing: true }),
);
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
switchMap((ms) => {
const bins = ms.map((m) =>
switchMap((mediaItems) => {
const bins = mediaItems.map((m) =>
combineLatest(
[m.speaker, m.presenter, m.vm.audioEnabled, m.vm.videoEnabled],
(speaker, presenter, audio, video) => {
[
m.speaker,
m.presenter,
m.vm.videoEnabled,
m.vm instanceof LocalUserMediaViewModel
? m.vm.alwaysShow
: of(false),
],
(speaker, presenter, video, alwaysShow) => {
let bin: SortingBin;
if (m.vm.local) bin = SortingBin.SelfStart;
if (m.vm.local)
bin = alwaysShow
? SortingBin.SelfAlwaysShown
: SortingBin.SelfNotAlwaysShown;
else if (presenter) bin = SortingBin.Presenters;
else if (speaker) bin = SortingBin.Speakers;
else if (video)
bin = audio ? SortingBin.VideoAndAudio : SortingBin.Video;
else bin = audio ? SortingBin.Audio : SortingBin.NoMedia;
else if (video) bin = SortingBin.Video;
else bin = SortingBin.NoVideo;
return [m, bin] as const;
},
@@ -428,153 +485,339 @@ export class CallViewModel extends ViewModel {
}),
);
private readonly spotlight: Observable<MediaViewModel[]> = combineLatest(
[this.screenShares, this.spotlightSpeaker],
(screenShares, spotlightSpeaker): MediaViewModel[] =>
private readonly spotlightAndPip: Observable<
[Observable<MediaViewModel[]>, Observable<UserMediaViewModel | null>]
> = this.screenShares.pipe(
map((screenShares) =>
screenShares.length > 0
? screenShares.map((m) => m.vm)
: spotlightSpeaker === null
? []
: [spotlightSpeaker.vm],
? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const)
: ([
this.spotlightSpeaker.pipe(map((speaker) => [speaker!])),
this.spotlightSpeaker.pipe(
switchMap((speaker) =>
speaker.local
? of(null)
: this.localUserMedia.pipe(
switchMap((vm) =>
vm.alwaysShow.pipe(
map((alwaysShow) => (alwaysShow ? vm : null)),
),
),
),
),
),
] as const),
),
);
// TODO: Make this react to changes in window dimensions and screen
// orientation
private readonly windowMode = of<WindowMode>("normal");
private readonly spotlight: Observable<MediaViewModel[]> =
this.spotlightAndPip.pipe(
switchMap(([spotlight]) => spotlight),
shareReplay(1),
);
private readonly _gridMode = new BehaviorSubject<GridMode>("grid");
private readonly pip: Observable<UserMediaViewModel | null> =
this.spotlightAndPip.pipe(switchMap(([, pip]) => pip));
/**
* The general shape of the window.
*/
public readonly windowMode: Observable<WindowMode> = fromEvent(
window,
"resize",
).pipe(
startWith(null),
map(() => {
const height = window.innerHeight;
const width = window.innerWidth;
if (height <= 400 && width <= 340) return "pip";
// Our layouts for flat windows are better at adapting to a small width
// than our layouts for narrow windows are at adapting to a small height,
// so we give "flat" precedence here
if (height <= 600) return "flat";
if (width <= 600) return "narrow";
return "normal";
}),
distinctUntilChanged(),
shareReplay(1),
);
private readonly spotlightExpandedToggle = new Subject<void>();
public readonly spotlightExpanded: Observable<boolean> =
this.spotlightExpandedToggle.pipe(
accumulate(false, (expanded) => !expanded),
shareReplay(1),
);
private readonly gridModeUserSelection = new Subject<GridMode>();
/**
* The layout mode of the media tile grid.
*/
public readonly gridMode = state(this._gridMode);
public readonly gridMode: Observable<GridMode> =
// If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends
this.gridModeUserSelection.pipe(
startWith(null),
switchMap((userSelection) =>
(userSelection === "spotlight"
? EMPTY
: combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe(
skip(userSelection === null ? 0 : 1),
map(
([hasScreenShares, windowMode]): GridMode =>
hasScreenShares || windowMode === "flat"
? "spotlight"
: "grid",
),
)
).pipe(startWith(userSelection ?? "grid")),
),
distinctUntilChanged(),
shareReplay(1),
);
public setGridMode(value: GridMode): void {
this._gridMode.next(value);
this.gridModeUserSelection.next(value);
}
public readonly layout: StateObservable<Layout> = state(
combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => {
private readonly oneOnOne: Observable<boolean> = combineLatest(
[this.grid, this.screenShares],
(grid, screenShares) =>
grid.length == 2 &&
// There might not be a remote tile if only the local user is in the call
// and they're using the duplicate tiles option
grid.some((vm) => !vm.local) &&
screenShares.length === 0,
);
private readonly gridLayout: Observable<Layout> = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) => ({
type: "grid",
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
? spotlight
: undefined,
grid,
}),
);
private readonly spotlightLandscapeLayout: Observable<Layout> = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) => ({ type: "spotlight-landscape", spotlight, grid }),
);
private readonly spotlightPortraitLayout: Observable<Layout> = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) => ({ type: "spotlight-portrait", spotlight, grid }),
);
private readonly spotlightExpandedLayout: Observable<Layout> = combineLatest(
[this.spotlight, this.pip],
(spotlight, pip) => ({
type: "spotlight-expanded",
spotlight,
pip: pip ?? undefined,
}),
);
private readonly oneOnOneLayout: Observable<Layout> = this.grid.pipe(
map((grid) => ({
type: "one-on-one",
local: grid.find((vm) => vm.local) as LocalUserMediaViewModel,
remote: grid.find((vm) => !vm.local) as RemoteUserMediaViewModel,
})),
);
private readonly pipLayout: Observable<Layout> = this.spotlight.pipe(
map((spotlight): Layout => ({ type: "pip", spotlight })),
);
public readonly layout: Observable<Layout> = this.windowMode.pipe(
switchMap((windowMode) => {
switch (windowMode) {
case "full screen":
throw new Error("unimplemented");
case "normal":
return this.gridMode.pipe(
switchMap((gridMode) => {
switch (gridMode) {
case "grid":
return this.oneOnOne.pipe(
switchMap((oneOnOne) =>
oneOnOne ? this.oneOnOneLayout : this.gridLayout,
),
);
case "spotlight":
return this.spotlightExpanded.pipe(
switchMap((expanded) =>
expanded
? this.spotlightExpandedLayout
: this.spotlightLandscapeLayout,
),
);
}
}),
);
case "narrow":
return this.oneOnOne.pipe(
switchMap((oneOnOne) =>
oneOnOne
? // The expanded spotlight layout makes for a better one-on-one
// experience in narrow windows
this.spotlightExpandedLayout
: combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) =>
grid.length > smallMobileCallThreshold ||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
? this.spotlightPortraitLayout
: this.gridLayout,
).pipe(switchAll()),
),
);
case "flat":
return this.gridMode.pipe(
switchMap((gridMode) => {
switch (gridMode) {
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return this.spotlightLandscapeLayout;
case "spotlight":
return this.spotlightExpandedLayout;
}
}),
);
case "pip":
throw new Error("unimplemented");
case "normal": {
switch (gridMode) {
case "grid":
return combineLatest(
[this.grid, this.spotlight, this.screenShares],
(grid, spotlight, screenShares): Layout => ({
type: "grid",
spotlight: screenShares.length > 0 ? spotlight : undefined,
grid,
}),
);
case "spotlight":
return combineLatest(
[this.grid, this.spotlight],
(grid, spotlight): Layout => ({
type: "spotlight",
spotlight,
grid,
}),
);
}
}
return this.pipLayout;
}
}).pipe(switchAll()),
}),
shareReplay(1),
);
public showSpotlightIndicators: Observable<boolean> = this.layout.pipe(
map((l) => l.type !== "grid"),
distinctUntilChanged(),
shareReplay(1),
);
/**
* The media tiles to be displayed in the call view.
* Determines whether video should be shown for a certain piece of media
* appearing in the grid.
*/
// TODO: Get rid of this field, replacing it with the 'layout' field above
// which keeps more details of the layout order internal to the view model
public readonly tiles: StateObservable<TileDescriptor<MediaViewModel>[]> =
state(
combineLatest([
this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant),
]).pipe(
scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
const ps = [localParticipant, ...remoteParticipants];
const tilesById = new Map(ts.map((t) => [t.id, t]));
const now = Date.now();
let allGhosts = true;
const newTiles = ps.flatMap((p) => {
const userMediaId = p.identity;
const member = findMatrixMember(this.matrixRoom, userMediaId);
allGhosts &&= member === undefined;
const spokeRecently =
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
// We always start with a local participant with the empty string as
// their ID before we're connected, this is fine and we'll be in
// "all ghosts" mode.
if (userMediaId !== "" && member === undefined) {
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
);
}
const userMediaVm =
tilesById.get(userMediaId)?.data ??
new UserMediaViewModel(userMediaId, member, p, this.encrypted);
tilesById.delete(userMediaId);
const userMediaTile: TileDescriptor<MediaViewModel> = {
id: userMediaId,
focused: false,
isPresenter: p.isScreenShareEnabled,
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
hasVideo: p.isCameraEnabled,
local: p.isLocal,
largeBaseSize: false,
data: userMediaVm,
};
if (p.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`;
const screenShareVm =
tilesById.get(screenShareId)?.data ??
new ScreenShareViewModel(
screenShareId,
member,
p,
this.encrypted,
);
tilesById.delete(screenShareId);
const screenShareTile: TileDescriptor<MediaViewModel> = {
id: screenShareId,
focused: true,
isPresenter: false,
isSpeaker: false,
hasVideo: true,
local: p.isLocal,
largeBaseSize: true,
placeNear: userMediaId,
data: screenShareVm,
};
return [userMediaTile, screenShareTile];
} else {
return [userMediaTile];
}
});
// Any tiles left in the map are unused and should be destroyed
for (const t of tilesById.values()) t.data.destroy();
// If every item is a ghost, that probably means we're still connecting
// and shouldn't bother showing anything yet
return allGhosts ? [] : newTiles;
}, [] as TileDescriptor<MediaViewModel>[]),
finalizeValue((ts) => {
for (const t of ts) t.data.destroy();
}),
public showGridVideo(vm: MediaViewModel): Observable<boolean> {
return this.layout.pipe(
map(
(l) =>
!(
(l.type === "spotlight-landscape" ||
l.type === "spotlight-portrait") &&
// This media is already visible in the spotlight; avoid duplication
l.spotlight.some((spotlightVm) => spotlightVm === vm)
),
),
distinctUntilChanged(),
);
}
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe(
map((l) => l.type !== "one-on-one" && !l.type.startsWith("spotlight-")),
distinctUntilChanged(),
shareReplay(1),
);
public readonly toggleSpotlightExpanded: Observable<(() => void) | null> =
this.windowMode.pipe(
switchMap((mode) =>
mode === "normal"
? this.layout.pipe(
map(
(l) =>
l.type === "spotlight-landscape" ||
l.type === "spotlight-expanded",
),
)
: of(false),
),
distinctUntilChanged(),
map((enabled) =>
enabled ? (): void => this.spotlightExpandedToggle.next() : null,
),
shareReplay(1),
);
private readonly screenTap = new Subject<void>();
private readonly screenHover = new Subject<void>();
private readonly screenUnhover = new Subject<void>();
/**
* Callback for when the user taps the call view.
*/
public tapScreen(): void {
this.screenTap.next();
}
/**
* Callback for when the user hovers over the call view.
*/
public hoverScreen(): void {
this.screenHover.next();
}
/**
* Callback for when the user stops hovering over the call view.
*/
public unhoverScreen(): void {
this.screenUnhover.next();
}
public readonly showHeader: Observable<boolean> = this.windowMode.pipe(
map((mode) => mode !== "pip" && mode !== "flat"),
distinctUntilChanged(),
shareReplay(1),
);
public readonly showFooter = this.windowMode.pipe(
switchMap((mode) => {
switch (mode) {
case "pip":
return of(false);
case "normal":
case "narrow":
return of(true);
case "flat":
// Sadly Firefox has some layering glitches that prevent the footer
// from appearing properly. They happen less often if we never hide
// the footer.
if (isFirefox()) return of(true);
// Show/hide the footer in response to interactions
return merge(
this.screenTap.pipe(map(() => "tap" as const)),
this.screenHover.pipe(map(() => "hover" as const)),
).pipe(
switchScan(
(state, interaction) =>
interaction === "tap"
? state
? // Toggle visibility on tap
of(false)
: // Hide after a timeout
timer(6000).pipe(
map(() => false),
startWith(true),
)
: // Show on hover and hide after a timeout
race(timer(3000), this.screenUnhover.pipe(take(1))).pipe(
map(() => false),
startWith(true),
),
false,
),
startWith(false),
);
}
}),
distinctUntilChanged(),
shareReplay(1),
);
public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room

View File

@@ -21,7 +21,6 @@ import {
observeParticipantEvents,
observeParticipantMedia,
} from "@livekit/components-core";
import { StateObservable, state } from "@react-rxjs/core";
import {
LocalParticipant,
LocalTrack,
@@ -32,34 +31,60 @@ import {
TrackEvent,
facingModeFromLocalTrack,
} from "livekit-client";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
import {
BehaviorSubject,
Observable,
combineLatest,
distinctUntilChanged,
distinctUntilKeyChanged,
fromEvent,
map,
of,
shareReplay,
startWith,
switchMap,
} from "rxjs";
import { useEffect } from "react";
import { ViewModel } from "./ViewModel";
import { useReactiveState } from "../useReactiveState";
import { alwaysShowSelf } from "../settings/settings";
// TODO: Move this naming logic into the view model
export function useDisplayName(vm: MediaViewModel): string {
const [displayName, setDisplayName] = useReactiveState(
() => vm.member?.rawDisplayName ?? "[👻]",
[vm.member],
);
useEffect(() => {
if (vm.member) {
const updateName = (): void => {
setDisplayName(vm.member!.rawDisplayName);
};
vm.member!.on(RoomMemberEvent.Name, updateName);
return (): void => {
vm.member!.removeListener(RoomMemberEvent.Name, updateName);
};
}
}, [vm.member, setDisplayName]);
return displayName;
}
function observeTrackReference(
participant: Participant,
source: Track.Source,
): StateObservable<TrackReferenceOrPlaceholder> {
return state(
observeParticipantMedia(participant).pipe(
map(() => ({
participant,
publication: participant.getTrackPublication(source),
source,
})),
distinctUntilKeyChanged("publication"),
),
): Observable<TrackReferenceOrPlaceholder> {
return observeParticipantMedia(participant).pipe(
map(() => ({
participant,
publication: participant.getTrackPublication(source),
source,
})),
distinctUntilKeyChanged("publication"),
shareReplay(1),
);
}
@@ -71,15 +96,16 @@ abstract class BaseMediaViewModel extends ViewModel {
/**
* The LiveKit video track for this media.
*/
public readonly video: StateObservable<TrackReferenceOrPlaceholder>;
public readonly video: Observable<TrackReferenceOrPlaceholder>;
/**
* Whether there should be a warning that this media is unencrypted.
*/
public readonly unencryptedWarning: StateObservable<boolean>;
public readonly unencryptedWarning: Observable<boolean>;
public constructor(
// TODO: This is only needed for full screen toggling and can be removed as
// soon as that code is moved into the view models
/**
* An opaque identifier for this media.
*/
public readonly id: string,
/**
* The Matrix room member to which this media belongs.
@@ -95,15 +121,13 @@ abstract class BaseMediaViewModel extends ViewModel {
super();
const audio = observeTrackReference(participant, audioSource);
this.video = observeTrackReference(participant, videoSource);
this.unencryptedWarning = state(
combineLatest(
[audio, this.video],
(a, v) =>
callEncrypted &&
(a.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false),
).pipe(distinctUntilChanged()),
);
this.unencryptedWarning = combineLatest(
[audio, this.video],
(a, v) =>
callEncrypted &&
(a.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false),
).pipe(distinctUntilChanged(), shareReplay(1));
}
}
@@ -111,66 +135,39 @@ abstract class BaseMediaViewModel extends ViewModel {
* Some participant's media.
*/
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
export type UserMediaViewModel =
| LocalUserMediaViewModel
| RemoteUserMediaViewModel;
/**
* Some participant's user media.
*/
export class UserMediaViewModel extends BaseMediaViewModel {
/**
* Whether the video should be mirrored.
*/
public readonly mirror = state(
this.video.pipe(
switchMap((v) => {
const track = v.publication?.track;
if (!(track instanceof LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe(
startWith(null),
// Mirror only front-facing cameras (those that face the user)
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
);
}),
),
);
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/**
* Whether the participant is speaking.
*/
public readonly speaking = state(
observeParticipantEvents(
this.participant,
ParticipantEvent.IsSpeakingChanged,
).pipe(map((p) => p.isSpeaking)),
public readonly speaking = observeParticipantEvents(
this.participant,
ParticipantEvent.IsSpeakingChanged,
).pipe(
map((p) => p.isSpeaking),
shareReplay(1),
);
private readonly _locallyMuted = new BehaviorSubject(false);
/**
* Whether we've disabled this participant's audio.
*/
public readonly locallyMuted = state(this._locallyMuted);
private readonly _localVolume = new BehaviorSubject(1);
/**
* The volume to which we've set this participant's audio, as a scalar
* multiplier.
*/
public readonly localVolume = state(this._localVolume);
/**
* Whether this participant is sending audio (i.e. is unmuted on their side).
*/
public readonly audioEnabled: StateObservable<boolean>;
public readonly audioEnabled: Observable<boolean>;
/**
* Whether this participant is sending video.
*/
public readonly videoEnabled: StateObservable<boolean>;
public readonly videoEnabled: Observable<boolean>;
private readonly _cropVideo = new BehaviorSubject(true);
/**
* Whether the tile video should be contained inside the tile or be cropped to fit.
*/
public readonly cropVideo = state(this._cropVideo);
public readonly cropVideo: Observable<boolean> = this._cropVideo;
public constructor(
id: string,
@@ -187,32 +184,96 @@ export class UserMediaViewModel extends BaseMediaViewModel {
Track.Source.Camera,
);
const media = observeParticipantMedia(participant);
this.audioEnabled = state(
media.pipe(map((m) => m.microphoneTrack?.isMuted === false)),
const media = observeParticipantMedia(participant).pipe(shareReplay(1));
this.audioEnabled = media.pipe(
map((m) => m.microphoneTrack?.isMuted === false),
);
this.videoEnabled = state(
media.pipe(map((m) => m.cameraTrack?.isMuted === false)),
this.videoEnabled = media.pipe(
map((m) => m.cameraTrack?.isMuted === false),
);
// Sync the local mute state and volume with LiveKit
if (!this.local)
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
muted ? 0 : volume,
)
.pipe(this.scope.bind())
.subscribe((volume) => {
(this.participant as RemoteParticipant).setVolume(volume);
});
}
public toggleLocallyMuted(): void {
this._locallyMuted.next(!this._locallyMuted.value);
}
public toggleFitContain(): void {
this._cropVideo.next(!this._cropVideo.value);
}
}
/**
* The local participant's user media.
*/
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether the video should be mirrored.
*/
public readonly mirror = this.video.pipe(
switchMap((v) => {
const track = v.publication?.track;
if (!(track instanceof LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe(
startWith(null),
// Mirror only front-facing cameras (those that face the user)
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
);
}),
shareReplay(1),
);
/**
* Whether to show this tile in a highly visible location near the start of
* the grid.
*/
public readonly alwaysShow = alwaysShowSelf.value;
public readonly setAlwaysShow = alwaysShowSelf.setValue;
public constructor(
id: string,
member: RoomMember | undefined,
participant: LocalParticipant,
callEncrypted: boolean,
) {
super(id, member, participant, callEncrypted);
}
}
/**
* A remote participant's user media.
*/
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
private readonly _locallyMuted = new BehaviorSubject(false);
/**
* Whether we've disabled this participant's audio.
*/
public readonly locallyMuted: Observable<boolean> = this._locallyMuted;
private readonly _localVolume = new BehaviorSubject(1);
/**
* The volume to which we've set this participant's audio, as a scalar
* multiplier.
*/
public readonly localVolume: Observable<number> = this._localVolume;
public constructor(
id: string,
member: RoomMember | undefined,
participant: RemoteParticipant,
callEncrypted: boolean,
) {
super(id, member, participant, callEncrypted);
// Sync the local mute state and volume with LiveKit
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
muted ? 0 : volume,
)
.pipe(this.scope.bind())
.subscribe((volume) => {
(this.participant as RemoteParticipant).setVolume(volume);
});
}
public toggleLocallyMuted(): void {
this._locallyMuted.next(!this._locallyMuted.value);
}
public setLocalVolume(value: number): void {
this._localVolume.next(value);

View File

@@ -1,49 +0,0 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
ForwardRefExoticComponent,
ForwardRefRenderFunction,
PropsWithoutRef,
RefAttributes,
forwardRef,
} from "react";
// eslint-disable-next-line no-restricted-imports
import { Subscribe, RemoveSubscribe } from "@react-rxjs/core";
/**
* Wraps a React component that consumes Observables, resulting in a component
* that safely subscribes to its Observables before rendering. The component
* will return null until the subscriptions are created.
*/
export function subscribe<P, R>(
render: ForwardRefRenderFunction<R, P>,
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<R>> {
const Subscriber = forwardRef<R, { p: P }>(({ p }, ref) => (
<RemoveSubscribe>{render(p, ref)}</RemoveSubscribe>
));
Subscriber.displayName = "Subscriber";
// eslint-disable-next-line react/display-name
const OuterComponent = forwardRef<R, P>((p, ref) => (
<Subscribe>
<Subscriber ref={ref} p={p} />
</Subscribe>
));
// Copy over the component's display name, default props, etc.
Object.assign(OuterComponent, render);
return OuterComponent;
}

View File

@@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useRef } from "react";
import { Ref, useCallback, useRef } from "react";
import { BehaviorSubject, Observable } from "rxjs";
import { useInitial } from "../useInitial";
/**
* React hook that creates an Observable from a changing value. The Observable
* replays its current value upon subscription and emits whenever the value
@@ -28,3 +30,14 @@ export function useObservable<T>(value: T): Observable<T> {
if (value !== subject.current.value) subject.current.next(value);
return subject.current;
}
/**
* React hook that creates a ref and an Observable that emits any values
* stored in the ref. The Observable replays the value currently stored in the
* ref upon subscription.
*/
export function useObservableRef<T>(initialValue: T): [Observable<T>, Ref<T>] {
const subject = useInitial(() => new BehaviorSubject(initialValue));
const ref = useCallback((value: T) => subject.next(value), [subject]);
return [subject, ref];
}

View File

@@ -0,0 +1,72 @@
/*
Copyright 2022-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.tile {
--media-view-border-radius: var(--cpd-space-4x);
transition: outline-color ease 0.15s;
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
}
/* Use a pseudo-element to create the expressive speaking border, since CSS
borders don't support gradients */
.tile::before {
content: "";
position: absolute;
z-index: -1; /* Put it below the outline */
opacity: 0; /* Hidden unless speaking */
transition: opacity ease 0.15s;
inset: calc(-1 * var(--cpd-border-width-4));
border-radius: var(--cpd-space-5x);
background: linear-gradient(
119deg,
rgba(13, 92, 189, 0.7) 0%,
rgba(13, 189, 168, 0.7) 100%
),
linear-gradient(
180deg,
rgba(13, 92, 189, 0.9) 0%,
rgba(13, 189, 168, 0.9) 100%
);
background-blend-mode: overlay, normal;
}
.tile.speaking {
/* !important because speaking border should take priority over hover */
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
}
.tile.speaking::before {
opacity: 1;
}
@media (hover: hover) {
.tile:hover {
outline: var(--cpd-border-width-2) solid
var(--cpd-color-border-interactive-hovered);
}
}
.muteIcon[data-muted="true"] {
color: var(--cpd-color-icon-secondary);
}
.muteIcon[data-muted="false"] {
color: var(--cpd-color-icon-primary);
}
.volumeSlider {
width: 100%;
}

315
src/tile/GridTile.tsx Normal file
View File

@@ -0,0 +1,315 @@
/*
Copyright 2022-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
ComponentProps,
ReactNode,
forwardRef,
useCallback,
useState,
} from "react";
import { animated } from "@react-spring/web";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import {
MicOnSolidIcon,
MicOffSolidIcon,
MicOffIcon,
OverflowHorizontalIcon,
VolumeOnIcon,
VolumeOffIcon,
VisibilityOnIcon,
UserProfileIcon,
ExpandIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ContextMenu,
MenuItem,
ToggleMenuItem,
Menu,
} from "@vector-im/compound-web";
import { useObservableEagerState } from "observable-hooks";
import styles from "./GridTile.module.css";
import {
UserMediaViewModel,
useDisplayName,
LocalUserMediaViewModel,
RemoteUserMediaViewModel,
} from "../state/MediaViewModel";
import { Slider } from "../Slider";
import { MediaView } from "./MediaView";
import { useLatest } from "../useLatest";
interface TileProps {
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
displayName: string;
showVideo: boolean;
showSpeakingIndicators: boolean;
}
interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel;
mirror: boolean;
menuStart?: ReactNode;
menuEnd?: ReactNode;
}
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
(
{
vm,
showVideo,
showSpeakingIndicators,
menuStart,
menuEnd,
className,
displayName,
...props
},
ref,
) => {
const { t } = useTranslation();
const video = useObservableEagerState(vm.video);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
const audioEnabled = useObservableEagerState(vm.audioEnabled);
const videoEnabled = useObservableEagerState(vm.videoEnabled);
const speaking = useObservableEagerState(vm.speaking);
const cropVideo = useObservableEagerState(vm.cropVideo);
const onSelectFitContain = useCallback(
(e: Event) => {
e.preventDefault();
vm.toggleFitContain();
},
[vm],
);
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
const [menuOpen, setMenuOpen] = useState(false);
const menu = (
<>
{menuStart}
<ToggleMenuItem
Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")}
checked={cropVideo}
onSelect={onSelectFitContain}
/>
{menuEnd}
</>
);
const tile = (
<MediaView
ref={ref}
video={video}
member={vm.member}
unencryptedWarning={unencryptedWarning}
videoEnabled={videoEnabled && showVideo}
videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeakingIndicators && speaking,
})}
nameTagLeadingIcon={
<MicIcon
width={20}
height={20}
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
data-muted={!audioEnabled}
className={styles.muteIcon}
/>
}
displayName={displayName}
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
{...props}
/>
);
return (
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
{menu}
</ContextMenu>
);
},
);
UserMediaTile.displayName = "UserMediaTile";
interface LocalUserMediaTileProps extends TileProps {
vm: LocalUserMediaViewModel;
onOpenProfile: () => void;
}
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
({ vm, onOpenProfile, ...props }, ref) => {
const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror);
const alwaysShow = useObservableEagerState(vm.alwaysShow);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
(e: Event) => {
e.preventDefault();
vm.setAlwaysShow(!latestAlwaysShow.current);
},
[vm, latestAlwaysShow],
);
return (
<UserMediaTile
ref={ref}
vm={vm}
mirror={mirror}
menuStart={
<ToggleMenuItem
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onSelect={onSelectAlwaysShow}
/>
}
menuEnd={
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
}
{...props}
/>
);
},
);
LocalUserMediaTile.displayName = "LocalUserMediaTile";
interface RemoteUserMediaTileProps extends TileProps {
vm: RemoteUserMediaViewModel;
}
const RemoteUserMediaTile = forwardRef<
HTMLDivElement,
RemoteUserMediaTileProps
>(({ vm, ...props }, ref) => {
const { t } = useTranslation();
const locallyMuted = useObservableEagerState(vm.locallyMuted);
const localVolume = useObservableEagerState(vm.localVolume);
const onSelectMute = useCallback(
(e: Event) => {
e.preventDefault();
vm.toggleLocallyMuted();
},
[vm],
);
const onChangeLocalVolume = useCallback(
(v: number) => vm.setLocalVolume(v),
[vm],
);
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
return (
<UserMediaTile
ref={ref}
vm={vm}
mirror={false}
menuStart={
<>
<ToggleMenuItem
Icon={MicOffIcon}
label={t("video_tile.mute_for_me")}
checked={locallyMuted}
onSelect={onSelectMute}
/>
{/* TODO: Figure out how to make this slider keyboard accessible */}
<MenuItem as="div" Icon={VolumeIcon} label={null} onSelect={null}>
<Slider
className={styles.volumeSlider}
label={t("video_tile.volume")}
value={localVolume}
onValueChange={onChangeLocalVolume}
min={0.1}
max={1}
step={0.01}
disabled={locallyMuted}
/>
</MenuItem>
</>
}
{...props}
/>
);
});
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface GridTileProps {
vm: UserMediaViewModel;
onOpenProfile: () => void;
targetWidth: number;
targetHeight: number;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
showVideo: boolean;
showSpeakingIndicators: boolean;
}
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
({ vm, onOpenProfile, ...props }, ref) => {
const displayName = useDisplayName(vm);
if (vm instanceof LocalUserMediaViewModel) {
return (
<LocalUserMediaTile
ref={ref}
vm={vm}
onOpenProfile={onOpenProfile}
displayName={displayName}
{...props}
/>
);
} else {
return (
<RemoteUserMediaTile
ref={ref}
vm={vm}
displayName={displayName}
{...props}
/>
);
}
},
);
GridTile.displayName = "GridTile";

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022-2023 New Vector Ltd
Copyright 2022-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,63 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.videoTile {
position: absolute;
top: 0;
container-name: videoTile;
.media {
container-name: mediaView;
container-type: size;
border-radius: var(--cpd-space-4x);
transition: outline-color ease 0.15s;
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
border-radius: var(--media-view-border-radius);
}
/* Use a pseudo-element to create the expressive speaking border, since CSS
borders don't support gradients */
.videoTile::before {
content: "";
position: absolute;
z-index: -1; /* Put it below the outline */
opacity: 0; /* Hidden unless speaking */
transition: opacity ease 0.15s;
inset: calc(-1 * var(--cpd-border-width-4));
border-radius: var(--cpd-space-5x);
background: linear-gradient(
119deg,
rgba(13, 92, 189, 0.7) 0%,
rgba(13, 189, 168, 0.7) 100%
),
linear-gradient(
180deg,
rgba(13, 92, 189, 0.9) 0%,
rgba(13, 189, 168, 0.9) 100%
);
background-blend-mode: overlay, normal;
}
.videoTile.speaking {
/* !important because speaking border should take priority over hover */
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
}
.videoTile.speaking::before {
opacity: 1;
}
@media (hover: hover) {
.videoTile:hover {
outline: var(--cpd-border-width-2) solid
var(--cpd-color-border-interactive-hovered);
}
}
.videoTile.maximised {
position: relative;
border-radius: 0;
inline-size: 100%;
block-size: 100%;
}
.videoTile video {
.media video {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
@@ -81,19 +31,19 @@ borders don't support gradients */
transform: translate(0);
}
.videoTile.mirror video {
.media.mirror video {
transform: scaleX(-1);
}
.videoTile.screenshare video {
object-fit: contain;
}
.videoTile.cropVideo video {
.media[data-video-fit="cover"] video {
object-fit: cover;
}
.videoTile.videoMuted video {
.media[data-video-fit="contain"] video {
object-fit: contain;
}
.media.videoMuted video {
display: none;
}
@@ -114,13 +64,13 @@ borders don't support gradients */
pointer-events: none;
}
.videoTile.videoMuted .avatar {
.media.videoMuted .avatar {
display: initial;
}
/* CSS makes us put a condition here, even though all we want to do is
unconditionally select the container so we can use cqmin units */
@container videoTile (width > 0) {
@container mediaView (width > 0) {
.avatar {
/* Half of the smallest dimension of the tile */
inline-size: 50cqmin;
@@ -137,11 +87,14 @@ unconditionally select the container so we can use cqmin units */
.fg {
position: absolute;
inset: var(--cpd-space-1x);
inset: var(
--media-view-fg-inset,
calc(var(--media-view-border-radius) - var(--cpd-space-3x))
);
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto;
grid-template-areas: ". button2" "nameTag button1";
grid-template-areas: ". ." "nameTag button";
gap: var(--cpd-space-1x);
place-items: start;
}
@@ -167,14 +120,6 @@ unconditionally select the container so we can use cqmin units */
flex-shrink: 0;
}
.muteIcon[data-muted="true"] {
color: var(--cpd-color-icon-secondary);
}
.muteIcon[data-muted="false"] {
color: var(--cpd-color-icon-primary);
}
.nameTag > .name {
text-overflow: ellipsis;
overflow: hidden;
@@ -200,8 +145,7 @@ unconditionally select the container so we can use cqmin units */
transition: opacity ease 0.15s;
}
.fg > button:focus-visible,
.fg > :focus-visible ~ button,
.fg:has(:focus-visible) > button,
.fg > button[data-enabled="true"],
.fg > button[data-state="open"] {
opacity: 1;
@@ -231,13 +175,5 @@ unconditionally select the container so we can use cqmin units */
}
.fg > button:first-of-type {
grid-area: button1;
}
.fg > button:nth-of-type(2) {
grid-area: button2;
}
.volumeSlider {
width: 100%;
grid-area: button;
}

125
src/tile/MediaView.tsx Normal file
View File

@@ -0,0 +1,125 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { animated } from "@react-spring/web";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { ComponentProps, ReactNode, forwardRef } from "react";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { VideoTrack } from "@livekit/components-react";
import { Text, Tooltip } from "@vector-im/compound-web";
import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./MediaView.module.css";
import { Avatar } from "../Avatar";
interface Props extends ComponentProps<typeof animated.div> {
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
video: TrackReferenceOrPlaceholder;
videoFit: "cover" | "contain";
mirror: boolean;
member: RoomMember | undefined;
videoEnabled: boolean;
unencryptedWarning: boolean;
nameTagLeadingIcon?: ReactNode;
displayName: string;
primaryButton?: ReactNode;
}
export const MediaView = forwardRef<HTMLDivElement, Props>(
(
{
className,
style,
targetWidth,
targetHeight,
video,
videoFit,
mirror,
member,
videoEnabled,
unencryptedWarning,
nameTagLeadingIcon,
displayName,
primaryButton,
...props
},
ref,
) => {
const { t } = useTranslation();
return (
<animated.div
className={classNames(styles.media, className, {
[styles.mirror]: mirror,
[styles.videoMuted]: !videoEnabled,
})}
style={style}
ref={ref}
data-testid="videoTile"
data-video-fit={videoFit}
{...props}
>
<div className={styles.bg}>
<Avatar
id={member?.userId ?? displayName}
name={displayName}
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
/>
{video.publication !== undefined && (
<VideoTrack
trackRef={video}
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
/>
)}
</div>
<div className={styles.fg}>
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium" className={styles.name}>
{displayName}
</Text>
{unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}
placement="bottom"
isTriggerInteractive={false}
>
<ErrorIcon
width={20}
height={20}
className={styles.errorIcon}
aria-hidden
/>
</Tooltip>
)}
</div>
{primaryButton}
</div>
</animated.div>
);
},
);
MediaView.displayName = "MediaView";

View File

@@ -0,0 +1,169 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.contents {
block-size: 100%;
inline-size: 100%;
display: flex;
border-radius: var(--cpd-space-6x);
contain: strict;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
scroll-snap-type: inline mandatory;
scroll-snap-stop: always;
/* It would be nice to use smooth scrolling here, but Firefox has a bug where
it will not re-snap if the snapping point changes while it's smoothly
animating to another snapping point.
scroll-behavior: smooth; */
}
.tile.maximised .contents {
border-radius: 0;
}
.item {
height: 100%;
flex-basis: 100%;
flex-shrink: 0;
--media-view-fg-inset: 10px;
}
.item.snap {
scroll-snap-align: start;
}
.advance {
appearance: none;
cursor: pointer;
opacity: 0;
padding: calc(var(--cpd-space-3x) - var(--cpd-border-width-1));
border: var(--cpd-border-width-1) solid
var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-radius-pill-effect);
background: var(--cpd-color-alpha-gray-1400);
box-shadow: var(--small-drop-shadow);
transition-duration: 0.1s;
transition-property: opacity, background-color, border-color;
position: absolute;
z-index: 1;
/* Center the button vertically on the tile */
top: 50%;
transform: translateY(-50%);
}
.advance > svg {
display: block;
color: var(--cpd-color-icon-on-solid-primary);
}
@media (hover) {
.advance:hover {
border-color: var(--cpd-color-bg-action-primary-hovered);
background: var(--cpd-color-bg-action-primary-hovered);
}
}
.advance:active {
border-color: var(--cpd-color-bg-action-primary-pressed);
background: var(--cpd-color-bg-action-primary-pressed);
}
.back {
inset-inline-start: var(--cpd-space-1x);
}
.next {
inset-inline-end: var(--cpd-space-1x);
}
.expand {
appearance: none;
cursor: pointer;
opacity: 0;
padding: var(--cpd-space-2x);
border: none;
border-radius: var(--cpd-radius-pill-effect);
background: var(--cpd-color-alpha-gray-1400);
box-shadow: var(--small-drop-shadow);
transition-duration: 0.1s;
transition-property: opacity, background-color;
position: absolute;
z-index: 1;
--inset: 6px;
inset-block-end: var(--inset);
inset-inline-end: var(--inset);
}
.expand > svg {
display: block;
color: var(--cpd-color-icon-on-solid-primary);
}
@media (hover) {
.expand:hover {
background: var(--cpd-color-bg-action-primary-hovered);
}
}
.expand:active {
background: var(--cpd-color-bg-action-primary-pressed);
}
@media (hover) {
.tile:hover > button {
opacity: 1;
}
}
.tile:has(:focus-visible) > button {
opacity: 1;
}
.indicators {
display: flex;
gap: var(--cpd-space-2x);
position: absolute;
inset-inline-start: 0;
inset-block-end: calc(-1 * var(--cpd-space-6x));
width: 100%;
justify-content: start;
transition: opacity ease 0.15s;
opacity: 0;
}
.indicators.show {
opacity: 1;
}
.maximised .indicators {
inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px);
justify-content: center;
}
.indicators > .item {
flex-basis: 32px;
block-size: 2px;
transition: background-color ease 0.15s;
}
.indicators > .item[data-visible="false"] {
background: var(--cpd-color-alpha-gray-600);
}
.indicators > .item[data-visible="true"] {
background: var(--cpd-color-gray-1400);
}

329
src/tile/SpotlightTile.tsx Normal file
View File

@@ -0,0 +1,329 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
ComponentProps,
RefAttributes,
forwardRef,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
ExpandIcon,
CollapseIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { animated } from "@react-spring/web";
import { Observable, map } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { RoomMember } from "matrix-js-sdk";
import { MediaView } from "./MediaView";
import styles from "./SpotlightTile.module.css";
import {
LocalUserMediaViewModel,
MediaViewModel,
ScreenShareViewModel,
UserMediaViewModel,
useDisplayName,
} from "../state/MediaViewModel";
import { useInitial } from "../useInitial";
import { useMergedRefs } from "../useMergedRefs";
import { useObservableRef } from "../state/useObservable";
import { useReactiveState } from "../useReactiveState";
import { useLatest } from "../useLatest";
interface SpotlightItemBaseProps {
className?: string;
"data-id": string;
targetWidth: number;
targetHeight: number;
video: TrackReferenceOrPlaceholder;
member: RoomMember | undefined;
unencryptedWarning: boolean;
displayName: string;
}
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
videoEnabled: boolean;
videoFit: "contain" | "cover";
}
interface SpotlightLocalUserMediaItemProps
extends SpotlightUserMediaItemBaseProps {
vm: LocalUserMediaViewModel;
}
const SpotlightLocalUserMediaItem = forwardRef<
HTMLDivElement,
SpotlightLocalUserMediaItemProps
>(({ vm, ...props }, ref) => {
const mirror = useObservableEagerState(vm.mirror);
return <MediaView ref={ref} mirror={mirror} {...props} />;
});
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
vm: UserMediaViewModel;
}
const SpotlightUserMediaItem = forwardRef<
HTMLDivElement,
SpotlightUserMediaItemProps
>(({ vm, ...props }, ref) => {
const videoEnabled = useObservableEagerState(vm.videoEnabled);
const cropVideo = useObservableEagerState(vm.cropVideo);
const baseProps: SpotlightUserMediaItemBaseProps = {
videoEnabled,
videoFit: cropVideo ? "cover" : "contain",
...props,
};
return vm instanceof LocalUserMediaViewModel ? (
<SpotlightLocalUserMediaItem ref={ref} vm={vm} {...baseProps} />
) : (
<MediaView mirror={false} {...baseProps} />
);
});
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
interface SpotlightItemProps {
vm: MediaViewModel;
targetWidth: number;
targetHeight: number;
intersectionObserver: Observable<IntersectionObserver>;
/**
* Whether this item should act as a scroll snapping point.
*/
snap: boolean;
}
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const displayName = useDisplayName(vm);
const video = useObservableEagerState(vm.video);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
// Hook this item up to the intersection observer
useEffect(() => {
const element = ourRef.current!;
let prevIo: IntersectionObserver | null = null;
const subscription = intersectionObserver.subscribe((io) => {
prevIo?.unobserve(element);
io.observe(element);
prevIo = io;
});
return (): void => {
subscription.unsubscribe();
prevIo?.unobserve(element);
};
}, [intersectionObserver]);
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
ref,
"data-id": vm.id,
className: classNames(styles.item, { [styles.snap]: snap }),
targetWidth,
targetHeight,
video,
member: vm.member,
unencryptedWarning,
displayName,
};
return vm instanceof ScreenShareViewModel ? (
<MediaView
videoEnabled
videoFit="contain"
mirror={false}
{...baseProps}
/>
) : (
<SpotlightUserMediaItem vm={vm} {...baseProps} />
);
},
);
SpotlightItem.displayName = "SpotlightItem";
interface Props {
vms: MediaViewModel[];
maximised: boolean;
expanded: boolean;
onToggleExpanded: (() => void) | null;
targetWidth: number;
targetHeight: number;
showIndicators: boolean;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
}
export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
(
{
vms,
maximised,
expanded,
onToggleExpanded,
targetWidth,
targetHeight,
showIndicators,
className,
style,
},
theirRef,
) => {
const { t } = useTranslation();
const [root, ourRef] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const [visibleId, setVisibleId] = useState(vms[0].id);
const latestVms = useLatest(vms);
const latestVisibleId = useLatest(visibleId);
const visibleIndex = vms.findIndex((vm) => vm.id === visibleId);
const canGoBack = visibleIndex > 0;
const canGoToNext = visibleIndex !== -1 && visibleIndex < vms.length - 1;
// To keep track of which item is visible, we need an intersection observer
// hooked up to the root element and the items. Because the items will run
// their effects before their parent does, we need to do this dance with an
// Observable to actually give them the intersection observer.
const intersectionObserver = useInitial<Observable<IntersectionObserver>>(
() =>
root.pipe(
map(
(r) =>
new IntersectionObserver(
(entries) => {
const visible = entries.find((e) => e.isIntersecting);
if (visible !== undefined)
setVisibleId(visible.target.getAttribute("data-id")!);
},
{ root: r, threshold: 0.5 },
),
),
),
);
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
(prev) =>
prev == null || prev === visibleId || vms.every((vm) => vm.id !== prev)
? null
: prev,
[visibleId],
);
const onBackClick = useCallback(() => {
const vms = latestVms.current;
const visibleIndex = vms.findIndex(
(vm) => vm.id === latestVisibleId.current,
);
if (visibleIndex > 0) setScrollToId(vms[visibleIndex - 1].id);
}, [latestVisibleId, latestVms, setScrollToId]);
const onNextClick = useCallback(() => {
const vms = latestVms.current;
const visibleIndex = vms.findIndex(
(vm) => vm.id === latestVisibleId.current,
);
if (visibleIndex !== -1 && visibleIndex !== vms.length - 1)
setScrollToId(vms[visibleIndex + 1].id);
}, [latestVisibleId, latestVms, setScrollToId]);
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
return (
<animated.div
ref={ref}
className={classNames(className, styles.tile, {
[styles.maximised]: maximised,
})}
style={style}
>
{canGoBack && (
<button
className={classNames(styles.advance, styles.back)}
aria-label={t("common.back")}
onClick={onBackClick}
>
<ChevronLeftIcon aria-hidden width={24} height={24} />
</button>
)}
<div className={styles.contents}>
{vms.map((vm) => (
<SpotlightItem
key={vm.id}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
intersectionObserver={intersectionObserver}
snap={scrollToId === null || scrollToId === vm.id}
/>
))}
</div>
{onToggleExpanded && (
<button
className={classNames(styles.expand)}
aria-label={
expanded
? t("video_tile.full_screen")
: t("video_tile.exit_full_screen")
}
onClick={onToggleExpanded}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
</button>
)}
{canGoToNext && (
<button
className={classNames(styles.advance, styles.next)}
aria-label={t("common.next")}
onClick={onNextClick}
>
<ChevronRightIcon aria-hidden width={24} height={24} />
</button>
)}
{!expanded && (
<div
className={classNames(styles.indicators, {
[styles.show]: showIndicators && vms.length > 1,
})}
>
{vms.map((vm) => (
<div
key={vm.id}
className={styles.item}
data-visible={vm.id === visibleId}
/>
))}
</div>
)}
</animated.div>
);
},
);
SpotlightTile.displayName = "SpotlightTile";

View File

@@ -14,20 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { RefObject, useCallback, useRef } from "react";
import { RefObject, useCallback, useMemo, useRef } from "react";
import { useEventTarget } from "./useEvents";
/**
* Determines whether focus is in the same part of the tree as the given
* element (specifically, if an ancestor or descendant of it is focused).
* element (specifically, if the element or an ancestor of it is focused).
*/
const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
const focusedElement = document.activeElement;
return (
focusedElement !== null &&
(focusedElement.contains(e) || e.contains(focusedElement))
);
return focusedElement !== null && focusedElement.contains(e);
};
export function useCallViewKeyboardShortcuts(
@@ -50,12 +47,17 @@ export function useCallViewKeyboardShortcuts(
if (!mayReceiveKeyEvents(focusElement.current)) return;
if (event.key === "m") {
event.preventDefault();
toggleMicrophoneMuted();
} else if (event.key == "v") {
event.preventDefault();
toggleLocalVideoMuted();
} else if (event.key === " " && !spacebarHeld.current) {
spacebarHeld.current = true;
setMicrophoneMuted(false);
} else if (event.key === " ") {
event.preventDefault();
if (!spacebarHeld.current) {
spacebarHeld.current = true;
setMicrophoneMuted(false);
}
}
},
[
@@ -65,6 +67,10 @@ export function useCallViewKeyboardShortcuts(
setMicrophoneMuted,
],
),
// Because this is set on the window, to prevent shortcuts from activating
// another event callback at the same time, we need to preventDefault
// *before* child elements receive the event by using capture mode
useMemo(() => ({ capture: true }), []),
);
useEventTarget(

26
src/useInitial.ts Normal file
View File

@@ -0,0 +1,26 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useRef } from "react";
/**
* React hook that returns the value given on the initial render.
*/
export function useInitial<T>(getValue: () => T): T {
const ref = useRef<{ value: T }>();
ref.current ??= { value: getValue() };
return ref.current.value;
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,16 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Olm from "@matrix-org/olm";
import olmWasmPath from "@matrix-org/olm/olm.wasm?url";
import { RefObject, useRef } from "react";
// https://gitlab.matrix.org/matrix-org/olm/-/issues/10
window.OLM_OPTIONS = {};
let olmLoaded: Promise<void> | null = null;
export interface LatestRef<T> extends RefObject<T> {
current: T;
}
/**
* Loads Olm, if not already loaded.
* React hook that returns a ref containing the value given on the latest
* render.
*/
export const loadOlm = (): Promise<void> =>
(olmLoaded ??= Olm.init({ locateFile: () => olmWasmPath }));
export function useLatest<T>(value: T): LatestRef<T> {
const ref = useRef<T>(value);
ref.current = value;
return ref;
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2023 New Vector Ltd
Copyright 2023-2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -44,7 +44,8 @@ export const useReactiveState = <T>(
if (
prevDeps.current === undefined ||
deps.length !== prevDeps.current.length ||
deps.some((d, i) => d !== prevDeps.current![i])
// Deps might be NaN, so we compare with Object.is rather than ===
deps.some((d, i) => !Object.is(d, prevDeps.current![i]))
) {
state.current = updateFn(state.current);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,195 +0,0 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ComponentType, ReactNode, useCallback, useMemo, useRef } from "react";
import type { RectReadOnly } from "react-use-measure";
import { useReactiveState } from "../useReactiveState";
import { TileDescriptor } from "../state/CallViewModel";
/**
* A video grid layout system with concrete states of type State.
*/
// Ideally State would be parameterized by the tile data type, but then that
// makes Layout a higher-kinded type, which isn't achievable in TypeScript
// (unless you invoke some dark type-level computation magic… 😏)
// So we're stuck with these types being a little too strong.
export interface Layout<State> {
/**
* The layout state for zero tiles.
*/
readonly emptyState: State;
/**
* Updates/adds/removes tiles in a way that looks natural in the context of
* the given initial state.
*/
readonly updateTiles: <T>(s: State, tiles: TileDescriptor<T>[]) => State;
/**
* Adapts the layout to a new container size.
*/
readonly updateBounds: (s: State, bounds: RectReadOnly) => State;
/**
* Gets tiles in the order created by the layout.
*/
readonly getTiles: <T>(s: State) => TileDescriptor<T>[];
/**
* Determines whether a tile is draggable.
*/
readonly canDragTile: <T>(s: State, tile: TileDescriptor<T>) => boolean;
/**
* Drags the tile 'from' to the location of the tile 'to' (if possible).
* The position parameters are numbers in the range [0, 1) describing the
* specific positions on 'from' and 'to' that the drag gesture is targeting.
*/
readonly dragTile: <T>(
s: State,
from: TileDescriptor<T>,
to: TileDescriptor<T>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number,
) => State;
/**
* Toggles the focus of the given tile (if this layout has the concept of
* focus).
*/
readonly toggleFocus?: <T>(s: State, tile: TileDescriptor<T>) => State;
/**
* A React component generating the slot elements for a given layout state.
*/
readonly Slots: ComponentType<{ s: State }>;
/**
* Whether the state of this layout should be remembered even while a
* different layout is active.
*/
readonly rememberState: boolean;
}
/**
* A version of Map with stronger types that allow us to save layout states in a
* type-safe way.
*/
export interface LayoutStatesMap {
get<State>(layout: Layout<State>): State | undefined;
set<State>(layout: Layout<State>, state: State): LayoutStatesMap;
delete<State>(layout: Layout<State>): boolean;
}
/**
* Hook creating a Map to store layout states in.
*/
export const useLayoutStates = (): LayoutStatesMap => {
const layoutStates = useRef<Map<unknown, unknown>>();
if (layoutStates.current === undefined) layoutStates.current = new Map();
return layoutStates.current as LayoutStatesMap;
};
interface UseLayout<State, T> {
state: State;
orderedItems: TileDescriptor<T>[];
generation: number;
canDragTile: (tile: TileDescriptor<T>) => boolean;
dragTile: (
from: TileDescriptor<T>,
to: TileDescriptor<T>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number,
) => void;
toggleFocus: ((tile: TileDescriptor<T>) => void) | undefined;
slots: ReactNode;
}
/**
* Hook which uses the provided layout system to arrange a set of items into a
* concrete layout state, and provides callbacks for user interaction.
*/
export function useLayout<State, T>(
layout: Layout<State>,
items: TileDescriptor<T>[],
bounds: RectReadOnly,
layoutStates: LayoutStatesMap,
): UseLayout<State, T> {
const prevLayout = useRef<Layout<unknown>>();
const prevState = layoutStates.get(layout);
const [state, setState] = useReactiveState<State>(() => {
// If the bounds aren't known yet, don't add anything to the layout
if (bounds.width === 0) {
return layout.emptyState;
} else {
if (
prevLayout.current !== undefined &&
layout !== prevLayout.current &&
!prevLayout.current.rememberState
)
layoutStates.delete(prevLayout.current);
const baseState = layoutStates.get(layout) ?? layout.emptyState;
return layout.updateTiles(layout.updateBounds(baseState, bounds), items);
}
}, [layout, items, bounds]);
const generation = useRef<number>(0);
if (state !== prevState) generation.current++;
prevLayout.current = layout as Layout<unknown>;
// No point in remembering an empty state, plus it would end up clobbering the
// real saved state while restoring a layout
if (state !== layout.emptyState) layoutStates.set(layout, state);
return {
state,
orderedItems: useMemo(() => layout.getTiles<T>(state), [layout, state]),
generation: generation.current,
canDragTile: useCallback(
(tile: TileDescriptor<T>) => layout.canDragTile(state, tile),
[layout, state],
),
dragTile: useCallback(
(
from: TileDescriptor<T>,
to: TileDescriptor<T>,
xPositionOnFrom: number,
yPositionOnFrom: number,
xPositionOnTo: number,
yPositionOnTo: number,
) =>
setState((s) =>
layout.dragTile(
s,
from,
to,
xPositionOnFrom,
yPositionOnFrom,
xPositionOnTo,
yPositionOnTo,
),
),
[layout, setState],
),
toggleFocus: useMemo(
() =>
layout.toggleFocus &&
((tile: TileDescriptor<T>): void =>
setState((s) => layout.toggleFocus!(s, tile))),
[layout, setState],
),
slots: <layout.Slots s={state} />,
};
}

View File

@@ -1,389 +0,0 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SpringRef, TransitionFn, useTransition } from "@react-spring/web";
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
import {
CSSProperties,
FC,
ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useMeasure from "react-use-measure";
import { zip } from "lodash";
import styles from "./NewVideoGrid.module.css";
import {
VideoGridProps as Props,
TileSpring,
ChildrenProperties,
TileSpringUpdate,
} from "./VideoGrid";
import { useReactiveState } from "../useReactiveState";
import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper";
import { BigGrid } from "./BigGrid";
import { useLayout } from "./Layout";
import { TileDescriptor } from "../state/CallViewModel";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
interface Tile<T> extends Rect {
item: TileDescriptor<T>;
}
interface DragState {
tileId: string;
tileX: number;
tileY: number;
cursorX: number;
cursorY: number;
}
interface TapData {
tileId: string;
ts: number;
}
interface SlotProps {
style?: CSSProperties;
}
export const Slot: FC<SlotProps> = ({ style }) => (
<div className={styles.slot} style={style} />
);
/**
* An interactive, animated grid of video tiles.
*/
export function NewVideoGrid<T>({
items,
disableAnimations,
layoutStates,
children,
}: Props<T>): ReactNode {
// 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 [slotsRoot, setSlotsRoot] = useState<HTMLDivElement | null>(null);
const [renderedGeneration, setRenderedGeneration] = useState(0);
useEffect(() => {
if (slotsRoot !== null) {
setRenderedGeneration(
parseInt(slotsRoot.getAttribute("data-generation")!),
);
const observer = new MutationObserver((mutations) => {
if (mutations.some((m) => m.type === "attributes")) {
setRenderedGeneration(
parseInt(slotsRoot.getAttribute("data-generation")!),
);
}
});
observer.observe(slotsRoot, { attributes: true });
return (): void => observer.disconnect();
}
}, [slotsRoot, setRenderedGeneration]);
const [gridRef1, gridBounds] = useMeasure();
const gridRef2 = useRef<HTMLDivElement | null>(null);
const gridRef = useMergedRefs(gridRef1, gridRef2);
const slotRects = useMemo(() => {
if (slotsRoot === null) return [];
const slots = slotsRoot.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
}, [slotsRoot, renderedGeneration, gridBounds]);
// TODO: Implement more layouts and select the right one here
const layout = BigGrid;
const {
state: grid,
orderedItems,
generation,
canDragTile,
dragTile,
toggleFocus,
slots,
} = useLayout(layout, items, gridBounds, layoutStates);
const [tiles] = useReactiveState<Tile<T>[]>(
(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 (renderedGeneration !== generation) return prevTiles ?? [];
const tileRects = new Map(
zip(orderedItems, slotRects) as [TileDescriptor<T>, Rect][],
);
// In order to not break drag gestures, it's critical that we render tiles
// in a stable order (that of 'items')
return items.map((item) => ({ ...tileRects.get(item)!, item }));
},
[slotRects, grid, renderedGeneration],
);
// 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<T>): string => item.id,
from: ({ x, y, width, height }: Tile<T>): TileSpringUpdate => ({
opacity: 0,
scale: 0,
shadow: 0,
shadowSpread: 0,
zIndex: 1,
x,
y,
width,
height,
immediate: disableAnimations,
}),
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
update: ({
item,
x,
y,
width,
height,
}: Tile<T>): TileSpringUpdate | null =>
item.id === dragState.current?.tileId
? null
: {
x,
y,
width,
height,
immediate: disableAnimations,
},
leave: { opacity: 0, scale: 0, immediate: disableAnimations },
config: { mass: 0.7, tension: 252, friction: 25 },
}),
// react-spring's types are bugged and can't infer the spring type
) as unknown as [TransitionFn<Tile<T>, TileSpring>, SpringRef<TileSpring>];
// Because we're using react-spring in imperative mode, we're responsible for
// firing animations manually whenever the tiles array updates
useEffect(() => {
springRef.start();
}, [tiles, springRef]);
const animateDraggedTile = (endOfGesture: boolean): void => {
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
const tile = tiles.find((t) => t.item.id === tileId)!;
springRef.current
.find((c) => (c.item as Tile<T>).item.id === tileId)
?.start(
endOfGesture
? {
scale: 1,
zIndex: 1,
shadow: 0,
x: tile.x,
y: tile.y,
width: tile.width,
height: tile.height,
immediate:
disableAnimations || ((key): boolean => key === "zIndex"),
// Allow the tile's position to settle before pushing its
// z-index back down
delay: (key): number => (key === "zIndex" ? 500 : 0),
}
: {
scale: 1.1,
zIndex: 2,
shadow: 15,
x: tileX,
y: tileY,
immediate:
disableAnimations ||
((key): boolean =>
key === "zIndex" || key === "x" || key === "y"),
},
);
const overTile = tiles.find(
(t) =>
cursorX >= t.x &&
cursorX < t.x + t.width &&
cursorY >= t.y &&
cursorY < t.y + t.height,
);
if (overTile !== undefined)
dragTile(
tile.item,
overTile.item,
(cursorX - tileX) / tile.width,
(cursorY - tileY) / tile.height,
(cursorX - overTile.x) / overTile.width,
(cursorY - overTile.y) / overTile.height,
);
};
const lastTap = useRef<TapData | null>(null);
// Callback for useDrag. We could call useDrag here, but the default
// pattern of spreading {...bind()} across the children to bind the gesture
// ends up breaking memoization and ruining this component's performance.
// Instead, we pass this callback to each tile via a ref, to let them bind the
// gesture using the much more sensible ref-based method.
const onTileDrag = (
tileId: string,
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
tap,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
initial: [initialX, initialY],
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delta: [dx, dy],
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
last,
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
): void => {
if (tap) {
const now = Date.now();
if (
tileId === lastTap.current?.tileId &&
now - lastTap.current.ts < 500
) {
toggleFocus?.(items.find((i) => i.id === tileId)!);
lastTap.current = null;
} else {
lastTap.current = { tileId, ts: now };
}
} else {
const tileController = springRef.current.find(
(c) => (c.item as Tile<T>).item.id === tileId,
)!;
if (canDragTile((tileController.item as Tile<T>).item)) {
if (dragState.current === null) {
const tileSpring = tileController.get();
dragState.current = {
tileId,
tileX: tileSpring.x,
tileY: tileSpring.y,
cursorX: initialX - gridBounds.x,
cursorY: initialY - gridBounds.y + scrollOffset.current,
};
}
dragState.current.tileX += dx;
dragState.current.tileY += dy;
dragState.current.cursorX += dx;
dragState.current.cursorY += dy;
animateDraggedTile(last);
if (last) dragState.current = null;
}
}
};
const onTileDragRef = useRef(onTileDrag);
onTileDragRef.current = onTileDrag;
const scrollOffset = useRef(0);
useScroll(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
({ xy: [, y], delta: [, dy] }) => {
scrollOffset.current = y;
if (dragState.current !== null) {
dragState.current.tileY += dy;
dragState.current.cursorY += dy;
animateDraggedTile(false);
}
},
{ target: gridRef2 },
);
// 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
ref={setSlotsRoot}
className={styles.slots}
data-generation={generation}
>
{slots}
</div>
{tileTransitions((spring, tile) => (
<TileWrapper
key={tile.item.id}
id={tile.item.id}
onDragRef={onTileDragRef}
targetWidth={tile.width}
targetHeight={tile.height}
data={tile.item.data}
{...spring}
>
{children as (props: ChildrenProperties<T>) => ReactNode}
</TileWrapper>
))}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,494 +0,0 @@
/*
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 {
ComponentProps,
ForwardedRef,
ReactNode,
forwardRef,
useCallback,
useEffect,
useState,
} from "react";
import { animated } from "@react-spring/web";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import {
TrackReferenceOrPlaceholder,
VideoTrack,
} from "@livekit/components-react";
import {
RoomMember,
RoomMemberEvent,
} from "matrix-js-sdk/src/models/room-member";
import MicOnSolidIcon from "@vector-im/compound-design-tokens/icons/mic-on-solid.svg?react";
import MicOffSolidIcon from "@vector-im/compound-design-tokens/icons/mic-off-solid.svg?react";
import ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg?react";
import MicOffIcon from "@vector-im/compound-design-tokens/icons/mic-off.svg?react";
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg?react";
import VolumeOnIcon from "@vector-im/compound-design-tokens/icons/volume-on.svg?react";
import VolumeOffIcon from "@vector-im/compound-design-tokens/icons/volume-off.svg?react";
import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg?react";
import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react";
import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react";
import {
Text,
Tooltip,
ContextMenu,
MenuItem,
ToggleMenuItem,
Menu,
} from "@vector-im/compound-web";
import { useStateObservable } from "@react-rxjs/core";
import { Avatar } from "../Avatar";
import styles from "./VideoTile.module.css";
import { useReactiveState } from "../useReactiveState";
import {
ScreenShareViewModel,
MediaViewModel,
UserMediaViewModel,
} from "../state/MediaViewModel";
import { subscribe } from "../state/subscribe";
import { useMergedRefs } from "../useMergedRefs";
import { Slider } from "../Slider";
interface TileProps {
tileRef?: ForwardedRef<HTMLDivElement>;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
video: TrackReferenceOrPlaceholder;
member: RoomMember | undefined;
videoEnabled: boolean;
maximised: boolean;
unencryptedWarning: boolean;
nameTagLeadingIcon?: ReactNode;
nameTag: string;
displayName: string;
primaryButton: ReactNode;
secondaryButton?: ReactNode;
[k: string]: unknown;
}
const Tile = forwardRef<HTMLDivElement, TileProps>(
(
{
tileRef = null,
className,
style,
targetWidth,
targetHeight,
video,
member,
videoEnabled,
maximised,
unencryptedWarning,
nameTagLeadingIcon,
nameTag,
displayName,
primaryButton,
secondaryButton,
...props
},
ref,
) => {
const { t } = useTranslation();
const mergedRef = useMergedRefs(tileRef, ref);
return (
<animated.div
className={classNames(styles.videoTile, className, {
[styles.maximised]: maximised,
[styles.videoMuted]: !videoEnabled,
})}
style={style}
ref={mergedRef}
data-testid="videoTile"
{...props}
>
<div className={styles.bg}>
<Avatar
id={member?.userId ?? displayName}
name={displayName}
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
/>
{video.publication !== undefined && (
<VideoTrack
trackRef={video}
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
/>
)}
</div>
<div className={styles.fg}>
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium" className={styles.name}>
{nameTag}
</Text>
{unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}
side="bottom"
isTriggerInteractive={false}
>
<ErrorIcon
width={20}
height={20}
aria-label={t("common.unencrypted")}
className={styles.errorIcon}
/>
</Tooltip>
)}
</div>
{primaryButton}
{secondaryButton}
</div>
</animated.div>
);
},
);
Tile.displayName = "Tile";
interface UserMediaTileProps {
vm: UserMediaViewModel;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
nameTag: string;
displayName: string;
maximised: boolean;
onOpenProfile: () => void;
showSpeakingIndicator: boolean;
}
const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
(
{
vm,
className,
style,
targetWidth,
targetHeight,
nameTag,
displayName,
maximised,
onOpenProfile,
showSpeakingIndicator,
},
ref,
) => {
const { t } = useTranslation();
const video = useStateObservable(vm.video);
const audioEnabled = useStateObservable(vm.audioEnabled);
const videoEnabled = useStateObservable(vm.videoEnabled);
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
const mirror = useStateObservable(vm.mirror);
const speaking = useStateObservable(vm.speaking);
const locallyMuted = useStateObservable(vm.locallyMuted);
const cropVideo = useStateObservable(vm.cropVideo);
const localVolume = useStateObservable(vm.localVolume);
const onChangeMute = useCallback(() => vm.toggleLocallyMuted(), [vm]);
const onChangeFitContain = useCallback(() => vm.toggleFitContain(), [vm]);
const onSelectMute = useCallback((e: Event) => e.preventDefault(), []);
const onSelectFitContain = useCallback(
(e: Event) => e.preventDefault(),
[],
);
const onChangeLocalVolume = useCallback(
(v: number) => vm.setLocalVolume(v),
[vm],
);
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
const [menuOpen, setMenuOpen] = useState(false);
const menu = vm.local ? (
<>
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
<ToggleMenuItem
Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")}
checked={cropVideo}
onChange={onChangeFitContain}
onSelect={onSelectFitContain}
/>
</>
) : (
<>
<ToggleMenuItem
Icon={MicOffIcon}
label={t("video_tile.mute_for_me")}
checked={locallyMuted}
onChange={onChangeMute}
onSelect={onSelectMute}
/>
<ToggleMenuItem
Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")}
checked={cropVideo}
onChange={onChangeFitContain}
onSelect={onSelectFitContain}
/>
{/* TODO: Figure out how to make this slider keyboard accessible */}
<MenuItem as="div" Icon={VolumeIcon} label={null} onSelect={null}>
<Slider
className={styles.volumeSlider}
label={t("video_tile.volume")}
value={localVolume}
onValueChange={onChangeLocalVolume}
min={0.1}
max={1}
step={0.01}
disabled={locallyMuted}
/>
</MenuItem>
</>
);
const tile = (
<Tile
tileRef={ref}
className={classNames(className, {
[styles.mirror]: mirror,
[styles.speaking]: showSpeakingIndicator && speaking,
[styles.cropVideo]: cropVideo,
})}
style={style}
targetWidth={targetWidth}
targetHeight={targetHeight}
video={video}
member={vm.member}
videoEnabled={videoEnabled}
maximised={maximised}
unencryptedWarning={unencryptedWarning}
nameTagLeadingIcon={
<MicIcon
width={20}
height={20}
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
data-muted={!audioEnabled}
className={styles.muteIcon}
/>
}
nameTag={nameTag}
displayName={displayName}
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={nameTag}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
/>
);
return (
<ContextMenu title={nameTag} trigger={tile} hasAccessibleAlternative>
{menu}
</ContextMenu>
);
},
);
UserMediaTile.displayName = "UserMediaTile";
interface ScreenShareTileProps {
vm: ScreenShareViewModel;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
nameTag: string;
displayName: string;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
}
const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
(
{
vm,
className,
style,
targetWidth,
targetHeight,
nameTag,
displayName,
maximised,
fullscreen,
onToggleFullscreen,
},
ref,
) => {
const { t } = useTranslation();
const video = useStateObservable(vm.video);
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
const onClickFullScreen = useCallback(
() => onToggleFullscreen(vm.id),
[onToggleFullscreen, vm],
);
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon;
return (
<Tile
ref={ref}
className={classNames(className, styles.screenshare)}
style={style}
targetWidth={targetWidth}
targetHeight={targetHeight}
video={video}
member={vm.member}
videoEnabled={true}
maximised={maximised}
unencryptedWarning={unencryptedWarning}
nameTag={nameTag}
displayName={displayName}
primaryButton={
!vm.local && (
<button
aria-label={
fullscreen
? t("video_tile.full_screen")
: t("video_tile.exit_full_screen")
}
onClick={onClickFullScreen}
>
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
)
}
/>
);
},
);
ScreenShareTile.displayName = "ScreenShareTile";
interface Props {
vm: MediaViewModel;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
onOpenProfile: () => void;
targetWidth: number;
targetHeight: number;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicator: boolean;
}
export const VideoTile = forwardRef<HTMLDivElement, Props>(
(
{
vm,
maximised,
fullscreen,
onToggleFullscreen,
onOpenProfile,
className,
style,
targetWidth,
targetHeight,
showSpeakingIndicator,
},
ref,
) => {
const { t } = useTranslation();
// Handle display name changes.
// TODO: Move this into the view model
const [displayName, setDisplayName] = useReactiveState(
() => vm.member?.rawDisplayName ?? "[👻]",
[vm.member],
);
useEffect(() => {
if (vm.member) {
const updateName = (): void => {
setDisplayName(vm.member!.rawDisplayName);
};
vm.member!.on(RoomMemberEvent.Name, updateName);
return (): void => {
vm.member!.removeListener(RoomMemberEvent.Name, updateName);
};
}
}, [vm.member, setDisplayName]);
const nameTag = vm.local
? t("video_tile.sfu_participant_local")
: displayName;
if (vm instanceof UserMediaViewModel) {
return (
<UserMediaTile
ref={ref}
className={className}
style={style}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
nameTag={nameTag}
displayName={displayName}
maximised={maximised}
onOpenProfile={onOpenProfile}
showSpeakingIndicator={showSpeakingIndicator}
/>
);
} else {
return (
<ScreenShareTile
ref={ref}
className={className}
style={style}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
nameTag={nameTag}
displayName={displayName}
maximised={maximised}
fullscreen={fullscreen}
onToggleFullscreen={onToggleFullscreen}
/>
);
}
},
);
VideoTile.displayName = "VideoTile";

View File

@@ -46,6 +46,21 @@ export enum ElementWidgetActions {
// host -> Element Call telling EC to stop screen sharing, or that
// the user cancelled when selecting a source after a ScreenshareRequest
ScreenshareStop = "io.element.screenshare_stop",
// This can be sent as from or to widget
// fromWidget: updates the client about the current device mute state
// toWidget: the client requests a specific device mute configuration
// The reply will always be the resulting configuration
// It is possible to sent an empty configuration to retrieve the current values or
// just one of the fields to update that particular value
// An undefined field means that EC will keep the mute state as is.
// -> this will allow the client to only get the current state
//
// The data of the widget action request and the response are:
// {
// audio_enabled?: boolean,
// video_enabled?: boolean
// }
DeviceMute = "io.element.device_mute",
}
export interface JoinCallData {
@@ -88,6 +103,7 @@ export const widget = ((): WidgetHelpers | null => {
ElementWidgetActions.SpotlightLayout,
ElementWidgetActions.ScreenshareStart,
ElementWidgetActions.ScreenshareStop,
ElementWidgetActions.DeviceMute,
].forEach((action) => {
api.on(`action:${action}`, (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
@@ -118,13 +134,17 @@ export const widget = ((): WidgetHelpers | null => {
"org.matrix.rageshake_request",
EventType.CallEncryptionKeysPrefix,
];
const sendState = [
{
eventType: EventType.GroupCallMemberPrefix,
stateKey: userId, // TODO: based on if we use the new format we want the key to be: `_${userId}_${deviceId}`
},
];
userId, // Legacy call membership events
`_${userId}_${deviceId}`, // Session membership events
`${userId}_${deviceId}`, // The above with no leading underscore, for room versions whose auth rules allow it
].map((stateKey) => ({
eventType: EventType.GroupCallMemberPrefix,
stateKey,
}));
const receiveState = [
{ eventType: EventType.RoomCreate },
{ eventType: EventType.RoomMember },
{ eventType: EventType.RoomEncryption },
{ eventType: EventType.GroupCallMemberPrefix },
@@ -152,6 +172,8 @@ export const widget = ((): WidgetHelpers | null => {
sendToDevice: sendRecvToDevice,
receiveToDevice: sendRecvToDevice,
turnServers: false,
sendDelayedEvents: true,
updateDelayedEvents: true,
},
roomId,
{
@@ -168,7 +190,7 @@ export const widget = ((): WidgetHelpers | null => {
const clientPromise = new Promise<MatrixClient>((resolve) => {
(async (): Promise<void> => {
// wait for the config file to be ready (we load very early on so it might not
// Wait for the config file to be ready (we load very early on so it might not
// be otherwise)
await Config.init();
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });

View File

@@ -0,0 +1,92 @@
/*
Copyright 2024 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { render } from "@testing-library/react";
import { FC, useRef } from "react";
import { test } from "vitest";
import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event";
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
interface TestComponentProps {
setMicrophoneMuted: (muted: boolean) => void;
onButtonClick?: () => void;
}
const TestComponent: FC<TestComponentProps> = ({
setMicrophoneMuted,
onButtonClick = (): void => {},
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useCallViewKeyboardShortcuts(
ref,
() => {},
() => {},
setMicrophoneMuted,
);
return (
<div ref={ref}>
<Button onClick={onButtonClick}>I'm a button</Button>
</div>
);
};
test("spacebar unmutes", async () => {
const user = userEvent.setup();
let muted = true;
render(<TestComponent setMicrophoneMuted={(m) => (muted = m)} />);
await user.keyboard("[Space>]");
expect(muted).toBe(false);
await user.keyboard("[/Space]");
expect(muted).toBe(true);
});
test("spacebar prioritizes pressing a button", async () => {
const user = userEvent.setup();
const setMuted = vi.fn();
const onClick = vi.fn();
render(
<TestComponent setMicrophoneMuted={setMuted} onButtonClick={onClick} />,
);
await user.tab(); // Focus the button
await user.keyboard("[Space]");
expect(setMuted).not.toBeCalled();
expect(onClick).toBeCalled();
});
test("unmuting happens in place of the default action", async () => {
const user = userEvent.setup();
const defaultPrevented = vi.fn();
// In the real application, we mostly just want the spacebar shortcut to avoid
// scrolling the page. But to test that here in JSDOM, we need some kind of
// container element that can be interactive and receive focus / keydown
// events. <video> is kind of a weird choice, but it'll do the job.
render(
<video
tabIndex={0}
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
>
<TestComponent setMicrophoneMuted={() => {}} />
</video>,
);
await user.tab(); // Focus the <video>
await user.keyboard("[Space]");
expect(defaultPrevented).toBeCalledWith(true);
});

View File

@@ -1,493 +0,0 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TileDescriptor } from "../../src/state/CallViewModel";
import {
addItems,
column,
cycleTileSize,
fillGaps,
forEachCellInArea,
Grid,
SparseGrid,
resize,
row,
moveTile,
} from "../../src/video-grid/BigGrid";
/**
* 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) ?? ([] as string[]);
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<unknown>,
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";
for (let i = 0; i < g.cells.length; i++) {
if (i > 0 && i % g.columns == 0) result += "\n";
result += g.cells[i]?.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
mjnok`,
);
testFillGaps(
"fills a big gap with 1×1 tiles",
`
abcd
e f
g h
ijkl`,
`
abcd
ehkf
glji`,
);
testFillGaps(
"fills a big gap with a large tile",
`
aa
bc`,
`
aa
cb`,
);
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`,
);
testFillGaps(
"keeps a large tile from hanging off the bottom",
`
abcd
efgh
ii
ii`,
`
abcd
iigh
iief`,
);
testFillGaps(
"collapses large tiles trapped at the bottom",
`
abcd
e fg
hh
hh
ii
ii`,
`
abcd
hhfg
hhie`,
);
testFillGaps(
"gives up on pushing large tiles upwards when not possible",
`
aa
aa
bccd
eccf
ghij`,
`
aadf
aaji
bcch
eccg`,
);
function testCycleTileSize(
title: string,
tileId: string,
input: string,
output: string,
): void {
test(`cycleTileSize ${title}`, () => {
const grid = mkGrid(input);
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
expect(showGrid(cycleTileSize(grid, tile))).toBe(output);
});
}
testCycleTileSize(
"expands a tile to 2×2 in a 3 column layout",
"c",
`
abc
def
ghi`,
`
acc
dcc
gbe
ifh`,
);
testCycleTileSize(
"expands a tile to 3×3 in a 4 column layout",
"g",
`
abcd
efgh`,
`
acdh
bggg
fggg
e`,
);
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
ccm
cch
lik`,
);
testCycleTileSize(
"does nothing if the tile has no room to expand",
"c",
`
abb
cbb
dde
ddf`,
`
abb
cbb
dde
ddf`,
);
test("cycleTileSize is its own inverse", () => {
const input = `
abc
def
ghi
jk`;
const grid = mkGrid(input);
let gridAfter = grid;
const toggle = (tileId: string): void => {
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
gridAfter = cycleTileSize(gridAfter, tile);
};
// Toggle a series of tiles
toggle("j");
toggle("h");
toggle("a");
// Now do the same thing in reverse
toggle("a");
toggle("h");
toggle("j");
// The grid should be back to its original state
expect(showGrid(gridAfter)).toBe(input);
});
function testAddItems(
title: string,
items: TileDescriptor<unknown>[],
input: string,
output: string,
): void {
test(`addItems ${title}`, () => {
expect(showGrid(addItems(items, mkGrid(input) as SparseGrid) as Grid)).toBe(
output,
);
});
}
testAddItems(
"appends 1×1 tiles",
["e", "f"].map((i) => ({ id: i }) as unknown as TileDescriptor<unknown>),
`
aab
aac
d`,
`
aab
aac
def`,
);
testAddItems(
"places one tile near another on request",
[{ id: "g", placeNear: "b" } as unknown as TileDescriptor<unknown>],
`
abc
def`,
`
abc
g
def`,
);
testAddItems(
"places items with a large base size",
[{ id: "g", largeBaseSize: true } as unknown as TileDescriptor<unknown>],
`
abc
def`,
`
abc
ggf
gge
d`,
);
function testMoveTile(
title: string,
from: number,
to: number,
input: string,
output: string,
): void {
test(`moveTile ${title}`, () => {
expect(showGrid(moveTile(mkGrid(input), from, to))).toBe(output);
});
}
testMoveTile(
"refuses to move a tile too far to the left",
1,
-1,
`
abc`,
`
abc`,
);
testMoveTile(
"refuses to move a tile too far to the right",
1,
3,
`
abc`,
`
abc`,
);
testMoveTile(
"moves a large tile to an unoccupied space",
3,
1,
`
a b
ccd
cce`,
`
acc
bcc
d e`,
);
testMoveTile(
"refuses to move a large tile to an occupied space",
3,
1,
`
abb
ccd
cce`,
`
abb
ccd
cce`,
);
function testResize(
title: string,
columns: number,
input: string,
output: string,
): void {
test(`resize ${title}`, () => {
expect(showGrid(resize(mkGrid(input), columns))).toBe(output);
});
}
testResize(
"contracts the grid",
2,
`
abbb
cbbb
ddde
dddf
gh`,
`
af
bb
bb
dd
dd
ch
eg`,
);
testResize(
"expands the grid",
4,
`
af
bb
bb
ch
dd
dd
eg`,
`
afcd
bbbg
bbbe
h`,
);

View File

@@ -1,69 +0,0 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TileDescriptor } from "../../src/state/CallViewModel";
import { Tile, reorderTiles } from "../../src/video-grid/VideoGrid";
const alice: Tile<unknown> = {
key: "alice",
order: 0,
item: { local: false } as unknown as TileDescriptor<unknown>,
remove: false,
focused: false,
isPresenter: false,
isSpeaker: false,
hasVideo: true,
};
const bob: Tile<unknown> = {
key: "bob",
order: 1,
item: { local: false } as unknown as TileDescriptor<unknown>,
remove: false,
focused: false,
isPresenter: false,
isSpeaker: false,
hasVideo: false,
};
test("reorderTiles does not promote a non-speaker", () => {
const tiles = [{ ...alice }, { ...bob }];
reorderTiles(tiles, "spotlight", 1);
expect(tiles).toEqual([
expect.objectContaining({ key: "alice", order: 0 }),
expect.objectContaining({ key: "bob", order: 1 }),
]);
});
test("reorderTiles promotes a speaker into the visible area", () => {
const tiles = [{ ...alice }, { ...bob, isSpeaker: true }];
reorderTiles(tiles, "spotlight", 1);
expect(tiles).toEqual([
expect.objectContaining({ key: "alice", order: 1 }),
expect.objectContaining({ key: "bob", order: 0 }),
]);
});
test("reorderTiles keeps a promoted speaker in the visible area", () => {
const tiles = [
{ ...alice, order: 1 },
{ ...bob, isSpeaker: true, order: 0 },
];
reorderTiles(tiles, "spotlight", 1);
expect(tiles).toEqual([
expect.objectContaining({ key: "alice", order: 1 }),
expect.objectContaining({ key: "bob", order: 0 }),
]);
});

View File

@@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "es2016",
"target": "es2022",
"module": "es2020",
"jsx": "react-jsx",
"lib": ["es2020", "dom", "dom.iterable"],
"lib": ["es2022", "dom", "dom.iterable"],
// From Matrix-JS-SDK
"strict": true,
@@ -15,6 +15,8 @@
"moduleResolution": "bundler",
"declaration": true,
"resolveJsonModule": true,
// Workaround for https://github.com/microsoft/TypeScript/issues/55132
"useDefineForClassFields": false,
"paths": {
// These imports within @livekit/components-core and
// @livekit/components-react are broken under the "bundler" module

5354
yarn.lock

File diff suppressed because it is too large Load Diff