Compare commits

...

150 Commits

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

* tabs generic - remove as from typography

* typography using React.component function

* comma mistake

* ...

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

* linter

* quick fix

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

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

Related: https://github.com/vector-im/element-call/issues/421
2022-07-20 20:43:11 +01:00
Šimon Brandner
ce8ac0a81c Fix formatting
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-16 17:54:49 +02:00
Šimon Brandner
4d8e0d7b85 Merge remote-tracking branch 'upstream/main' into SimonBrandner/feat/audio-share 2022-07-16 17:54:24 +02:00
Šimon Brandner
e63b3d1b3e Add support for audio sharing
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-16 09:08:38 +02:00
Robin Townsend
689835cc17 Merge branch 'main' into matroska 2022-07-15 16:56:52 -04:00
Robin Townsend
3fc8fe505b Merge branch 'main' into matroska 2022-07-15 14:38:12 -04:00
Robin Townsend
fc26bef80a Make Vite work with matrix-widget-api 2022-07-15 11:24:38 -04:00
Šimon Brandner
d5375ca9ed Delint
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:22:13 +02:00
Šimon Brandner
eda8404144 Add UI for local volume control
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:18:56 +02:00
Timo K
e17a7cedb6 form_home 2022-07-14 19:20:52 +02:00
Šimon Brandner
4ad4cff23f Add handling for local volume control
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-14 16:18:10 +02:00
Robin Townsend
7fab4ca1ba Merge branch 'main' into matroska 2022-07-13 15:54:06 -04:00
Timo K
619e3c4852 form 2022-07-07 23:40:29 +02:00
Robin Townsend
d5e638c8f7 WIP 2022-06-27 17:41:07 -04:00
108 changed files with 3151 additions and 1306 deletions

View File

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

View File

@@ -38,7 +38,8 @@
"classnames": "^2.3.1",
"color-hash": "^2.0.1",
"events": "^3.3.0",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#984dd26a138411ef73903ff4e635f2752e0829f2",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#45e56f8cc36c459ed43e405be4206e5e66b3ad98",
"matrix-widget-api": "^1.0.0",
"mermaid": "^8.13.8",
"normalize.css": "^8.0.1",
"pako": "^2.0.4",

View File

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

View File

@@ -18,6 +18,7 @@ import React from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./auth/RegisterPage";
@@ -26,37 +27,49 @@ import { RoomRedirect } from "./room/RoomRedirect";
import { ClientProvider } from "./ClientContext";
import { usePageFocusStyle } from "./usePageFocusStyle";
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
import { InspectorContextProvider } from "./room/GroupCallInspector";
import { CrashView } from "./FullScreenView";
const SentryRoute = Sentry.withSentryRouting(Route);
export default function App({ history }) {
interface AppProps {
history: History;
}
export default function App({ history }: AppProps) {
usePageFocusStyle();
const errorPage = <CrashView />;
return (
<Router history={history}>
<ClientProvider>
<OverlayProvider>
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
</SentryRoute>
</Switch>
</OverlayProvider>
<InspectorContextProvider>
<Sentry.ErrorBoundary fallback={errorPage}>
<OverlayProvider>
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
</SentryRoute>
</Switch>
</OverlayProvider>
</Sentry.ErrorBoundary>
</InspectorContextProvider>
</ClientProvider>
</Router>
);

View File

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

View File

@@ -26,9 +26,15 @@ import React, {
import { useHistory } from "react-router-dom";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { logger } from "matrix-js-sdk/src/logger";
import { ErrorView } from "./FullScreenView";
import { initClient, defaultHomeserver } from "./matrix-utils";
import {
initClient,
initMatroskaClient,
defaultHomeserver,
CryptoStoreIntegrityError,
} from "./matrix-utils";
declare global {
interface Window {
@@ -62,6 +68,7 @@ interface ClientState {
changePassword: (password: string) => Promise<void>;
logout: () => void;
setClient: (client: MatrixClient, session: Session) => void;
error?: Error;
}
const ClientContext = createContext<ClientState>(null);
@@ -90,40 +97,78 @@ export const ClientProvider: FC<Props> = ({ children }) => {
});
useEffect(() => {
const restore = async (): Promise<
const init = async (): Promise<
Pick<ClientProviderState, "client" | "isPasswordlessUser">
> => {
try {
const session = loadSession();
const query = new URLSearchParams(window.location.search);
const widgetId = query.get("widgetId");
const parentUrl = query.get("parentUrl");
if (widgetId && parentUrl) {
// We're inside a widget, so let's engage *Matroska mode*
logger.log("Using a Matroska client");
return {
client: await initMatroskaClient(widgetId, parentUrl),
isPasswordlessUser: false,
};
} else {
// We're running as a standalone application
try {
const session = loadSession();
if (!session) return { client: undefined, isPasswordlessUser: false };
logger.log("Using a standalone client");
if (session) {
/* eslint-disable camelcase */
const { user_id, device_id, access_token, passwordlessUser } =
session;
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
true
);
try {
return {
client: await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
true
),
isPasswordlessUser: passwordlessUser,
};
} catch (err) {
if (err instanceof CryptoStoreIntegrityError) {
// We can't use this session anymore, so let's log it out
try {
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false // Don't need the crypto store just to log out
);
await client.logout(undefined, true);
} catch (err_) {
logger.warn(
"The previous session was lost, and we couldn't log it out, " +
"either"
);
}
}
throw err;
}
/* eslint-enable camelcase */
return { client, isPasswordlessUser: passwordlessUser };
} catch (err) {
clearSession();
throw err;
}
return { client: undefined, isPasswordlessUser: false };
} catch (err) {
console.error(err);
clearSession();
throw err;
}
};
restore()
init()
.then(({ client, isPasswordlessUser }) => {
setState({
client,
@@ -131,15 +176,18 @@ export const ClientProvider: FC<Props> = ({ children }) => {
isAuthenticated: Boolean(client),
isPasswordlessUser,
userName: client?.getUserIdLocalpart(),
error: undefined,
});
})
.catch(() => {
.catch((err) => {
logger.error(err);
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
error: undefined,
});
});
}, []);
@@ -169,6 +217,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
isAuthenticated: true,
isPasswordlessUser: false,
userName: client.getUserIdLocalpart(),
error: undefined,
});
},
[client]
@@ -189,6 +238,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
isAuthenticated: true,
isPasswordlessUser: session.passwordlessUser,
userName: newClient.getUserIdLocalpart(),
error: undefined,
});
} else {
clearSession();
@@ -199,6 +249,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
error: undefined,
});
}
},
@@ -257,6 +308,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
logout,
userName,
setClient,
error: undefined,
}),
[
loading,

View File

@@ -1,22 +1,49 @@
import React from "react";
import styles from "./Facepile.module.css";
import classNames from "classnames";
import { Avatar, sizes } from "./Avatar";
/*
Copyright 2022 New Vector Ltd
const overlapMap = {
xs: 2,
sm: 4,
md: 8,
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { HTMLAttributes } from "react";
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import styles from "./Facepile.module.css";
import { Avatar, Size, sizes } from "./Avatar";
const overlapMap: Partial<Record<Size, number>> = {
[Size.XS]: 2,
[Size.SM]: 4,
[Size.MD]: 8,
};
interface Props extends HTMLAttributes<HTMLDivElement> {
className: string;
client: MatrixClient;
participants: RoomMember[];
max?: number;
size?: Size;
}
export function Facepile({
className,
client,
participants,
max,
size,
max = 3,
size = Size.XS,
...rest
}) {
}: Props) {
const _size = sizes.get(size);
const _overlap = overlapMap[size];
@@ -56,8 +83,3 @@ export function Facepile({
</div>
);
}
Facepile.defaultProps = {
max: 3,
size: "xs",
};

View File

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

View File

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

130
src/FullScreenView.tsx Normal file
View File

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

View File

@@ -1,18 +1,26 @@
import classNames from "classnames";
import React, { useCallback, useRef } from "react";
import React, { HTMLAttributes, ReactNode, useCallback, useRef } from "react";
import { Link } from "react-router-dom";
import styles from "./Header.module.css";
import { ReactComponent as Logo } from "./icons/Logo.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
import { useButton } from "@react-aria/button";
import { Subtitle } from "./typography/Typography";
import { Avatar } from "./Avatar";
import { IncompatibleVersionModal } from "./IncompatibleVersionModal";
import { AriaButtonProps } from "@react-types/button";
import { Room } from "matrix-js-sdk/src/models/room";
import styles from "./Header.module.css";
import { useModalTriggerState } from "./Modal";
import { Button } from "./button";
import { ReactComponent as Logo } from "./icons/Logo.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { Subtitle } from "./typography/Typography";
import { Avatar, Size } from "./Avatar";
import { IncompatibleVersionModal } from "./IncompatibleVersionModal";
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
export function Header({ children, className, ...rest }) {
interface HeaderProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
className?: string;
}
export function Header({ children, className, ...rest }: HeaderProps) {
return (
<header className={classNames(styles.header, className)} {...rest}>
{children}
@@ -20,7 +28,18 @@ export function Header({ children, className, ...rest }) {
);
}
export function LeftNav({ children, className, hideMobile, ...rest }) {
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
className?: string;
hideMobile?: boolean;
}
export function LeftNav({
children,
className,
hideMobile,
...rest
}: LeftNavProps) {
return (
<div
className={classNames(
@@ -36,7 +55,18 @@ export function LeftNav({ children, className, hideMobile, ...rest }) {
);
}
export function RightNav({ children, className, hideMobile, ...rest }) {
interface RightNavProps extends HTMLAttributes<HTMLElement> {
children?: ReactNode;
className?: string;
hideMobile?: boolean;
}
export function RightNav({
children,
className,
hideMobile,
...rest
}: RightNavProps) {
return (
<div
className={classNames(
@@ -52,7 +82,11 @@ export function RightNav({ children, className, hideMobile, ...rest }) {
);
}
export function HeaderLogo({ className }) {
interface HeaderLogoProps {
className?: string;
}
export function HeaderLogo({ className }: HeaderLogoProps) {
return (
<Link className={classNames(styles.headerLogo, className)} to="/">
<Logo />
@@ -60,12 +94,17 @@ export function HeaderLogo({ className }) {
);
}
export function RoomHeaderInfo({ roomName, avatarUrl }) {
interface RoomHeaderInfo {
roomName: string;
avatarUrl: string;
}
export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) {
return (
<>
<div className={styles.roomAvatar}>
<Avatar
size="md"
size={Size.MD}
src={avatarUrl}
bgKey={roomName}
fallback={roomName.slice(0, 1).toUpperCase()}
@@ -77,12 +116,18 @@ export function RoomHeaderInfo({ roomName, avatarUrl }) {
);
}
interface RoomSetupHeaderInfoProps extends AriaButtonProps<"button"> {
roomName: string;
avatarUrl: string;
isEmbedded: boolean;
}
export function RoomSetupHeaderInfo({
roomName,
avatarUrl,
isEmbedded,
...rest
}) {
}: RoomSetupHeaderInfoProps) {
const ref = useRef();
const { buttonProps } = useButton(rest, ref);
@@ -102,7 +147,15 @@ export function RoomSetupHeaderInfo({
);
}
export function VersionMismatchWarning({ users, room }) {
interface VersionMismatchWarningProps {
users: Set<string>;
room: Room;
}
export function VersionMismatchWarning({
users,
room,
}: VersionMismatchWarningProps) {
const { modalState, modalProps } = useModalTriggerState();
const onDetailsClick = useCallback(() => {

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from "matrix-js-sdk";
import { Room } from "matrix-js-sdk/src/models/room";
import React from "react";
import { Modal, ModalContent } from "./Modal";

View File

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

6
src/IndexedDBWorker.ts Normal file
View File

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

View File

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

89
src/ListBox.tsx Normal file
View File

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

View File

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

View File

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

View File

@@ -1,29 +1,73 @@
import React, { useRef, useMemo } from "react";
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/* eslint-disable jsx-a11y/no-autofocus */
import React, { useRef, useMemo, ReactNode } from "react";
import {
useOverlay,
usePreventScroll,
useModal,
OverlayContainer,
OverlayProps,
} from "@react-aria/overlays";
import { useOverlayTriggerState } from "@react-stately/overlays";
import {
OverlayTriggerState,
useOverlayTriggerState,
} from "@react-stately/overlays";
import { useDialog } from "@react-aria/dialog";
import { FocusScope } from "@react-aria/focus";
import { useButton } from "@react-aria/button";
import { ButtonAria, useButton } from "@react-aria/button";
import classNames from "classnames";
import { AriaDialogProps } from "@react-types/dialog";
import { ReactComponent as CloseIcon } from "./icons/Close.svg";
import styles from "./Modal.module.css";
import classNames from "classnames";
export function Modal(props) {
const { title, children, className, mobileFullScreen } = props;
export interface ModalProps extends OverlayProps, AriaDialogProps {
title: string;
children: ReactNode;
className?: string;
mobileFullScreen?: boolean;
onClose?: () => void;
}
export function Modal({
title,
children,
className,
mobileFullScreen,
onClose,
...rest
}: ModalProps) {
const modalRef = useRef();
const { overlayProps, underlayProps } = useOverlay(props, modalRef);
const { overlayProps, underlayProps } = useOverlay(
{ ...rest, onClose },
modalRef
);
usePreventScroll();
const { modalProps } = useModal();
const { dialogProps, titleProps } = useDialog(props, modalRef);
const { dialogProps, titleProps } = useDialog(rest, modalRef);
const closeButtonRef = useRef();
const { buttonProps: closeButtonProps } = useButton({
onPress: () => props.onClose(),
});
const { buttonProps: closeButtonProps } = useButton(
{
onPress: () => onClose(),
},
closeButtonRef
);
return (
<OverlayContainer>
@@ -58,7 +102,16 @@ export function Modal(props) {
);
}
export function ModalContent({ children, className, ...rest }) {
interface ModalContentProps {
children: ReactNode;
className?: string;
}
export function ModalContent({
children,
className,
...rest
}: ModalContentProps) {
return (
<div className={classNames(styles.content, className)} {...rest}>
{children}
@@ -66,7 +119,10 @@ export function ModalContent({ children, className, ...rest }) {
);
}
export function useModalTriggerState() {
export function useModalTriggerState(): {
modalState: OverlayTriggerState;
modalProps: { isOpen: boolean; onClose: () => void };
} {
const modalState = useOverlayTriggerState({});
const modalProps = useMemo(
() => ({ isOpen: modalState.isOpen, onClose: modalState.close }),
@@ -75,7 +131,10 @@ export function useModalTriggerState() {
return { modalState, modalProps };
}
export function useToggleModalButton(modalState, ref) {
export function useToggleModalButton(
modalState: OverlayTriggerState,
ref: React.RefObject<HTMLButtonElement>
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
return useButton(
{
onPress: () => modalState.toggle(),
@@ -84,7 +143,10 @@ export function useToggleModalButton(modalState, ref) {
);
}
export function useOpenModalButton(modalState, ref) {
export function useOpenModalButton(
modalState: OverlayTriggerState,
ref: React.RefObject<HTMLButtonElement>
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
return useButton(
{
onPress: () => modalState.open(),
@@ -93,7 +155,10 @@ export function useOpenModalButton(modalState, ref) {
);
}
export function useCloseModalButton(modalState, ref) {
export function useCloseModalButton(
modalState: OverlayTriggerState,
ref: React.RefObject<HTMLButtonElement>
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
return useButton(
{
onPress: () => modalState.close(),
@@ -102,8 +167,12 @@ export function useCloseModalButton(modalState, ref) {
);
}
export function ModalTrigger({ children }) {
const { modalState, modalProps } = useModalState();
interface ModalTriggerProps {
children: ReactNode;
}
export function ModalTrigger({ children }: ModalTriggerProps) {
const { modalState, modalProps } = useModalTriggerState();
const buttonRef = useRef();
const { buttonProps } = useToggleModalButton(modalState, buttonRef);

View File

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

View File

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

View File

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

114
src/Tooltip.tsx Normal file
View File

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

View File

@@ -1,16 +1,26 @@
import React, { useMemo } from "react";
import { Item } from "@react-stately/collections";
import { useLocation } from "react-router-dom";
import { Button, LinkButton } from "./button";
import { PopoverMenuTrigger } from "./popover/PopoverMenu";
import { Menu } from "./Menu";
import { Tooltip, TooltipTrigger } from "./Tooltip";
import { Avatar } from "./Avatar";
import { TooltipTrigger } from "./Tooltip";
import { Avatar, Size } from "./Avatar";
import { ReactComponent as UserIcon } from "./icons/User.svg";
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
import styles from "./UserMenu.module.css";
import { useLocation } from "react-router-dom";
import { Body } from "./typography/Typography";
import styles from "./UserMenu.module.css";
interface UserMenuProps {
preventNavigation: boolean;
isAuthenticated: boolean;
isPasswordlessUser: boolean;
displayName: string;
avatarUrl: string;
onAction: (value: string) => void;
}
export function UserMenu({
preventNavigation,
@@ -19,7 +29,7 @@ export function UserMenu({
displayName,
avatarUrl,
onAction,
}) {
}: UserMenuProps) {
const location = useLocation();
const items = useMemo(() => {
@@ -62,11 +72,11 @@ export function UserMenu({
return (
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger placement="bottom left">
<TooltipTrigger tooltip={() => "Profile"} placement="bottom left">
<Button variant="icon" className={styles.userButton}>
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
<Avatar
size="sm"
size={Size.SM}
className={styles.avatar}
src={avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
@@ -75,12 +85,11 @@ export function UserMenu({
<UserIcon />
)}
</Button>
{() => "Profile"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="User menu" onAction={onAction}>
{items.map(({ key, icon: Icon, label }) => (
<Item key={key} textValue={label} className={styles.menuItem}>
<Item key={key} textValue={label}>
<Icon width={24} height={24} className={styles.menuIcon} />
<Body overflowEllipsis>{label}</Body>
</Item>

View File

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

View File

@@ -15,6 +15,7 @@ limitations under the License.
*/
import React, {
ChangeEvent,
FC,
FormEvent,
useCallback,
@@ -100,7 +101,7 @@ export const RegisterPage: FC = () => {
submit()
.then(() => {
if (location.state?.from) {
history.push(location.state.from);
history.push(location.state?.from);
} else {
history.push("/");
}
@@ -166,7 +167,9 @@ export const RegisterPage: FC = () => {
required
name="password"
type="password"
onChange={(e) => setPassword(e.target.value)}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setPassword(e.target.value)
}
value={password}
placeholder="Password"
label="Password"
@@ -177,7 +180,9 @@ export const RegisterPage: FC = () => {
required
type="password"
name="passwordConfirmation"
onChange={(e) => setPasswordConfirmation(e.target.value)}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setPasswordConfirmation(e.target.value)
}
value={passwordConfirmation}
placeholder="Confirm Password"
label="Confirm Password"

View File

@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef } from "react";
import React, { forwardRef, useCallback } from "react";
import { PressEvent } from "@react-types/shared";
import classNames from "classnames";
import { useButton } from "@react-aria/button";
@@ -29,7 +29,10 @@ import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
import { ReactComponent as Fullscreen } from "../icons/Fullscreen.svg";
import { ReactComponent as FullscreenExit } from "../icons/FullscreenExit.svg";
import { TooltipTrigger } from "../Tooltip";
import { VolumeIcon } from "./VolumeIcon";
export type ButtonVariant =
| "default"
@@ -73,6 +76,7 @@ interface Props {
children: Element[];
onPress: (e: PressEvent) => void;
onPressStart: (e: PressEvent) => void;
// TODO: add all props for <Button>
[index: string]: unknown;
}
export const Button = forwardRef<HTMLButtonElement, Props>(
@@ -135,14 +139,16 @@ export function MicButton({
...rest
}: {
muted: boolean;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<TooltipTrigger
tooltip={() => (muted ? "Unmute microphone" : "Mute microphone")}
>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <MuteMicIcon /> : <MicIcon />}
</Button>
{() => (muted ? "Unmute microphone" : "Mute microphone")}
</TooltipTrigger>
);
}
@@ -152,14 +158,16 @@ export function VideoButton({
...rest
}: {
muted: boolean;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<TooltipTrigger
tooltip={() => (muted ? "Turn on camera" : "Turn off camera")}
>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <DisableVideoIcon /> : <VideoIcon />}
</Button>
{() => (muted ? "Turn on camera" : "Turn off camera")}
</TooltipTrigger>
);
}
@@ -171,14 +179,16 @@ export function ScreenshareButton({
}: {
enabled: boolean;
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<TooltipTrigger
tooltip={() => (enabled ? "Stop sharing screen" : "Share screen")}
>
<Button variant="toolbarSecondary" {...rest} on={enabled}>
<ScreenshareIcon />
</Button>
{() => (enabled ? "Stop sharing screen" : "Share screen")}
</TooltipTrigger>
);
}
@@ -188,10 +198,11 @@ export function HangupButton({
...rest
}: {
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<TooltipTrigger tooltip={() => "Leave"}>
<Button
variant="toolbar"
className={classNames(styles.hangupButton, className)}
@@ -199,7 +210,6 @@ export function HangupButton({
>
<HangupIcon />
</Button>
{() => "Leave"}
</TooltipTrigger>
);
}
@@ -209,14 +219,14 @@ export function SettingsButton({
...rest
}: {
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<TooltipTrigger tooltip={() => "Settings"}>
<Button variant="toolbar" {...rest}>
<SettingsIcon />
</Button>
{() => "Settings"}
</TooltipTrigger>
);
}
@@ -226,14 +236,52 @@ export function InviteButton({
...rest
}: {
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<TooltipTrigger tooltip={() => "Invite"}>
<Button variant="toolbar" {...rest}>
<AddUserIcon />
</Button>
{() => "Invite"}
</TooltipTrigger>
);
}
interface AudioButtonProps extends Omit<Props, "variant"> {
/**
* A number between 0 and 1
*/
volume: number;
}
export function AudioButton({ volume, ...rest }: AudioButtonProps) {
return (
<TooltipTrigger tooltip={() => "Local volume"}>
<Button variant="icon" {...rest}>
<VolumeIcon volume={volume} />
</Button>
</TooltipTrigger>
);
}
interface FullscreenButtonProps extends Omit<Props, "variant"> {
fullscreen?: boolean;
}
export function FullscreenButton({
fullscreen,
...rest
}: FullscreenButtonProps) {
const getTooltip = useCallback(() => {
return fullscreen ? "Exit full screen" : "Full screen";
}, [fullscreen]);
return (
<TooltipTrigger tooltip={getTooltip}>
<Button variant="icon" {...rest}>
{fullscreen ? <FullscreenExit /> : <Fullscreen />}
</Button>
</TooltipTrigger>
);
}

View File

@@ -23,10 +23,10 @@ import { Button, ButtonVariant } from "./Button";
interface Props {
value: string;
children: JSX.Element;
className: string;
variant: ButtonVariant;
copiedMessage: string;
children?: JSX.Element | string;
className?: string;
variant?: ButtonVariant;
copiedMessage?: string;
}
export function CopyButton({
value,

View File

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

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

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

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

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

View File

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

View File

@@ -46,7 +46,7 @@ export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
{callType === CallType.Video ? "Video call" : "Walkie-talkie call"}
</Headline>
</Button>
{(props) => (
{(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label="Call type menu" onAction={setCallType}>
<Item key={CallType.Video} textValue="Video call">
<VideoIcon />

View File

@@ -15,6 +15,7 @@ limitations under the License.
*/
import React from "react";
import { useClient } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView";

View File

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

View File

@@ -14,7 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState, useCallback } from "react";
import React, {
useState,
useCallback,
FormEvent,
FormEventHandler,
} from "react";
import { useHistory } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useGroupCallRooms } from "./useGroupCallRooms";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
@@ -26,21 +34,28 @@ import { CallList } from "./CallList";
import { UserMenuContainer } from "../UserMenuContainer";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useHistory } from "react-router-dom";
import { Title } from "../typography/Typography";
import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
export function RegisteredView({ client }) {
interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
}
export function RegisteredView({ client, isPasswordlessUser }: Props) {
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [error, setError] = useState<Error>();
const history = useHistory();
const onSubmit = useCallback(
(e) => {
const { modalState, modalProps } = useModalTriggerState();
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(e: FormEvent) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("callName");
const data = new FormData(e.target as HTMLFormElement);
const roomNameData = data.get("callName");
const roomName = typeof roomNameData === "string" ? roomNameData : "";
const ptt = callType === CallType.Radio;
async function submit() {
@@ -64,17 +79,15 @@ export function RegisteredView({ client }) {
console.error(error);
setLoading(false);
setError(error);
reset();
}
});
},
[client, callType]
[client, history, modalState, callType]
);
const recentRooms = useGroupCallRooms(client);
const { modalState, modalProps } = useModalTriggerState();
const [existingRoomId, setExistingRoomId] = useState();
const [existingRoomId, setExistingRoomId] = useState<string>();
const onJoinExistingRoom = useCallback(() => {
history.push(`/${existingRoomId}`);
}, [history, existingRoomId]);

View File

@@ -14,45 +14,46 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react";
import React, { FC, useCallback, useState, FormEventHandler } from "react";
import { useHistory } from "react-router-dom";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useClient } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { UserMenuContainer } from "../UserMenuContainer";
import { useHistory } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useRecaptcha } from "../auth/useRecaptcha";
import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Body, Caption, Link } from "../typography/Typography";
import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName";
export function UnauthenticatedView() {
export const UnauthenticatedView: FC = () => {
const { setClient } = useClient();
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [error, setError] = useState<Error>();
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const { modalState, modalProps } = useModalTriggerState();
const [onFinished, setOnFinished] = useState();
const [onFinished, setOnFinished] = useState<() => void>();
const history = useHistory();
const onSubmit = useCallback(
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("callName");
const displayName = data.get("displayName");
const data = new FormData(e.target as HTMLFormElement);
const roomName = data.get("callName") as string;
const displayName = data.get("displayName") as string;
const ptt = callType === CallType.Radio;
async function submit() {
@@ -68,12 +69,12 @@ export function UnauthenticatedView() {
true
);
let roomIdOrAlias;
let roomIdOrAlias: string;
try {
[roomIdOrAlias] = await createRoom(client, roomName, ptt);
} catch (error) {
if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => () => {
setOnFinished(() => {
setClient(client, session);
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
const [, serverName] = client.getUserId().split(":");
@@ -100,7 +101,7 @@ export function UnauthenticatedView() {
reset();
});
},
[register, reset, execute, history, callType]
[register, reset, execute, history, callType, modalState, setClient]
);
const callNameLabel =
@@ -177,4 +178,4 @@ export function UnauthenticatedView() {
)}
</>
);
}
};

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

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

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

After

Width:  |  Height:  |  Size: 1.6 KiB

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

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

After

Width:  |  Height:  |  Size: 788 B

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

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

After

Width:  |  Height:  |  Size: 368 B

View File

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

After

Width:  |  Height:  |  Size: 613 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,9 +28,7 @@ import { Integrations } from "@sentry/tracing";
import "./index.css";
import App from "./App";
import { ErrorView } from "./FullScreenView";
import { init as initRageshake } from "./settings/rageshake";
import { InspectorContextProvider } from "./room/GroupCallInspector";
initRageshake();
@@ -86,6 +84,10 @@ if (import.meta.env.VITE_CUSTOM_THEME) {
"--background",
import.meta.env.VITE_THEME_BACKGROUND as string
);
style.setProperty(
"--background-85",
import.meta.env.VITE_THEME_BACKGROUND_85 as string
);
}
const history = createBrowserHistory();
@@ -104,11 +106,7 @@ Sentry.init({
ReactDOM.render(
<React.StrictMode>
<Sentry.ErrorBoundary fallback={ErrorView}>
<InspectorContextProvider>
<App history={history} />
</InspectorContextProvider>
</Sentry.ErrorBoundary>
<App history={history} />
</React.StrictMode>,
document.getElementById("root")
);

View File

@@ -5,14 +5,26 @@ import { MemoryStore } from "matrix-js-sdk/src/store/memory";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import {
createClient,
createRoomWidgetClient,
MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { ICreateClientOpts } from "matrix-js-sdk/src/matrix";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { WidgetApi } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/webrtc/groupCall";
import type { Room } from "matrix-js-sdk/src/models/room";
import IndexedDBWorker from "./IndexedDBWorker?worker";
import { getRoomParams } from "./room/useRoomParams";
export const defaultHomeserver =
(import.meta.env.VITE_DEFAULT_HOMESERVER as string) ??
@@ -53,7 +65,74 @@ function waitForSync(client: MatrixClient) {
}
/**
* Initialises and returns a new Matrix Client
* Initialises and returns a new widget-API-based Matrix Client.
* @param widgetId The ID of the widget that the app is running inside.
* @param parentUrl The URL of the parent client.
* @returns The MatrixClient instance
*/
export async function initMatroskaClient(
widgetId: string,
parentUrl: string
): Promise<MatrixClient> {
// In this mode, we use a special client which routes all requests through
// the host application via the widget API
const { roomId, userId, deviceId } = getRoomParams();
if (!roomId) throw new Error("Room ID must be supplied");
if (!userId) throw new Error("User ID must be supplied");
if (!deviceId) throw new Error("Device ID must be supplied");
// These are all the event types the app uses
const sendState = [
{ eventType: EventType.GroupCallPrefix },
{ eventType: EventType.GroupCallMemberPrefix, stateKey: userId },
];
const receiveState = [
{ eventType: EventType.RoomMember },
{ eventType: EventType.GroupCallPrefix },
{ eventType: EventType.GroupCallMemberPrefix },
];
const sendRecvToDevice = [
EventType.CallInvite,
EventType.CallCandidates,
EventType.CallAnswer,
EventType.CallHangup,
EventType.CallReject,
EventType.CallSelectAnswer,
EventType.CallNegotiate,
EventType.CallSDPStreamMetadataChanged,
EventType.CallSDPStreamMetadataChangedPrefix,
EventType.CallReplaces,
"org.matrix.call_duplicate_session",
];
// Since all data should be coming from the host application, there's no
// need to persist anything, and therefore we can use the default stores
// We don't even need to set up crypto
const client = createRoomWidgetClient(
new WidgetApi(widgetId, new URL(parentUrl).origin),
{
sendState,
receiveState,
sendToDevice: sendRecvToDevice,
receiveToDevice: sendRecvToDevice,
turnServers: true,
},
roomId,
{
baseUrl: "",
userId,
deviceId,
timelineSupport: true,
}
);
await client.startClient();
return client;
}
/**
* Initialises and returns a new standalone Matrix Client.
* If true is passed for the 'restore' parameter, a check will be made
* to ensure that corresponding crypto data is stored and recovered.
* If the check fails, CryptoStoreIntegrityError will be thrown.
@@ -127,16 +206,13 @@ export async function initClient(
storeOpts.cryptoStore = new MemoryCryptoStore();
}
// XXX: we read from the URL search params in RoomPage too:
// XXX: we read from the room params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const params = new URLSearchParams(window.location.search);
// disable e2e only if enableE2e=false is given
const enableE2e = params.get("enableE2e") !== "false";
if (!enableE2e) {
const { e2eEnabled } = getRoomParams();
if (!e2eEnabled) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
@@ -144,10 +220,10 @@ export async function initClient(
...storeOpts,
...clientOptions,
useAuthorizationHeader: true,
// Use a relatively low timeout for API calls: this is a realtime application
// Use a relatively low timeout for API calls: this is a realtime app
// so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000,
useE2eForGroupCall: enableE2e,
useE2eForGroupCall: e2eEnabled,
});
try {
@@ -219,9 +295,10 @@ export function isLocalRoomId(roomId: string): boolean {
export async function createRoom(
client: MatrixClient,
name: string
name: string,
ptt: boolean
): Promise<[string, string]> {
const result = await client.createRoom({
const createPromise = client.createRoom({
visibility: Visibility.Private,
preset: Preset.PublicChat,
name,
@@ -251,20 +328,50 @@ export async function createRoom(
},
});
// Wait for the room to arrive
await new Promise<void>((resolve, reject) => {
const onRoom = async (room: Room) => {
if (room.roomId === (await createPromise).room_id) {
resolve();
cleanUp();
}
};
createPromise.catch((e) => {
reject(e);
cleanUp();
});
const cleanUp = () => {
client.off(ClientEvent.Room, onRoom);
};
client.on(ClientEvent.Room, onRoom);
});
const result = await createPromise;
console.log(`Creating ${ptt ? "PTT" : "video"} group call room`);
await client.createGroupCall(
result.room_id,
ptt ? GroupCallType.Voice : GroupCallType.Video,
ptt,
GroupCallIntent.Room
);
return [fullAliasFromRoomName(name, client), result.room_id];
}
export function getRoomUrl(roomId: string): string {
if (roomId.startsWith("#")) {
const [localPart, host] = roomId.replace("#", "").split(":");
export function getRoomUrl(roomIdOrAlias: string): string {
if (roomIdOrAlias.startsWith("#")) {
const [localPart, host] = roomIdOrAlias.replace("#", "").split(":");
if (host !== defaultHomeserverHost) {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
return `${window.location.protocol}//${window.location.host}/room/${roomIdOrAlias}`;
} else {
return `${window.location.protocol}//${window.location.host}/${localPart}`;
}
} else {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
return `${window.location.protocol}//${window.location.host}/room/#?roomId=${roomIdOrAlias}`;
}
}

View File

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

View File

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

View File

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

View File

@@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState } from "react";
import { MatrixClient } from "matrix-js-sdk";
import React, { ChangeEvent, useCallback, useEffect, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Button } from "../button";
import { useProfile } from "./useProfile";
@@ -26,7 +26,7 @@ import styles from "./ProfileModal.module.css";
interface Props {
client: MatrixClient;
onClose: () => {};
onClose: () => void;
[rest: string]: unknown;
}
export function ProfileModal({ client, ...rest }: Props) {
@@ -47,7 +47,7 @@ export function ProfileModal({ client, ...rest }: Props) {
}, []);
const onChangeDisplayName = useCallback(
(e) => {
(e: ChangeEvent<HTMLInputElement>) => {
setDisplayName(e.target.value);
},
[setDisplayName]

View File

@@ -15,12 +15,24 @@ limitations under the License.
*/
import React from "react";
import styles from "./AudioPreview.module.css";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections";
import styles from "./AudioPreview.module.css";
import { SelectInput } from "../input/SelectInput";
import { Body } from "../typography/Typography";
interface Props {
state: GroupCallState;
roomName: string;
audioInput: string;
audioInputs: MediaDeviceInfo[];
setAudioInput: (deviceId: string) => void;
audioOutput: string;
audioOutputs: MediaDeviceInfo[];
setAudioOutput: (deviceId: string) => void;
}
export function AudioPreview({
state,
roomName,
@@ -30,7 +42,7 @@ export function AudioPreview({
audioOutput,
audioOutputs,
setAudioOutput,
}) {
}: Props) {
return (
<>
<h1>{`${roomName} - Walkie-talkie call`}</h1>

View File

@@ -15,13 +15,15 @@ limitations under the License.
*/
import React from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import styles from "./CallEndedView.module.css";
import { LinkButton } from "../button";
import { useProfile } from "../profile/useProfile";
import { Subtitle, Body, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
export function CallEndedView({ client }) {
export function CallEndedView({ client }: { client: MatrixClient }) {
const { displayName } = useProfile(client);
return (

View File

@@ -15,6 +15,8 @@ limitations under the License.
*/
import React, { useCallback, useEffect } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { Modal, ModalContent } from "../Modal";
import { Button } from "../button";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
@@ -23,9 +25,14 @@ import {
useRageshakeRequest,
} from "../settings/submit-rageshake";
import { Body } from "../typography/Typography";
import { randomString } from "matrix-js-sdk/src/randomstring";
export function FeedbackModal({ inCall, roomId, ...rest }) {
interface Props {
inCall: boolean;
roomId: string;
onClose?: () => void;
// TODO: add all props for for <Modal>
[index: string]: unknown;
}
export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const sendRageshakeRequest = useRageshakeRequest();
@@ -33,8 +40,10 @@ export function FeedbackModal({ inCall, roomId, ...rest }) {
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const description = data.get("description");
const sendLogs = data.get("sendLogs");
const descriptionData = data.get("description");
const description =
typeof descriptionData === "string" ? descriptionData : "";
const sendLogs = Boolean(data.get("sendLogs"));
const rageshakeRequestId = randomString(16);
submitRageshake({
@@ -53,12 +62,12 @@ export function FeedbackModal({ inCall, roomId, ...rest }) {
useEffect(() => {
if (sent) {
rest.onClose();
onClose();
}
}, [sent, rest.onClose]);
}, [sent, onClose]);
return (
<Modal title="Submit Feedback" isDismissable {...rest}>
<Modal title="Submit Feedback" isDismissable onClose={onClose} {...rest}>
<ModalContent>
<Body>Having trouble? Help us fix it.</Body>
<form onSubmit={onSubmitFeedback}>

View File

@@ -15,6 +15,8 @@ limitations under the License.
*/
import React from "react";
import { Item } from "@react-stately/collections";
import { Button } from "../button";
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { ReactComponent as SpotlightIcon } from "../icons/Spotlight.svg";
@@ -22,19 +24,22 @@ import { ReactComponent as FreedomIcon } from "../icons/Freedom.svg";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import menuStyles from "../Menu.module.css";
import { Menu } from "../Menu";
import { Item } from "@react-stately/collections";
import { Tooltip, TooltipTrigger } from "../Tooltip";
import { TooltipTrigger } from "../Tooltip";
export function GridLayoutMenu({ layout, setLayout }) {
export type Layout = "freedom" | "spotlight";
interface Props {
layout: Layout;
setLayout: (layout: Layout) => void;
}
export function GridLayoutMenu({ layout, setLayout }: Props) {
return (
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger>
<TooltipTrigger tooltip={() => "Layout Type"}>
<Button variant="icon">
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
</Button>
{() => "Layout Type"}
</TooltipTrigger>
{(props) => (
{(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label="Grid layout menu" onAction={setLayout}>
<Item key="freedom" textValue="Freedom">
<FreedomIcon />

View File

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

View File

@@ -14,21 +14,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ReactNode } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { usePageTitle } from "../usePageTitle";
interface Props {
client: MatrixClient;
roomIdOrAlias: string;
viaServers: string[];
children: (groupCall: GroupCall) => ReactNode;
createPtt: boolean;
}
export function GroupCallLoader({
client,
roomId,
roomIdOrAlias,
viaServers,
createPtt,
children,
}) {
createPtt,
}: Props): JSX.Element {
const { loading, error, groupCall } = useLoadGroupCall(
client,
roomId,
roomIdOrAlias,
viaServers,
createPtt
);
@@ -47,5 +58,5 @@ export function GroupCallLoader({
return <ErrorView error={error} />;
}
return children(groupCall);
return <>{children(groupCall)}</>;
}

View File

@@ -16,7 +16,9 @@ limitations under the License.
import React, { useCallback, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useGroupCall } from "./useGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView";
@@ -26,14 +28,25 @@ import { CallEndedView } from "./CallEndedView";
import { useRoomAvatar } from "./useRoomAvatar";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation";
declare global {
interface Window {
groupCall: GroupCall;
}
}
interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
isEmbedded: boolean;
roomIdOrAlias: string;
groupCall: GroupCall;
}
export function GroupCallView({
client,
isPasswordlessUser,
isEmbedded,
roomId,
roomIdOrAlias,
groupCall,
}) {
}: Props) {
const {
state,
error,
@@ -52,7 +65,6 @@ export function GroupCallView({
isScreensharing,
localScreenshareFeed,
screenshareFeeds,
hasLocalParticipant,
participants,
unencryptedEventsFromUsers,
} = useGroupCall(groupCall);
@@ -80,7 +92,7 @@ export function GroupCallView({
if (!isPasswordlessUser) {
history.push("/");
}
}, [leave, history]);
}, [leave, isPasswordlessUser, history]);
if (error) {
return <ErrorView error={error} />;
@@ -89,7 +101,7 @@ export function GroupCallView({
return (
<PTTCallView
client={client}
roomId={roomId}
roomIdOrAlias={roomIdOrAlias}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
groupCall={groupCall}
@@ -117,7 +129,7 @@ export function GroupCallView({
isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
roomId={roomId}
roomIdOrAlias={roomIdOrAlias}
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
/>
);
@@ -142,7 +154,6 @@ export function GroupCallView({
<LobbyView
client={client}
groupCall={groupCall}
hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
state={state}
@@ -153,7 +164,7 @@ export function GroupCallView({
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
roomId={roomId}
roomIdOrAlias={roomIdOrAlias}
isEmbedded={isEmbedded}
/>
);

View File

@@ -54,6 +54,12 @@ limitations under the License.
margin-right: 0px;
}
.footerFullscreen {
position: absolute;
width: 100%;
bottom: 0;
}
.avatar {
position: absolute;
top: 50%;

View File

@@ -15,6 +15,13 @@ limitations under the License.
*/
import React, { useCallback, useMemo, useRef } from "react";
import { usePreventScroll } from "@react-aria/overlays";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import classNames from "classnames";
import styles from "./InCallView.module.css";
import {
HangupButton,
@@ -38,11 +45,13 @@ import { Avatar } from "../Avatar";
import { UserMenuContainer } from "../UserMenuContainer";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { usePreventScroll } from "@react-aria/overlays";
import { useMediaHandler } from "../settings/useMediaHandler";
import { useShowInspector } from "../settings/useSetting";
import { useShowInspector, useSpatialAudio } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { useAudioContext } from "../video-grid/useMediaStream";
import { useFullscreen } from "../video-grid/useFullscreen";
import { AudioContainer } from "../video-grid/AudioContainer";
import { useAudioOutputDevice } from "../video-grid/useAudioOutputDevice";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -50,6 +59,34 @@ const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// For now we can disable screensharing in Safari.
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
interface Props {
client: MatrixClient;
groupCall: GroupCall;
roomName: string;
avatarUrl: string;
microphoneMuted: boolean;
localVideoMuted: boolean;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
toggleScreensharing: () => void;
userMediaFeeds: CallFeed[];
activeSpeaker: string;
onLeave: () => void;
isScreensharing: boolean;
screenshareFeeds: CallFeed[];
localScreenshareFeed: CallFeed;
roomIdOrAlias: string;
unencryptedEventsFromUsers: Set<string>;
}
export interface Participant {
id: string;
focused: boolean;
presenter: boolean;
callFeed?: CallFeed;
isLocal?: boolean;
}
export function InCallView({
client,
groupCall,
@@ -65,11 +102,16 @@ export function InCallView({
toggleScreensharing,
isScreensharing,
screenshareFeeds,
roomId,
localScreenshareFeed,
roomIdOrAlias,
unencryptedEventsFromUsers,
}) {
}: Props) {
usePreventScroll();
const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0);
const elementRef = useRef<HTMLDivElement>();
const { layout, setLayout } = useVideoGridLayout(screenshareFeeds.length > 0);
const { toggleFullscreen, fullscreenParticipant } = useFullscreen(elementRef);
const [spatialAudio] = useSpatialAudio();
const [audioContext, audioDestination, audioRef] = useAudioContext();
const { audioOutput } = useMediaHandler();
@@ -78,8 +120,10 @@ export function InCallView({
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
useAudioOutputDevice(audioRef, audioOutput);
const items = useMemo(() => {
const participants = [];
const participants: Participant[] = [];
for (const callFeed of userMediaFeeds) {
participants.push({
@@ -90,6 +134,7 @@ export function InCallView({
? callFeed.userId === activeSpeaker
: false,
isLocal: callFeed.isLocal(),
presenter: false,
});
}
@@ -107,6 +152,7 @@ export function InCallView({
callFeed,
focused: true,
isLocal: callFeed.isLocal(),
presenter: false,
});
}
@@ -114,7 +160,7 @@ export function InCallView({
}, [userMediaFeeds, activeSpeaker, screenshareFeeds, layout]);
const renderAvatar = useCallback(
(roomMember, width, height) => {
(roomMember: RoomMember, width: number, height: number) => {
const avatarUrl = roomMember.user?.avatarUrl;
const size = Math.round(Math.min(width, height) / 2);
@@ -128,69 +174,113 @@ export function InCallView({
/>
);
},
[client]
[]
);
const renderContent = useCallback((): JSX.Element => {
if (items.length === 0) {
return (
<div className={styles.centerMessage}>
<p>Waiting for other participants...</p>
</div>
);
}
if (fullscreenParticipant) {
return (
<VideoTileContainer
key={fullscreenParticipant.id}
item={fullscreenParticipant}
getAvatar={renderAvatar}
audioContext={audioContext}
audioDestination={audioDestination}
disableSpeakingIndicator={true}
isFullscreen={!!fullscreenParticipant}
onFullscreen={toggleFullscreen}
/>
);
}
return (
<VideoGrid items={items} layout={layout} disableAnimations={isSafari}>
{({ item, ...rest }: { item: Participant; [x: string]: unknown }) => (
<VideoTileContainer
key={item.id}
item={item}
getAvatar={renderAvatar}
audioContext={audioContext}
audioDestination={audioDestination}
disableSpeakingIndicator={items.length < 3}
isFullscreen={!!fullscreenParticipant}
onFullscreen={toggleFullscreen}
{...rest}
/>
)}
</VideoGrid>
);
}, [
fullscreenParticipant,
items,
audioContext,
audioDestination,
layout,
renderAvatar,
toggleFullscreen,
]);
const {
modalState: rageshakeRequestModalState,
modalProps: rageshakeRequestModalProps,
} = useRageshakeRequestModal(groupCall.room.roomId);
const footerClassNames = classNames(styles.footer, {
[styles.footerFullscreen]: fullscreenParticipant,
});
return (
<div className={styles.inRoom}>
<div className={styles.inRoom} ref={elementRef}>
<audio ref={audioRef} />
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
<VersionMismatchWarning
users={unencryptedEventsFromUsers}
room={groupCall.room}
/>
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
<UserMenuContainer preventNavigation />
</RightNav>
</Header>
{items.length === 0 ? (
<div className={styles.centerMessage}>
<p>Waiting for other participants...</p>
</div>
) : (
<VideoGrid items={items} layout={layout} disableAnimations={isSafari}>
{({ item, ...rest }) => (
<VideoTileContainer
key={item.id}
item={item}
getAvatar={renderAvatar}
showName={items.length > 2 || item.focused}
audioOutputDevice={audioOutput}
audioContext={audioContext}
audioDestination={audioDestination}
disableSpeakingIndicator={items.length < 3}
{...rest}
/>
)}
</VideoGrid>
{(!spatialAudio || fullscreenParticipant) && (
<AudioContainer
items={items}
audioContext={audioContext}
audioDestination={audioDestination}
/>
)}
<div className={styles.footer}>
{!fullscreenParticipant && (
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
<VersionMismatchWarning
users={unencryptedEventsFromUsers}
room={groupCall.room}
/>
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
<UserMenuContainer preventNavigation />
</RightNav>
</Header>
)}
{renderContent()}
<div className={footerClassNames}>
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
{canScreenshare && !isSafari && (
{canScreenshare && !isSafari && !fullscreenParticipant && (
<ScreenshareButton
enabled={isScreensharing}
onPress={toggleScreensharing}
/>
)}
<OverflowMenu
inCall
roomId={roomId}
client={client}
groupCall={groupCall}
showInvite={true}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
{!fullscreenParticipant && (
<OverflowMenu
inCall
roomIdOrAlias={roomIdOrAlias}
groupCall={groupCall}
showInvite={true}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
)}
<HangupButton onPress={onLeave} />
</div>
<GroupCallInspector
@@ -201,7 +291,7 @@ export function InCallView({
{rageshakeRequestModalState.isOpen && (
<RageshakeRequestModal
{...rageshakeRequestModalProps}
roomId={roomId}
roomIdOrAlias={roomIdOrAlias}
/>
)}
</div>

View File

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

View File

@@ -15,10 +15,14 @@ limitations under the License.
*/
import React, { useEffect, useRef } from "react";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { PressEvent } from "@react-types/shared";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import styles from "./LobbyView.module.css";
import { Button, CopyButton } from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { useCallFeed } from "../video-grid/useCallFeed";
import { getRoomUrl } from "../matrix-utils";
import { UserMenuContainer } from "../UserMenuContainer";
@@ -28,6 +32,22 @@ import { useMediaHandler } from "../settings/useMediaHandler";
import { VideoPreview } from "./VideoPreview";
import { AudioPreview } from "./AudioPreview";
interface Props {
client: MatrixClient;
groupCall: GroupCall;
roomName: string;
avatarUrl: string;
state: GroupCallState;
onInitLocalCallFeed: () => void;
onEnter: (e: PressEvent) => void;
localCallFeed: CallFeed;
microphoneMuted: boolean;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
localVideoMuted: boolean;
roomIdOrAlias: string;
isEmbedded: boolean;
}
export function LobbyView({
client,
groupCall,
@@ -41,9 +61,9 @@ export function LobbyView({
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
roomId,
roomIdOrAlias,
isEmbedded,
}) {
}: Props) {
const { stream } = useCallFeed(localCallFeed);
const {
audioInput,
@@ -60,7 +80,7 @@ export function LobbyView({
useLocationNavigation(state === GroupCallState.InitializingLocalCallFeed);
const joinCallButtonRef = useRef();
const joinCallButtonRef = useRef<HTMLButtonElement>();
useEffect(() => {
if (state === GroupCallState.LocalCallFeedInitialized) {
@@ -95,7 +115,7 @@ export function LobbyView({
<VideoPreview
state={state}
client={client}
roomId={roomId}
roomIdOrAlias={roomIdOrAlias}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
@@ -116,7 +136,7 @@ export function LobbyView({
<Body>Or</Body>
<CopyButton
variant="secondaryCopy"
value={getRoomUrl(roomId)}
value={getRoomUrl(roomIdOrAlias)}
className={styles.copyButton}
copiedMessage="Call link copied"
>

View File

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

View File

@@ -17,7 +17,9 @@ limitations under the License.
import React, { useEffect } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCall, MatrixClient, RoomMember } from "matrix-js-sdk";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useDelayedState } from "../useDelayedState";
@@ -38,6 +40,7 @@ import { usePTTSounds } from "../sound/usePttSounds";
import { PTTClips } from "../sound/PTTClips";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
import { Size } from "../Avatar";
function getPromptText(
networkWaiting: boolean,
@@ -86,7 +89,7 @@ function getPromptText(
interface Props {
client: MatrixClient;
roomId: string;
roomIdOrAlias: string;
roomName: string;
avatarUrl: string;
groupCall: GroupCall;
@@ -98,7 +101,7 @@ interface Props {
export const PTTCallView: React.FC<Props> = ({
client,
roomId,
roomIdOrAlias,
roomName,
avatarUrl,
groupCall,
@@ -112,7 +115,7 @@ export const PTTCallView: React.FC<Props> = ({
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
const facepileSize = bounds.width < 800 ? "sm" : "md";
const facepileSize = bounds.width < 800 ? Size.SM : Size.MD;
const showControls = bounds.height > 500;
const pttButtonSize = 232;
@@ -204,8 +207,7 @@ export const PTTCallView: React.FC<Props> = ({
<div className={styles.footer}>
<OverflowMenu
inCall
roomId={roomId}
client={client}
roomIdOrAlias={roomIdOrAlias}
groupCall={groupCall}
showInvite={false}
feedbackModalState={feedbackModalState}
@@ -282,7 +284,7 @@ export const PTTCallView: React.FC<Props> = ({
</div>
{inviteModalState.isOpen && showControls && (
<InviteModal roomId={roomId} {...inviteModalProps} />
<InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
)}
</div>
);

View File

@@ -14,12 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import React from "react";
import { useCallFeed } from "../video-grid/useCallFeed";
import { useMediaStream } from "../video-grid/useMediaStream";
import styles from "./PTTFeed.module.css";
export function PTTFeed({ callFeed, audioOutputDevice }) {
export function PTTFeed({
callFeed,
audioOutputDevice,
}: {
callFeed: CallFeed;
audioOutputDevice: string;
}) {
const { isLocal, stream } = useCallFeed(callFeed);
const mediaRef = useMediaStream(stream, audioOutputDevice, isLocal);
return <audio ref={mediaRef} className={styles.audioFeed} playsInline />;

View File

@@ -14,21 +14,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect } from "react";
import { Modal, ModalContent } from "../Modal";
import React, { FC, useEffect } from "react";
import { Modal, ModalContent, ModalProps } from "../Modal";
import { Button } from "../button";
import { FieldRow, ErrorMessage } from "../input/Input";
import { useSubmitRageshake } from "../settings/submit-rageshake";
import { Body } from "../typography/Typography";
export function RageshakeRequestModal({ rageshakeRequestId, roomId, ...rest }) {
interface Props extends Omit<ModalProps, "title" | "children"> {
rageshakeRequestId: string;
roomIdOrAlias: string;
onClose: () => void;
}
export const RageshakeRequestModal: FC<Props> = ({
rageshakeRequestId,
roomIdOrAlias,
...rest
}) => {
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
useEffect(() => {
if (sent) {
rest.onClose();
}
}, [sent, rest.onClose]);
}, [sent, rest]);
return (
<Modal title="Debug Log Request" isDismissable {...rest}>
@@ -43,7 +54,7 @@ export function RageshakeRequestModal({ rageshakeRequestId, roomId, ...rest }) {
submitRageshake({
sendLogs: true,
rageshakeRequestId,
roomId,
roomId: roomIdOrAlias, // Possibly not a room ID, but oh well
})
}
disabled={sending}
@@ -59,4 +70,4 @@ export function RageshakeRequestModal({ rageshakeRequestId, roomId, ...rest }) {
</ModalContent>
</Modal>
);
}
};

View File

@@ -15,11 +15,12 @@ limitations under the License.
*/
import React, { useCallback, useState } from "react";
import { useLocation } from "react-router-dom";
import styles from "./RoomAuthView.module.css";
import { Button } from "../button";
import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { useLocation } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Form } from "../form/Form";
import { UserMenuContainer } from "../UserMenuContainer";
@@ -27,7 +28,7 @@ import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser
export function RoomAuthView() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [error, setError] = useState<Error>();
const { registerPasswordlessUser, recaptchaId, privacyPolicyUrl } =
useRegisterPasswordlessUser();
@@ -36,7 +37,9 @@ export function RoomAuthView() {
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const displayName = data.get("displayName");
const dataForDisplayName = data.get("displayName");
const displayName =
typeof dataForDisplayName === "string" ? dataForDisplayName : "";
registerPasswordlessUser(displayName).catch((error) => {
console.error("Failed to register passwordless user", e);

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021 New Vector Ltd
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,34 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useMemo, useState } from "react";
import { useLocation, useParams } from "react-router-dom";
import React, { FC, useEffect, useState } from "react";
import { useClient } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView";
import { GroupCallLoader } from "./GroupCallLoader";
import { GroupCallView } from "./GroupCallView";
import { useRoomParams } from "./useRoomParams";
import { MediaHandlerProvider } from "../settings/useMediaHandler";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
export function RoomPage() {
export const RoomPage: FC = () => {
const { loading, isAuthenticated, error, client, isPasswordlessUser } =
useClient();
const { roomId: maybeRoomId } = useParams();
const { hash, search } = useLocation();
const [viaServers, isEmbedded, isPtt, displayName] = useMemo(() => {
const params = new URLSearchParams(search);
return [
params.getAll("via"),
params.has("embed"),
params.get("ptt") === "true",
params.get("displayName"),
];
}, [search]);
const roomId = (maybeRoomId || hash || "").toLowerCase();
const { registerPasswordlessUser, recaptchaId } =
useRegisterPasswordlessUser();
const { roomAlias, roomId, viaServers, isEmbedded, isPtt, displayName } =
useRoomParams();
const roomIdOrAlias = roomId ?? roomAlias;
if (!roomIdOrAlias) throw new Error("No room specified");
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false);
useEffect(() => {
@@ -76,14 +69,14 @@ export function RoomPage() {
<MediaHandlerProvider client={client}>
<GroupCallLoader
client={client}
roomId={roomId}
roomIdOrAlias={roomIdOrAlias}
viaServers={viaServers}
createPtt={isPtt}
>
{(groupCall) => (
<GroupCallView
client={client}
roomId={roomId}
roomIdOrAlias={roomIdOrAlias}
groupCall={groupCall}
isPasswordlessUser={isPasswordlessUser}
isEmbedded={isEmbedded}
@@ -92,4 +85,4 @@ export function RoomPage() {
</GroupCallLoader>
</MediaHandlerProvider>
);
}
};

View File

@@ -16,6 +16,7 @@ limitations under the License.
import React, { useEffect } from "react";
import { useLocation, useHistory } from "react-router-dom";
import { defaultHomeserverHost } from "../matrix-utils";
import { LoadingView } from "../FullScreenView";

View File

@@ -16,11 +16,11 @@ limitations under the License.
import React, { useEffect, useState } from "react";
function leftPad(value) {
return value < 10 ? "0" + value : value;
function leftPad(value: number): string {
return value < 10 ? "0" + value : "" + value;
}
function formatTime(msElapsed) {
function formatTime(msElapsed: number): string {
const secondsElapsed = msElapsed / 1000;
const hours = Math.floor(secondsElapsed / 3600);
const minutes = Math.floor(secondsElapsed / 60) - hours * 60;
@@ -28,15 +28,15 @@ function formatTime(msElapsed) {
return `${leftPad(hours)}:${leftPad(minutes)}:${leftPad(seconds)}`;
}
export function Timer({ value }) {
const [timestamp, setTimestamp] = useState();
export function Timer({ value }: { value: string }) {
const [timestamp, setTimestamp] = useState<string>();
useEffect(() => {
const startTimeMs = performance.now();
let animationFrame;
let animationFrame: number;
function onUpdate(curTimeMs) {
function onUpdate(curTimeMs: number) {
const msElapsed = curTimeMs - startTimeMs;
setTimestamp(formatTime(msElapsed));
animationFrame = requestAnimationFrame(onUpdate);

View File

@@ -9,12 +9,11 @@
}
.preview video {
width: calc(100% + 1px);
width: 100%;
height: 100%;
object-fit: contain;
background-color: black;
/* transform scale doesn't perfectly match width, so make -1.01 border issues */
transform: scaleX(-1.01);
transform: scaleX(-1);
}
.avatarContainer {

View File

@@ -15,29 +15,42 @@ limitations under the License.
*/
import React from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MicButton, VideoButton } from "../button";
import { useMediaStream } from "../video-grid/useMediaStream";
import { OverflowMenu } from "./OverflowMenu";
import { Avatar } from "../Avatar";
import { useProfile } from "../profile/useProfile";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import styles from "./VideoPreview.module.css";
import { Body } from "../typography/Typography";
import { useModalTriggerState } from "../Modal";
interface Props {
client: MatrixClient;
state: GroupCallState;
roomIdOrAlias: string;
microphoneMuted: boolean;
localVideoMuted: boolean;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
audioOutput: string;
stream: MediaStream;
}
export function VideoPreview({
client,
state,
roomId,
roomIdOrAlias,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
audioOutput,
stream,
}) {
}: Props) {
const videoRef = useMediaStream(stream, audioOutput, true);
const { displayName, avatarUrl } = useProfile(client);
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
@@ -80,10 +93,12 @@ export function VideoPreview({
onPress={toggleLocalVideoMuted}
/>
<OverflowMenu
roomId={roomId}
client={client}
roomIdOrAlias={roomIdOrAlias}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
inCall={false}
groupCall={undefined}
showInvite={false}
/>
</div>
</>

View File

@@ -29,7 +29,7 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { usePageUnload } from "./usePageUnload";
export interface UseGroupCallType {
export interface UseGroupCallReturnType {
state: GroupCallState;
calls: MatrixCall[];
localCallFeed: CallFeed;
@@ -72,7 +72,7 @@ interface State {
hasLocalParticipant: boolean;
}
export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
const [
{
state,
@@ -302,9 +302,11 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
const toggleScreensharing = useCallback(() => {
updateState({ requestingScreenshare: true });
groupCall.setScreensharingEnabled(!groupCall.isScreensharing()).then(() => {
updateState({ requestingScreenshare: false });
});
groupCall
.setScreensharingEnabled(!groupCall.isScreensharing(), { audio: true })
.then(() => {
updateState({ requestingScreenshare: false });
});
}, [groupCall]);
useEffect(() => {

View File

@@ -87,7 +87,8 @@ export const useLoadGroupCall = (
// The room doesn't exist, but we can create it
const [, roomId] = await createRoom(
client,
roomNameFromRoomId(roomIdOrAlias)
roomNameFromRoomId(roomIdOrAlias),
createPtt
);
// likewise, wait for the room
return await waitForRoom(roomId);

View File

@@ -32,11 +32,11 @@ function isIOS() {
);
}
export function usePageUnload(callback) {
export function usePageUnload(callback: () => void) {
useEffect(() => {
let pageVisibilityTimeout;
let pageVisibilityTimeout: number;
function onBeforeUnload(event) {
function onBeforeUnload(event: PageTransitionEvent) {
if (event.type === "visibilitychange") {
if (document.visibilityState === "visible") {
clearTimeout(pageVisibilityTimeout);

93
src/room/useRoomParams.ts Normal file
View File

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

View File

@@ -16,28 +16,30 @@ limitations under the License.
import { useEffect } from "react";
import * as Sentry from "@sentry/react";
import { GroupCall, GroupCallEvent } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallEvent, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
export function useSentryGroupCallHandler(groupCall) {
export function useSentryGroupCallHandler(groupCall: GroupCall) {
useEffect(() => {
function onHangup(call) {
function onHangup(call: MatrixCall) {
if (call.hangupReason === "ice_failed") {
Sentry.captureException(new Error("Call hangup due to ICE failure."));
}
}
function onError(error) {
function onError(error: Error) {
Sentry.captureException(error);
}
if (groupCall) {
groupCall.on("hangup", onHangup);
groupCall.on("error", onError);
groupCall.on(CallEvent.Hangup, onHangup);
groupCall.on(GroupCallEvent.Error, onError);
}
return () => {
if (groupCall) {
groupCall.removeListener("hangup", onHangup);
groupCall.removeListener("error", onError);
groupCall.removeListener(CallEvent.Hangup, onHangup);
groupCall.removeListener(GroupCallEvent.Error, onError);
}
};
}, [groupCall]);

View File

@@ -32,9 +32,8 @@ import { useDownloadDebugLog } from "./submit-rageshake";
import { Body } from "../typography/Typography";
interface Props {
setShowInspector: boolean;
showInspector: boolean;
[rest: string]: unknown;
isOpen: boolean;
onClose: () => void;
}
export const SettingsModal = (props: Props) => {

View File

@@ -16,7 +16,7 @@ limitations under the License.
import { useCallback, useContext, useEffect, useState } from "react";
import pako from "pako";
import { MatrixEvent } from "matrix-js-sdk";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { OverlayTriggerState } from "@react-stately/overlays";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
@@ -26,11 +26,11 @@ import { InspectorContext } from "../room/GroupCallInspector";
import { useModalTriggerState } from "../Modal";
interface RageShakeSubmitOptions {
description: string;
roomId: string;
label: string;
sendLogs: boolean;
rageshakeRequestId: string;
rageshakeRequestId?: string;
description?: string;
roomId?: string;
label?: string;
}
export function useSubmitRageshake(): {
@@ -40,7 +40,7 @@ export function useSubmitRageshake(): {
error: Error;
} {
const client: MatrixClient = useClient().client;
const [{ json }] = useContext(InspectorContext);
const json = useContext(InspectorContext);
const [{ sending, sent, error }, setState] = useState({
sending: false,
@@ -274,7 +274,7 @@ export function useSubmitRageshake(): {
}
export function useDownloadDebugLog(): () => void {
const [{ json }] = useContext(InspectorContext);
const json = useContext(InspectorContext);
const downloadDebugLog = useCallback(() => {
const blob = new Blob([JSON.stringify(json)], { type: "application/json" });

View File

@@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
import React, {
useState,
@@ -24,6 +24,7 @@ import React, {
useMemo,
useContext,
createContext,
ReactNode,
} from "react";
export interface MediaHandlerContextInterface {
@@ -73,7 +74,7 @@ function updateMediaPreferences(newPreferences: MediaPreferences): void {
}
interface Props {
client: MatrixClient;
children: JSX.Element[];
children: ReactNode;
}
export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
const [

View File

@@ -15,6 +15,7 @@ limitations under the License.
*/
import React from "react";
import { TabContainer, TabItem } from "./Tabs";
import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
@@ -29,7 +30,7 @@ export default {
},
};
export const Tabs = () => (
export const Tabs: React.FC<{}> = () => (
<TabContainer>
<TabItem
title={

View File

@@ -17,19 +17,28 @@ limitations under the License.
import React, { useRef } from "react";
import { useTabList, useTab, useTabPanel } from "@react-aria/tabs";
import { Item } from "@react-stately/collections";
import { useTabListState } from "@react-stately/tabs";
import styles from "./Tabs.module.css";
import { useTabListState, TabListState } from "@react-stately/tabs";
import classNames from "classnames";
import { AriaTabPanelProps, TabListProps } from "@react-types/tabs";
import { Node } from "@react-types/shared";
export function TabContainer(props) {
const state = useTabListState(props);
const ref = useRef();
import styles from "./Tabs.module.css";
interface TabContainerProps<T> extends TabListProps<T> {
className?: string;
}
export function TabContainer<T extends object>(
props: TabContainerProps<T>
): JSX.Element {
const state = useTabListState<T>(props);
const ref = useRef<HTMLUListElement>();
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={classNames(styles.tabContainer, props.className)}>
<ul {...tabListProps} ref={ref} className={styles.tabList}>
{[...state.collection].map((item) => (
<Tab key={item.key} item={item} state={state} />
<Tab item={item} state={state} />
))}
</ul>
<TabPanel key={state.selectedItem?.key} state={state} />
@@ -37,9 +46,14 @@ export function TabContainer(props) {
);
}
function Tab({ item, state }) {
interface TabProps<T> {
item: Node<T>;
state: TabListState<T>;
}
function Tab<T>({ item, state }: TabProps<T>): JSX.Element {
const { key, rendered } = item;
const ref = useRef();
const ref = useRef<HTMLLIElement>();
const { tabProps } = useTab({ key }, state, ref);
return (
@@ -56,8 +70,12 @@ function Tab({ item, state }) {
);
}
function TabPanel({ state, ...props }) {
const ref = useRef();
interface TabPanelProps<T> extends AriaTabPanelProps {
state: TabListState<T>;
}
function TabPanel<T>({ state, ...props }: TabPanelProps<T>): JSX.Element {
const ref = useRef<HTMLDivElement>();
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className={styles.tabPanel}>

View File

@@ -15,6 +15,7 @@ limitations under the License.
*/
import React from "react";
import { Headline, Title, Subtitle, Body, Caption, Micro } from "./Typography";
export default {
@@ -24,7 +25,7 @@ export default {
},
};
export const Typography = () => (
export const Typography: React.FC<{}> = () => (
<>
<Headline>Headline Semi Bold</Headline>
<Title>Title</Title>

View File

@@ -14,12 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef } from "react";
import { createElement, forwardRef, ReactNode } from "react";
import classNames from "classnames";
import { Link as RouterLink } from "react-router-dom";
import * as H from "history";
import styles from "./Typography.module.css";
export const Headline = forwardRef(
interface TypographyProps {
children: ReactNode;
fontWeight?: string;
className?: string;
overflowEllipsis?: boolean;
as?: string;
}
export const Headline = forwardRef<HTMLHeadingElement, TypographyProps>(
(
{
as: Component = "h1",
@@ -31,23 +41,23 @@ export const Headline = forwardRef(
},
ref
) => {
return (
<Component
{...rest}
className={classNames(
return createElement(
Component,
{
...rest,
className: classNames(
styles[fontWeight],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
)}
ref={ref}
>
{children}
</Component>
),
ref,
},
children
);
}
);
export const Title = forwardRef(
export const Title = forwardRef<HTMLHeadingElement, TypographyProps>(
(
{
as: Component = "h2",
@@ -59,23 +69,23 @@ export const Title = forwardRef(
},
ref
) => {
return (
<Component
{...rest}
className={classNames(
return createElement(
Component,
{
...rest,
className: classNames(
styles[fontWeight],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
)}
ref={ref}
>
{children}
</Component>
),
ref,
},
children
);
}
);
export const Subtitle = forwardRef(
export const Subtitle = forwardRef<HTMLParagraphElement, TypographyProps>(
(
{
as: Component = "h3",
@@ -87,23 +97,23 @@ export const Subtitle = forwardRef(
},
ref
) => {
return (
<Component
{...rest}
className={classNames(
return createElement(
Component,
{
...rest,
className: classNames(
styles[fontWeight],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
)}
ref={ref}
>
{children}
</Component>
),
ref,
},
children
);
}
);
export const Body = forwardRef(
export const Body = forwardRef<HTMLParagraphElement, TypographyProps>(
(
{
as: Component = "p",
@@ -115,23 +125,23 @@ export const Body = forwardRef(
},
ref
) => {
return (
<Component
{...rest}
className={classNames(
return createElement(
Component,
{
...rest,
className: classNames(
styles[fontWeight],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
)}
ref={ref}
>
{children}
</Component>
),
ref,
},
children
);
}
);
export const Caption = forwardRef(
export const Caption = forwardRef<HTMLParagraphElement, TypographyProps>(
(
{
as: Component = "p",
@@ -143,24 +153,24 @@ export const Caption = forwardRef(
},
ref
) => {
return (
<Component
{...rest}
className={classNames(
return createElement(
Component,
{
...rest,
className: classNames(
styles.caption,
styles[fontWeight],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
)}
ref={ref}
>
{children}
</Component>
),
ref,
},
children
);
}
);
export const Micro = forwardRef(
export const Micro = forwardRef<HTMLParagraphElement, TypographyProps>(
(
{
as: Component = "p",
@@ -172,24 +182,29 @@ export const Micro = forwardRef(
},
ref
) => {
return (
<Component
{...rest}
className={classNames(
return createElement(
Component,
{
...rest,
className: classNames(
styles.micro,
styles[fontWeight],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
)}
ref={ref}
>
{children}
</Component>
),
ref,
},
children
);
}
);
export const Link = forwardRef(
interface LinkProps extends TypographyProps {
to?: H.LocationDescriptor<unknown>;
color?: string;
href?: string;
}
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
(
{
as,
@@ -204,8 +219,8 @@ export const Link = forwardRef(
},
ref
) => {
const Component = as || (to ? RouterLink : "a");
let externalLinkProps;
const Component: string | RouterLink = as || (to ? RouterLink : "a");
let externalLinkProps: { href: string; target: string; rel: string };
if (href) {
externalLinkProps = {
@@ -215,21 +230,21 @@ export const Link = forwardRef(
};
}
return (
<Component
{...externalLinkProps}
{...rest}
to={to}
className={classNames(
return createElement(
Component,
{
...externalLinkProps,
...rest,
to: to,
className: classNames(
styles[color],
styles[fontWeight],
{ [styles.overflowEllipsis]: overflowEllipsis },
className
)}
ref={ref}
>
{children}
</Component>
),
ref: ref,
},
children
);
}
);

View File

@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { useHistory } from "react-router-dom";
export function useLocationNavigation(enabled = false) {
export function useLocationNavigation(enabled = false): void {
const history = useHistory();
useEffect(() => {
@@ -12,7 +12,7 @@ export function useLocationNavigation(enabled = false) {
const url = new URL(tx.pathname, window.location.href);
url.search = tx.search;
url.hash = tx.hash;
window.location = url.href;
window.location.href = url.href;
});
}

View File

@@ -1,8 +1,9 @@
import { useEffect } from "react";
import { useFocusVisible } from "@react-aria/interactions";
import styles from "./usePageFocusStyle.module.css";
export function usePageFocusStyle() {
export function usePageFocusStyle(): void {
const { isFocusVisible } = useFocusVisible();
useEffect(() => {

View File

@@ -1,9 +0,0 @@
import { useEffect } from "react";
export function usePageTitle(title) {
useEffect(() => {
const productName =
import.meta.env.VITE_PRODUCT_NAME || "Matrix Video Chat";
document.title = title ? `${productName} | ${title}` : productName;
}, [title]);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,14 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from "classnames";
import React, { forwardRef } from "react";
import styles from "./Form.module.css";
import { useEffect } from "react";
export const Form = forwardRef(({ children, className, ...rest }, ref) => {
return (
<form {...rest} className={classNames(styles.form, className)} ref={ref}>
{children}
</form>
);
});
export function usePageTitle(title: string): void {
useEffect(() => {
const productName =
import.meta.env.VITE_PRODUCT_NAME || "Matrix Video Chat";
document.title = title ? `${productName} | ${title}` : productName;
}, [title]);
}

View File

@@ -0,0 +1,97 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useRef } from "react";
import { Participant } from "../room/InCallView";
import { useCallFeed } from "./useCallFeed";
import { useMediaStreamTrackCount } from "./useMediaStream";
// XXX: These in fact do not render anything but to my knowledge this is the
// only way to a hook on an array
interface AudioForParticipantProps {
item: Participant;
audioContext: AudioContext;
audioDestination: AudioNode;
}
export function AudioForParticipant({
item,
audioContext,
audioDestination,
}: AudioForParticipantProps): JSX.Element {
const { stream, localVolume, audioMuted } = useCallFeed(item.callFeed);
const [audioTrackCount] = useMediaStreamTrackCount(stream);
const gainNodeRef = useRef<GainNode>();
const sourceRef = useRef<MediaStreamAudioSourceNode>();
useEffect(() => {
if (!item.isLocal && audioContext && !audioMuted && audioTrackCount > 0) {
if (!gainNodeRef.current) {
gainNodeRef.current = new GainNode(audioContext, {
gain: localVolume,
});
}
if (!sourceRef.current) {
sourceRef.current = audioContext.createMediaStreamSource(stream);
}
const source = sourceRef.current;
const gainNode = gainNodeRef.current;
gainNode.gain.value = localVolume;
source.connect(gainNode).connect(audioDestination);
return () => {
source.disconnect();
gainNode.disconnect();
};
}
}, [
item,
audioContext,
audioDestination,
stream,
localVolume,
audioMuted,
audioTrackCount,
]);
return null;
}
interface AudioContainerProps {
items: Participant[];
audioContext: AudioContext;
audioDestination: AudioNode;
}
export function AudioContainer({
items,
...rest
}: AudioContainerProps): JSX.Element {
return (
<>
{items
.filter((item) => !item.isLocal)
.map((item) => (
<AudioForParticipant key={item.id} item={item} {...rest} />
))}
</>
);
}

View File

@@ -15,10 +15,12 @@ limitations under the License.
*/
import React, { useState } from "react";
import { useMemo } from "react";
import { VideoGrid, useVideoGridLayout } from "./VideoGrid";
import { VideoTile } from "./VideoTile";
import { useMemo } from "react";
import { Button } from "../button";
import { Participant } from "../room/InCallView";
export default {
title: "VideoGrid",
@@ -28,10 +30,10 @@ export default {
};
export const ParticipantsTest = () => {
const [layout, setLayout] = useVideoGridLayout(false);
const { layout, setLayout } = useVideoGridLayout(false);
const [participantCount, setParticipantCount] = useState(1);
const items = useMemo(
const items: Participant[] = useMemo(
() =>
new Array(participantCount).fill(undefined).map((_, i) => ({
id: (i + 1).toString(),
@@ -46,9 +48,7 @@ export const ParticipantsTest = () => {
<div style={{ display: "flex", width: "100vw", height: "32px" }}>
<Button
onPress={() =>
setLayout((layout) =>
layout === "freedom" ? "spotlight" : "freedom"
)
setLayout(layout === "freedom" ? "spotlight" : "freedom")
}
>
Toggle Layout
@@ -76,7 +76,6 @@ export const ParticipantsTest = () => {
<VideoTile
key={item.id}
name={`User ${item.id}`}
showName={items.length > 2 || item.focused}
disableSpeakingIndicator={items.length < 3}
{...rest}
/>

View File

@@ -14,20 +14,46 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useDrag, useGesture } from "@use-gesture/react";
import { useSprings } from "@react-spring/web";
import React, { Key, useCallback, useEffect, useRef, useState } from "react";
import { FullGestureState, useDrag, useGesture } from "@use-gesture/react";
import { Interpolation, SpringValue, useSprings } from "@react-spring/web";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import styles from "./VideoGrid.module.css";
import { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/types";
export function useVideoGridLayout(hasScreenshareFeeds) {
const layoutRef = useRef("freedom");
const revertLayoutRef = useRef("freedom");
import styles from "./VideoGrid.module.css";
import { Layout } from "../room/GridLayoutMenu";
import { Participant } from "../room/InCallView";
interface TilePosition {
x: number;
y: number;
width: number;
height: number;
zIndex: number;
}
interface Tile {
key: Key;
order: number;
item: Participant;
remove: boolean;
focused: boolean;
presenter: boolean;
}
type LayoutDirection = "vertical" | "horizontal";
export function useVideoGridLayout(hasScreenshareFeeds: boolean): {
layout: Layout;
setLayout: (layout: Layout) => void;
} {
const layoutRef = useRef<Layout>("freedom");
const revertLayoutRef = useRef<Layout>("freedom");
const prevHasScreenshareFeeds = useRef(hasScreenshareFeeds);
const [, forceUpdate] = useState({});
const setLayout = useCallback((layout) => {
const setLayout = useCallback((layout: Layout) => {
// Store the user's set layout to revert to after a screenshare is finished
revertLayoutRef.current = layout;
layoutRef.current = layout;
@@ -48,13 +74,13 @@ export function useVideoGridLayout(hasScreenshareFeeds) {
prevHasScreenshareFeeds.current = hasScreenshareFeeds;
return [layoutRef.current, setLayout];
return { layout: layoutRef.current, setLayout };
}
const GAP = 8;
function useIsMounted() {
const isMountedRef = useRef(false);
const isMountedRef = useRef<boolean>(false);
useEffect(() => {
isMountedRef.current = true;
@@ -67,7 +93,7 @@ function useIsMounted() {
return isMountedRef;
}
function isInside([x, y], targetTile) {
function isInside([x, y]: number[], targetTile: TilePosition): boolean {
const left = targetTile.x;
const top = targetTile.y;
const bottom = targetTile.y + targetTile.height;
@@ -80,17 +106,18 @@ function isInside([x, y], targetTile) {
return true;
}
const getPipGap = (gridAspectRatio) => (gridAspectRatio < 1 ? 12 : 24);
const getPipGap = (gridAspectRatio: number): number =>
gridAspectRatio < 1 ? 12 : 24;
function getTilePositions(
tileCount,
presenterTileCount,
gridWidth,
gridHeight,
pipXRatio,
pipYRatio,
layout
) {
tileCount: number,
presenterTileCount: number,
gridWidth: number,
gridHeight: number,
pipXRatio: number,
pipYRatio: number,
layout: Layout
): TilePosition[] {
if (layout === "freedom") {
if (tileCount === 2 && presenterTileCount === 0) {
return getOneOnOneLayoutTilePositions(
@@ -113,11 +140,11 @@ function getTilePositions(
}
function getOneOnOneLayoutTilePositions(
gridWidth,
gridHeight,
pipXRatio,
pipYRatio
) {
gridWidth: number,
gridHeight: number,
pipXRatio: number,
pipYRatio: number
): TilePosition[] {
const [remotePosition] = getFreedomLayoutTilePositions(
1,
0,
@@ -149,8 +176,12 @@ function getOneOnOneLayoutTilePositions(
];
}
function getSpotlightLayoutTilePositions(tileCount, gridWidth, gridHeight) {
const tilePositions = [];
function getSpotlightLayoutTilePositions(
tileCount: number,
gridWidth: number,
gridHeight: number
): TilePosition[] {
const tilePositions: TilePosition[] = [];
const gridAspectRatio = gridWidth / gridHeight;
@@ -215,11 +246,11 @@ function getSpotlightLayoutTilePositions(tileCount, gridWidth, gridHeight) {
}
function getFreedomLayoutTilePositions(
tileCount,
presenterTileCount,
gridWidth,
gridHeight
) {
tileCount: number,
presenterTileCount: number,
gridWidth: number,
gridHeight: number
): TilePosition[] {
if (tileCount === 0) {
return [];
}
@@ -330,7 +361,14 @@ function getFreedomLayoutTilePositions(
return tilePositions;
}
function getSubGridBoundingBox(positions) {
function getSubGridBoundingBox(positions: TilePosition[]): {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
} {
let left = 0;
let right = 0;
let top = 0;
@@ -373,13 +411,18 @@ function getSubGridBoundingBox(positions) {
};
}
function isMobileBreakpoint(gridWidth, gridHeight) {
function isMobileBreakpoint(gridWidth: number, gridHeight: number): boolean {
const gridAspectRatio = gridWidth / gridHeight;
return gridAspectRatio < 1;
}
function getGridLayout(tileCount, presenterTileCount, gridWidth, gridHeight) {
let layoutDirection = "horizontal";
function getGridLayout(
tileCount: number,
presenterTileCount: number,
gridWidth: number,
gridHeight: number
): { itemGridRatio: number; layoutDirection: LayoutDirection } {
let layoutDirection: LayoutDirection = "horizontal";
let itemGridRatio = 1;
if (presenterTileCount === 0) {
@@ -397,7 +440,13 @@ function getGridLayout(tileCount, presenterTileCount, gridWidth, gridHeight) {
return { itemGridRatio, layoutDirection };
}
function centerTiles(positions, gridWidth, gridHeight, offsetLeft, offsetTop) {
function centerTiles(
positions: TilePosition[],
gridWidth: number,
gridHeight: number,
offsetLeft: number,
offsetTop: number
) {
const bounds = getSubGridBoundingBox(positions);
const leftOffset = Math.round((gridWidth - bounds.width) / 2) + offsetLeft;
@@ -408,7 +457,11 @@ function centerTiles(positions, gridWidth, gridHeight, offsetLeft, offsetTop) {
return positions;
}
function applyTileOffsets(positions, leftOffset, topOffset) {
function applyTileOffsets(
positions: TilePosition[],
leftOffset: number,
topOffset: number
) {
for (const position of positions) {
position.x += leftOffset;
position.y += topOffset;
@@ -417,12 +470,16 @@ function applyTileOffsets(positions, leftOffset, topOffset) {
return positions;
}
function getSubGridLayout(tileCount, gridWidth, gridHeight) {
function getSubGridLayout(
tileCount: number,
gridWidth: number,
gridHeight: number
): { columnCount: number; rowCount: number; tileAspectRatio: number } {
const gridAspectRatio = gridWidth / gridHeight;
let columnCount;
let rowCount;
let tileAspectRatio = 16 / 9;
let columnCount: number;
let rowCount: number;
let tileAspectRatio: number = 16 / 9;
if (gridAspectRatio < 3 / 4) {
// Phone
@@ -528,26 +585,26 @@ function getSubGridLayout(tileCount, gridWidth, gridHeight) {
}
function getSubGridPositions(
tileCount,
columnCount,
rowCount,
tileAspectRatio,
gridWidth,
gridHeight
tileCount: number,
columnCount: number,
rowCount: number,
tileAspectRatio: number,
gridWidth: number,
gridHeight: number
) {
if (tileCount === 0) {
return [];
}
const newTilePositions = [];
const newTilePositions: TilePosition[] = [];
const boxWidth = Math.round(
(gridWidth - GAP * (columnCount + 1)) / columnCount
);
const boxHeight = Math.round((gridHeight - GAP * (rowCount + 1)) / rowCount);
let tileWidth;
let tileHeight;
let tileWidth: number;
let tileHeight: number;
if (tileAspectRatio) {
const boxAspectRatio = boxWidth / boxHeight;
@@ -568,7 +625,7 @@ function getSubGridPositions(
const verticalIndex = Math.floor(i / columnCount);
const top = verticalIndex * GAP + verticalIndex * tileHeight;
let rowItemCount;
let rowItemCount: number;
if (verticalIndex + 1 === rowCount && tileCount % columnCount !== 0) {
rowItemCount = tileCount % columnCount;
@@ -603,16 +660,16 @@ function getSubGridPositions(
return newTilePositions;
}
function reorderTiles(tiles, layout) {
function reorderTiles(tiles: Tile[], layout: Layout) {
if (layout === "freedom" && tiles.length === 2) {
// 1:1 layout
tiles.forEach((tile) => (tile.order = tile.item.isLocal ? 0 : 1));
} else {
const focusedTiles = [];
const presenterTiles = [];
const otherTiles = [];
const focusedTiles: Tile[] = [];
const presenterTiles: Tile[] = [];
const otherTiles: Tile[] = [];
const orderedTiles = new Array(tiles.length);
const orderedTiles: Tile[] = new Array(tiles.length);
tiles.forEach((tile) => (orderedTiles[tile.order] = tile));
orderedTiles.forEach((tile) =>
@@ -630,27 +687,63 @@ function reorderTiles(tiles, layout) {
}
}
export function VideoGrid({ items, layout, disableAnimations, children }) {
interface DragTileData {
offsetX: number;
offsetY: number;
key: Key;
x: number;
y: number;
}
interface ChildrenProperties extends ReactDOMAttributes {
key: Key;
style: {
scale: SpringValue<number>;
opacity: SpringValue<number>;
boxShadow: Interpolation<number, string>;
};
width: number;
height: number;
item: Participant;
[index: string]: unknown;
}
interface VideoGridProps {
items: Participant[];
layout: Layout;
disableAnimations?: boolean;
children: (props: ChildrenProperties) => React.ReactNode;
}
export function VideoGrid({
items,
layout,
disableAnimations,
children,
}: VideoGridProps) {
// Place the PiP in the bottom right corner by default
const [pipXRatio, setPipXRatio] = useState(1);
const [pipYRatio, setPipYRatio] = useState(1);
const [{ tiles, tilePositions }, setTileState] = useState({
const [{ tiles, tilePositions }, setTileState] = useState<{
tiles: Tile[];
tilePositions: TilePosition[];
}>({
tiles: [],
tilePositions: [],
});
const [scrollPosition, setScrollPosition] = useState(0);
const draggingTileRef = useRef(null);
const lastTappedRef = useRef({});
const lastLayoutRef = useRef(layout);
const [scrollPosition, setScrollPosition] = useState<number>(0);
const draggingTileRef = useRef<DragTileData>(null);
const lastTappedRef = useRef<{ [index: Key]: number }>({});
const lastLayoutRef = useRef<Layout>(layout);
const isMounted = useIsMounted();
const [gridRef, gridBounds] = useMeasure({ polyfill: ResizeObserver });
useEffect(() => {
setTileState(({ tiles, ...rest }) => {
const newTiles = [];
const removedTileKeys = new Set();
const newTiles: Tile[] = [];
const removedTileKeys: Set<Key> = new Set();
for (const tile of tiles) {
let item = items.find((item) => item.id === tile.key);
@@ -663,7 +756,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
removedTileKeys.add(tile.key);
}
let focused;
let focused: boolean;
let presenter = false;
if (layout === "spotlight") {
@@ -694,7 +787,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
continue;
}
const newTile = {
const newTile: Tile = {
key: item.id,
order: existingTile?.order ?? newTiles.length,
item,
@@ -721,7 +814,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
}
setTileState(({ tiles, ...rest }) => {
const newTiles = tiles
const newTiles: Tile[] = tiles
.filter((tile) => !removedTileKeys.has(tile.key))
.map((tile) => ({ ...tile })); // clone before reordering
reorderTiles(newTiles, layout);
@@ -772,7 +865,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
}, [items, gridBounds, layout, isMounted, pipXRatio, pipYRatio]);
const animate = useCallback(
(tiles) => (tileIndex) => {
(tiles: Tile[]) => (tileIndex: number) => {
const tile = tiles[tileIndex];
const tilePosition = tilePositions[tile.order];
const draggingTile = draggingTileRef.current;
@@ -789,7 +882,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
opacity: 1,
zIndex: 2,
shadow: 15,
immediate: (key) =>
immediate: (key: string) =>
disableAnimations ||
key === "zIndex" ||
key === "x" ||
@@ -831,11 +924,11 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
opacity: 0,
},
reset: false,
immediate: (key) =>
immediate: (key: string) =>
disableAnimations || key === "zIndex" || key === "shadow",
// If we just stopped dragging a tile, give it time for its animation
// to settle before pushing its z-index back down
delay: (key) => (key === "zIndex" ? 500 : 0),
delay: (key: string) => (key === "zIndex" ? 500 : 0),
};
}
},
@@ -849,7 +942,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
]);
const onTap = useCallback(
(tileKey) => {
(tileKey: Key) => {
const lastTapped = lastTappedRef.current[tileKey];
if (!lastTapped || Date.now() - lastTapped > 500) {
@@ -866,7 +959,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
setTileState(({ tiles, ...state }) => {
let presenterTileCount = 0;
const newTiles = tiles.map((tile) => {
let newTile = { ...tile }; // clone before reordering
const newTile = { ...tile }; // clone before reordering
if (tile.item === item) {
newTile.focused = !tile.focused;
@@ -895,7 +988,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
};
});
},
[tiles, gridBounds, layout]
[tiles, layout, gridBounds.width, gridBounds.height, pipXRatio, pipYRatio]
);
const bindTile = useDrag(
@@ -1008,12 +1101,18 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
);
const onGridGesture = useCallback(
(e, isWheel) => {
(
e:
| Omit<FullGestureState<"wheel">, "event">
| Omit<FullGestureState<"drag">, "event">,
isWheel: boolean
) => {
if (layout !== "spotlight") {
return;
}
const isMobile = isMobileBreakpoint(gridBounds.width, gridBounds.height);
let movement = e.delta[isMobile ? 0 : 1];
if (isWheel) {

View File

@@ -40,18 +40,21 @@
box-shadow: inset 0 0 0 4px var(--accent) !important;
}
.videoTile.fullscreen {
position: relative;
border-radius: 0;
}
.videoTile.screenshare > video {
object-fit: contain;
}
.memberName {
.infoBubble {
position: absolute;
bottom: 16px;
left: 16px;
height: 24px;
padding: 0 8px;
color: white;
background-color: rgba(23, 25, 28, 0.85);
color: var(--primary-content);
background-color: var(--background-85);
display: flex;
align-items: center;
justify-content: center;
@@ -62,6 +65,47 @@
z-index: 1;
}
.toolbar {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 42px;
color: var(--primary-content);
background-color: var(--background-85);
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
z-index: 1;
}
.toolbar:not(:hover) {
opacity: 0;
}
.toolbar:hover + .presenterLabel {
top: calc(42px + 20px); /* toolbar + margin */
}
.button {
margin-right: 16px;
}
.button svg {
width: 16px;
height: 16px;
}
.memberName {
left: 16px;
bottom: 16px;
}
.memberName > * {
margin-right: 6px;
}

View File

@@ -17,24 +17,49 @@ limitations under the License.
import React, { forwardRef } from "react";
import { animated } from "@react-spring/web";
import classNames from "classnames";
import styles from "./VideoTile.module.css";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg";
import { AudioButton, FullscreenButton } from "../button/Button";
export const VideoTile = forwardRef(
interface Props {
name: string;
speaking?: boolean;
audioMuted?: boolean;
videoMuted?: boolean;
screenshare?: boolean;
avatar?: JSX.Element;
mediaRef?: React.RefObject<MediaElement>;
onOptionsPress?: () => void;
localVolume?: number;
isFullscreen?: boolean;
onFullscreen?: () => void;
className?: string;
showOptions?: boolean;
isLocal?: boolean;
disableSpeakingIndicator?: boolean;
}
export const VideoTile = forwardRef<HTMLDivElement, Props>(
(
{
className,
isLocal,
name,
speaking,
audioMuted,
noVideo,
videoMuted,
screenshare,
avatar,
name,
showName,
mediaRef,
onOptionsPress,
localVolume,
isFullscreen,
onFullscreen,
className,
showOptions,
isLocal,
// TODO: disableSpeakingIndicator is not used atm.
disableSpeakingIndicator,
...rest
},
ref
@@ -46,11 +71,30 @@ export const VideoTile = forwardRef(
[styles.speaking]: speaking,
[styles.muted]: audioMuted,
[styles.screenshare]: screenshare,
[styles.fullscreen]: isFullscreen,
})}
ref={ref}
{...rest}
>
{(videoMuted || noVideo) && (
{(!isLocal || screenshare) && (
<div className={classNames(styles.toolbar)}>
{!isLocal && (
<AudioButton
className={styles.button}
volume={localVolume}
onPress={onOptionsPress}
/>
)}
{screenshare && (
<FullscreenButton
className={styles.button}
fullscreen={isFullscreen}
onPress={onFullscreen}
/>
)}
</div>
)}
{videoMuted && (
<>
<div className={styles.videoMutedOverlay} />
{avatar}
@@ -61,13 +105,11 @@ export const VideoTile = forwardRef(
<span>{`${name} is presenting`}</span>
</div>
) : (
(showName || audioMuted || (videoMuted && !noVideo)) && (
<div className={styles.memberName}>
{audioMuted && !(videoMuted && !noVideo) && <MicMutedIcon />}
{videoMuted && !noVideo && <VideoMutedIcon />}
{showName && <span title={name}>{name}</span>}
</div>
)
<div className={classNames(styles.infoBubble, styles.memberName)}>
{audioMuted && !videoMuted && <MicMutedIcon />}
{videoMuted && <VideoMutedIcon />}
<span title={name}>{name}</span>
</div>
)}
<video ref={mediaRef} playsInline disablePictureInPicture />
</animated.div>

View File

@@ -1,74 +0,0 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import React from "react";
import { useCallFeed } from "./useCallFeed";
import { useSpatialMediaStream } from "./useMediaStream";
import { useRoomMemberName } from "./useRoomMemberName";
import { VideoTile } from "./VideoTile";
export function VideoTileContainer({
item,
width,
height,
getAvatar,
showName,
audioOutputDevice,
audioContext,
audioDestination,
disableSpeakingIndicator,
...rest
}) {
const {
isLocal,
audioMuted,
videoMuted,
noVideo,
speaking,
stream,
purpose,
member,
} = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(member);
const [tileRef, mediaRef] = useSpatialMediaStream(
stream,
audioOutputDevice,
audioContext,
audioDestination,
isLocal
);
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
return (
<VideoTile
isLocal={isLocal}
speaking={speaking && !disableSpeakingIndicator}
audioMuted={audioMuted}
noVideo={noVideo}
videoMuted={videoMuted}
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName}
showName={showName}
ref={tileRef}
mediaRef={mediaRef}
avatar={getAvatar && getAvatar(member, width, height)}
{...rest}
/>
);
}

View File

@@ -0,0 +1,116 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import React from "react";
import { useCallback } from "react";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useCallFeed } from "./useCallFeed";
import { useSpatialMediaStream } from "./useMediaStream";
import { useRoomMemberName } from "./useRoomMemberName";
import { VideoTile } from "./VideoTile";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
import { useModalTriggerState } from "../Modal";
import { Participant } from "../room/InCallView";
interface Props {
item: Participant;
width?: number;
height?: number;
getAvatar: (
roomMember: RoomMember,
width: number,
height: number
) => JSX.Element;
audioContext: AudioContext;
audioDestination: AudioNode;
disableSpeakingIndicator: boolean;
isFullscreen: boolean;
onFullscreen: (item: Participant) => void;
}
export function VideoTileContainer({
item,
width,
height,
getAvatar,
audioContext,
audioDestination,
disableSpeakingIndicator,
isFullscreen,
onFullscreen,
...rest
}: Props) {
const {
isLocal,
audioMuted,
videoMuted,
localVolume,
speaking,
stream,
purpose,
member,
} = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(member);
const [tileRef, mediaRef] = useSpatialMediaStream(
stream,
audioContext,
audioDestination,
isLocal,
localVolume
);
const {
modalState: videoTileSettingsModalState,
modalProps: videoTileSettingsModalProps,
} = useModalTriggerState();
const onOptionsPress = () => {
videoTileSettingsModalState.open();
};
const onFullscreenCallback = useCallback(() => {
onFullscreen(item);
}, [onFullscreen, item]);
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
return (
<>
<VideoTile
isLocal={isLocal}
speaking={speaking && !disableSpeakingIndicator}
audioMuted={audioMuted}
videoMuted={videoMuted}
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName}
ref={tileRef}
mediaRef={mediaRef}
avatar={getAvatar && getAvatar(member, width, height)}
onOptionsPress={onOptionsPress}
localVolume={localVolume}
isFullscreen={isFullscreen}
onFullscreen={onFullscreenCallback}
{...rest}
/>
{videoTileSettingsModalState.isOpen && (
<VideoTileSettingsModal
{...videoTileSettingsModalProps}
feed={item.callFeed}
/>
)}
</>
);
}

View File

@@ -0,0 +1,104 @@
.videoTileSettingsModal {
width: 700px;
height: 316px;
display: flex;
}
.content {
position: relative;
margin: 27px 34px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.localVolumePercentage {
width: 3ch;
}
.localVolumeSlider[type="range"] {
-ms-appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
background-color: transparent;
--slider-color: var(--quinary-content);
--slider-height: 4px;
--thumb-color: var(--accent);
--thumb-radius: 100%;
--thumb-size: 16px;
--thumb-margin-top: -6px;
cursor: pointer;
width: 100%;
}
.localVolumeSlider[type="range"]::-moz-range-track {
-moz-appearance: none;
appearance: none;
background-color: var(--slider-color);
height: var(--slider-height);
}
.localVolumeSlider[type="range"]::-ms-track {
-ms-appearance: none;
appearance: none;
background-color: var(--slider-color);
height: var(--slider-height);
}
.localVolumeSlider[type="range"]::-webkit-slider-runnable-track {
-webkit-appearance: none;
appearance: none;
background-color: var(--slider-color);
height: var(--slider-height);
}
.localVolumeSlider[type="range"]::-moz-range-thumb {
-moz-appearance: none;
appearance: none;
height: var(--thumb-size);
width: var(--thumb-size);
margin-top: var(--thumb-margin-top);
border-radius: var(--thumb-radius);
background: var(--thumb-color);
}
.localVolumeSlider[type="range"]::-ms-thumb {
-ms-appearance: none;
appearance: none;
height: var(--thumb-size);
width: var(--thumb-size);
margin-top: var(--thumb-margin-top);
border-radius: var(--thumb-radius);
background: var(--thumb-color);
}
.localVolumeSlider[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: var(--thumb-size);
width: var(--thumb-size);
margin-top: var(--thumb-margin-top);
border-radius: var(--thumb-radius);
background: var(--thumb-color);
}
.localVolumeSlider[type="range"]::-moz-range-progress {
-moz-appearance: none;
appearance: none;
height: var(--slider-height);
background: var(--thumb-color);
}
.localVolumeSlider[type="range"]::-ms-fill-lower {
-moz-appearance: none;
appearance: none;
height: var(--slider-height);
background: var(--thumb-color);
}

View File

@@ -0,0 +1,77 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, useState } from "react";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { FieldRow } from "../input/Input";
import { Modal } from "../Modal";
import styles from "./VideoTileSettingsModal.module.css";
import { VolumeIcon } from "../button/VolumeIcon";
interface LocalVolumeProps {
feed: CallFeed;
}
const LocalVolume: React.FC<LocalVolumeProps> = ({
feed,
}: LocalVolumeProps) => {
const [localVolume, setLocalVolume] = useState<number>(feed.getLocalVolume());
const onLocalVolumeChanged = (event: ChangeEvent<HTMLInputElement>) => {
const value: number = +event.target.value;
setLocalVolume(value);
feed.setLocalVolume(value);
};
return (
<>
<FieldRow>
<VolumeIcon volume={localVolume} />
<input
className={styles.localVolumeSlider}
type="range"
min="0"
max="1"
step="0.01"
value={localVolume}
onChange={onLocalVolumeChanged}
/>
</FieldRow>
</>
);
};
// TODO: Extend ModalProps
interface Props {
feed: CallFeed;
}
export const VideoTileSettingsModal = ({ feed, ...rest }: Props) => {
return (
<Modal
className={styles.videoTileSettingsModal}
title="Local volume"
isDismissable
mobileFullScreen
{...rest}
>
<div className={styles.content}>
<LocalVolume feed={feed} />
</div>
</Modal>
);
};

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