Compare commits

...

191 Commits

Author SHA1 Message Date
Matthew Hodgson
4f9efb3563 last minute s/radio call/walkie-talkie call/ig 2022-06-07 13:31:19 +01:00
Robin
69cfa1db6d Merge pull request #372 from robintown/organize-colors
Organize colors
2022-06-06 09:03:53 -04:00
Robin Townsend
977016fbb2 Merge branch 'main' into organize-colors 2022-06-06 09:03:40 -04:00
Robin
fb3d9e2a16 Merge pull request #374 from robintown/fix-warning
Fix warning
2022-06-03 08:24:40 -04:00
Robin Townsend
8da492d00d Fix warning 2022-06-02 16:30:35 -04:00
Robin
9676014120 Merge pull request #373 from robintown/camera
'Webcam' → 'Camera'
2022-06-02 14:06:58 -04:00
Robin Townsend
7d87b8d1e5 'Webcam' → 'Camera' 2022-06-02 13:53:31 -04:00
David Baker
ecb139721b Merge pull request #370 from vector-im/dbkr/avoid-browser-index-import
Fix app when built in production mode
2022-06-02 11:01:49 +01:00
Robin Townsend
aa45261b0d Organize colors 2022-06-01 11:48:17 -04:00
David Baker
017ec13981 Disable typescript warnings 2022-06-01 16:05:58 +01:00
David Baker
880a2ca127 Merge pull request #359 from vector-im/dbkr/lower_sdk_timeout
Lower timeout on js-sdk API call to 5s
2022-06-01 16:04:14 +01:00
David Baker
5282ab5f12 Merge remote-tracking branch 'origin/main' into dbkr/avoid-browser-index-import 2022-06-01 16:03:18 +01:00
David Baker
582e6637dc Merge remote-tracking branch 'origin/main' into dbkr/lower_sdk_timeout 2022-06-01 16:02:48 +01:00
David Baker
65804cd962 Merge pull request #358 from vector-im/dbkr/matrix-utils-ts
Convert matrix-utils to typescript
2022-06-01 16:02:20 +01:00
David Baker
0411e1cac8 Fix app when built in production mode
The recent typescripting appears to have caused the typescript
compiler to get confused about dependency references and start
refwrencing things like CRYPTO_ENABLED in the js-sdk before it's
defined them.

This avoids using things from the (javascript) browser-index import
and instead pulls everything in from the typescript files, then
fixes the resulting type failures, (in some cases with hacks).
2022-06-01 15:55:02 +01:00
Robin
bab5c9aa42 Merge pull request #367 from robintown/vu-animation
Add a VU meter-style animation to radio mode
2022-06-01 10:42:07 -04:00
Robin Townsend
d680a36cab Bump the animation size up a little bit more 2022-06-01 10:41:49 -04:00
Robin Townsend
25bde3560b Use color variables 2022-06-01 10:41:12 -04:00
Robin Townsend
ddac2ba5ef Merge branch 'main' into vu-animation 2022-06-01 10:31:04 -04:00
Robin
cd55098921 Merge pull request #365 from robintown/spatial-audio
Spatial audio
2022-06-01 09:17:04 -04:00
Robin
f1bdad0d7f Merge pull request #366 from robintown/chrome-android-sink
Fix crash when setting audio output on Chrome for Android
2022-06-01 09:14:41 -04:00
Robin
9fac2c95e5 Merge pull request #368 from robintown/radio-button-cursor
Make PTTButton feel more clickable
2022-06-01 09:13:04 -04:00
David Baker
486d0abd30 Merge pull request #363 from vector-im/dbkr/ptt_connection_lost
Show when connection is lost on PTT mode
2022-06-01 10:24:53 +01:00
David Baker
d9bd48b9a6 Split out client sync listeber into separate useEffect 2022-06-01 10:21:44 +01:00
David Baker
64e30c89e3 Comment typo
Co-authored-by: Robin <robin@robin.town>
2022-06-01 10:13:20 +01:00
David Baker
1860eaae7a Merge pull request #360 from vector-im/dbkr/consistent_sort
Sort call feeds consistently when choosing active speaker
2022-06-01 10:12:56 +01:00
David Baker
771424cbf0 Expand comment 2022-06-01 10:11:02 +01:00
David Baker
925a909ec1 Merge pull request #361 from vector-im/dbkr/usegroupcall_ts
Convert useGroupCall to TS
2022-06-01 10:07:12 +01:00
David Baker
f07ee54e05 Finish sentence
Co-authored-by: Robin <robin@robin.town>
2022-06-01 10:04:49 +01:00
David Baker
7ee2f630db Add more typers to useInteractiveLogin
otherwise apparently Typescript can't trace the MatrixClient type
through.
2022-06-01 09:59:59 +01:00
David Baker
626fdb9f79 Merge remote-tracking branch 'origin/main' into dbkr/matrix-utils-ts 2022-06-01 09:37:59 +01:00
David Baker
2cf40ff0b8 Fix room creation
The room alias is not part of the spec. Synapse returns it anyway,
but it's not part of the js-sdk types. We don't really need the
server to tell us what the alias is, so just generate it locally
instead.
2022-06-01 09:29:47 +01:00
David Baker
9edc1acc90 Add type to indexeddb variable 2022-06-01 09:07:00 +01:00
Robin Townsend
641e6c53b6 Make the animation smaller 2022-05-31 23:41:05 -04:00
Robin Townsend
14fbddf780 Make PTTButton feel more clickable 2022-05-31 18:08:42 -04:00
Robin Townsend
2a69b72bed Add a VU meter-style animation to radio mode 2022-05-31 18:01:34 -04:00
Robin Townsend
e21094b525 Fix crash when setting audio output on Chrome for Android 2022-05-31 16:21:35 -04:00
Robin Townsend
da3d038547 Make it work on Chrome 2022-05-31 16:11:39 -04:00
Robin Townsend
c6b90803f8 Add spatial audio capabilities 2022-05-31 13:36:15 -04:00
Robin
9444f43c72 Merge pull request #357 from robintown/ts-auth
TypeScriptify the auth directory
2022-05-31 10:35:39 -04:00
Robin Townsend
26251e1e60 Don't abuse useMemo for creating a MatrixClient 2022-05-31 10:33:10 -04:00
Robin Townsend
5b3183cbd3 Make eslint config stricter
now that we can
2022-05-31 10:32:54 -04:00
David Baker
e9b963080c Show when connection is lost on PTT mode 2022-05-30 16:28:16 +01:00
David Baker
1164e6f1e7 Add return type too 2022-05-30 15:53:44 +01:00
David Baker
21c7bb979e Convert useGroupCall to TS 2022-05-30 15:30:57 +01:00
David Baker
1ff9073a1a Sort call feeds consistently when choosing active speaker 2022-05-30 12:14:25 +01:00
David Baker
7ed2f9bd9a Lower timeout on js-sdk API call to 5s 2022-05-30 11:46:27 +01:00
David Baker
2cdbeb6f12 Fix imports 2022-05-30 11:41:59 +01:00
David Baker
7bd95621f1 More types 2022-05-30 11:28:16 +01:00
David Baker
a05501a909 Convert matrix-utils to typescript 2022-05-30 10:09:13 +01:00
Robin Townsend
e6960a1e15 TypeScriptify RegisterPage 2022-05-27 16:55:50 -04:00
Robin Townsend
c057713004 TypeScriptify useInteractiveRegistration 2022-05-27 16:55:50 -04:00
Robin Townsend
35e2135e3c TypeScriptify useInteractiveLogin 2022-05-27 14:52:32 -04:00
Robin Townsend
af74228f8e TypeScriptify useRecaptcha 2022-05-27 10:37:27 -04:00
Robin Townsend
9a44790450 TypeScriptify LoginPage 2022-05-27 10:37:00 -04:00
Robin
5c4bab2a8a Merge pull request #356 from robintown/call-type-dropdown
Add a dropdown to choose between video calls and radio calls
2022-05-27 08:54:38 -04:00
Robin Townsend
94380b64bd Set color-scheme to dark to make the focus ring on the dropdown button
legible
2022-05-26 14:12:25 -04:00
Robin Townsend
cbfd03f9c6 Add a dropdown to choose between video calls and radio calls 2022-05-26 13:52:06 -04:00
Robin
edf58f1d7d Merge pull request #354 from robintown/smooth-dnd
Smoother drag-and-drop
2022-05-25 08:37:14 -04:00
Robin Townsend
17fed7cd9c Prettyify 2022-05-24 16:55:53 -04:00
Robin Townsend
266861bdad Fix order of tiles in 1:1 layout 2022-05-24 16:54:33 -04:00
Robin Townsend
426e1a433b Make drag-and-drop smoother 2022-05-24 16:37:24 -04:00
Robin
3b8dfcec51 Merge pull request #349 from robintown/rate-limit
Handle rate limits when upgrading from a guest account
2022-05-24 07:45:24 -04:00
Robin
6f892edd5e Merge pull request #348 from robintown/limit-width
Limit the width of the remote participant's video in 1:1 layout
2022-05-24 07:45:09 -04:00
Robin
126bfec339 Merge pull request #347 from robintown/prevent-unmute
Prevent video elements from being mistakenly muted/unmuted
2022-05-24 07:44:43 -04:00
Robin Townsend
59938cd46b Handle rate limits when upgrading from a guest account 2022-05-23 10:48:02 -04:00
Robin Townsend
a445bcd0b9 Limit the width of the remote participant's video in 1:1 layout 2022-05-23 09:59:55 -04:00
Robin Townsend
2acb6825e9 Prevent video elements from being mistakenly muted/unmuted 2022-05-23 09:20:34 -04:00
Robin
7d44a1e979 Merge pull request #345 from robintown/unregistered-join-existing
Fix joining an existing room from UnregisteredView
2022-05-20 07:52:47 -04:00
Robin Townsend
aa1fabf857 Fix joining an existing room from UnregisteredView 2022-05-19 15:59:02 -04:00
David Baker
c714a0608c Merge pull request #337 from vector-im/dbkr/bump_js_sdk_for_olm
Bump js-sdk dependency to encrypt to-device messages
2022-05-19 20:52:11 +01:00
David Baker
92d15e110a Update to include https://github.com/matrix-org/matrix-js-sdk/pull/2383 2022-05-19 19:10:31 +01:00
Robin
1367ff9914 Merge pull request #340 from robintown/fix-invite-modal
Fix soft crash when opening invite modal in lobby
2022-05-19 10:46:41 -04:00
Robin
7a2d64c0ef Merge pull request #339 from robintown/room-avatars
Display room avatars
2022-05-19 10:46:24 -04:00
David Baker
60b5f7cab2 Merge pull request #334 from vector-im/dbkr/codeowners
Add CODEOWNERS file
2022-05-19 10:40:54 +01:00
David Baker
d81c52e9bb Merge pull request #329 from vector-im/dbkr/rageshake_ptt
Enable rageshake on PTT mode
2022-05-19 10:40:43 +01:00
Robin Townsend
c54f1bd7a3 Fix soft crash when opening invite modal in lobby 2022-05-18 19:04:59 -04:00
Robin Townsend
24f721e414 Display room avatars 2022-05-18 19:00:59 -04:00
David Baker
3e19843bf7 Bump js-sdk dependency to encrypt to-device messages 2022-05-18 14:53:43 +01:00
Robin
183eea9f24 Merge pull request #336 from robintown/fix-links
Fix links
2022-05-18 08:45:33 -04:00
Robin
548ea7220b Merge pull request #335 from robintown/drag-local-video
Make local video in 1:1 calls draggable
2022-05-18 08:45:22 -04:00
Robin Townsend
8cd45b64a1 Fix links
The href attribute was never actually being set.
2022-05-17 18:30:59 -04:00
Robin
c33d97a2ed Merge pull request #332 from robintown/double-call-name-prompt
Don't leave UnauthenticatedView if there was a room creation error
2022-05-17 17:43:17 -04:00
Robin Townsend
7926a1f9b9 Make local video in 1:1 calls draggable 2022-05-17 17:35:35 -04:00
David Baker
c7da1177ab Add CODEOWNERS file 2022-05-17 18:44:22 +01:00
Robin Townsend
1e5539f165 Don't leave UnauthenticatedView if there was a room creation error 2022-05-17 12:38:01 -04:00
David Baker
d019add257 Merge remote-tracking branch 'origin/main' into dbkr/rageshake_ptt 2022-05-17 15:41:57 +01:00
David Baker
cc8ce7a05c Move feedback button to overflow menu
To be consistent with normal view and avoid nested dialogs.

Also disable space for the PTT key when the feedback dialog is visible,
since otherwise you can't type a space. Involves some rearrangement of
modal state.

Remove accidentally comitted vite port config.
2022-05-17 15:36:13 +01:00
David Baker
6913fddcd3 Merge pull request #303 from vector-im/to-device-olm2
Add support for to-device messages via OLM
2022-05-17 13:33:30 +01:00
David Baker
c13040f0b0 Merge pull request #327 from vector-im/dbkr/end_talk_sound
Add sound when speaker stops speaking
2022-05-16 20:37:38 +01:00
David Baker
b3285974f9 Enable rageshake on PTT mode
By putting another 'Submit Feedback' button in the developer section
of the setting modal (we can work out a better place for it).
2022-05-16 16:58:39 +01:00
David Baker
24a1091954 Merge pull request #325 from vector-im/dbkr/fix_mute_races
Mute local mic if blocked and fix races on mute / unmute
2022-05-16 14:12:15 +01:00
David Baker
9fd7329554 Add sound when speaker stops speaking
And also a slightly nicer blocked sound (ok, I couldn't let it go).
2022-05-13 21:00:14 +01:00
David Baker
2a19a9964d Merge pull request #326 from vector-im/dbkr/ptt_button_touch_fixes
Fixes for touch interface on push-to-talk button
2022-05-13 20:45:22 +01:00
David Baker
3fc9c1b74a Import createref separately 2022-05-13 20:43:20 +01:00
David Baker
f6f0c20b08 Chain promises correctly 2022-05-13 20:39:21 +01:00
David Baker
26a1c165d9 Merge pull request #322 from vector-im/dbkr/blocked_sound_on_timeout
Play the blocked sound on time limit
2022-05-13 19:26:46 +01:00
David Baker
2af87fa8b8 Fixes for touch interface on push-to-talk button
* Avoid also getting a 'mousedown' event by making the event listener
   non-passive so the preventDefault() works
 * Remember the touch that pressed the button so we only un-press
   when that touch ends, otherwise the button gets released if the
   user taps the screen anywhere else.
2022-05-13 18:28:48 +01:00
David Baker
d34c8d08a4 Add comment 2022-05-13 18:09:45 +01:00
David Baker
0f687fb8b8 Fix races on mute / unmute
By serialising everything on a promise chain
2022-05-13 17:58:59 +01:00
David Baker
603dd3786a Play the blocked sound on time limit
Play the 'blocked' sound effect whenever the user is till holding
the button but can't speak anymore, whether they've been cut off
by someone else or have reached their time limit.
2022-05-12 14:13:03 +01:00
David Baker
9fbe4278c2 Merge pull request #321 from vector-im/dbkr/ptt_mobile_touch_prompt
Fix hold-to-speak and prompt text on mobile
2022-05-12 12:16:48 +01:00
David Baker
b222b4f708 Fix hold-to-speak and prompt text on mobile 2022-05-12 12:07:04 +01:00
David Baker
abc2449b07 Merge pull request #320 from vector-im/dbkr/pttcallview_types
More types on PTTCallView
2022-05-12 10:07:59 +01:00
David Baker
e6459de0d9 Merge pull request #319 from vector-im/dbkr/pttsounds
Add push-to-talk sound effects
2022-05-12 10:07:50 +01:00
David Baker
323505fbb4 Put back listeners commented for testing 2022-05-12 10:04:14 +01:00
David Baker
2b06c6f2e6 More types on PTTCallView
Give it a props type
2022-05-11 17:44:26 +01:00
David Baker
5a56e46f7b Prettier 2022-05-11 16:50:41 +01:00
David Baker
abe9ece38f Add push-to-talk sound effects
Fixes https://github.com/vector-im/element-call/issues/296
2022-05-11 16:28:08 +01:00
David Baker
cb8d837370 Fetch redirects file from github 2022-05-11 13:20:35 +01:00
David Baker
500a19d655 Merge pull request #318 from vector-im/dbkr/netlify_redirect
Add redirects to netlify deployment
2022-05-11 13:11:09 +01:00
David Baker
0d3daf5fa3 move config files into config/ 2022-05-11 12:11:49 +01:00
David Baker
66aede01dc Merge pull request #316 from vector-im/dbkr/typescript_round_1
Initial round of typescripting
2022-05-11 11:50:07 +01:00
David Baker
6d7be57dcf More comments 2022-05-11 11:42:17 +01:00
David Baker
5b913205af Add redirects to netlify deployment 2022-05-11 11:39:48 +01:00
David Baker
fd93d89b26 Merge pull request #317 from dbkr/main
Add GHA to deploy main branch to Netlify
2022-05-11 11:13:18 +01:00
David Baker
abdfcd879d Hopefully add sentry to cd builds 2022-05-10 21:24:20 +01:00
Robert Long
b231424f96 Reference vite/svgr types 2022-05-10 13:04:04 -07:00
David Baker
b2418d5384 Put manual deployment updating back
the netlify action's deployment updating doesn't seem to work
2022-05-10 20:48:30 +01:00
David Baker
f2232a0740 Enable production deploys
and also remove the manual deployment updating as it looks like the netlify action supports it anyway
2022-05-10 19:55:05 +01:00
David Baker
04c6d990bd Make dir name match 2022-05-10 19:39:27 +01:00
David Baker
455bb09108 Try bumping token permissions 2022-05-10 19:35:40 +01:00
David Baker
d8fe617535 Try this path 2022-05-10 19:20:15 +01:00
David Baker
970568fd17 yaml indenting 2022-05-10 19:19:22 +01:00
David Baker
f6677889e0 Hopefully deploy main branch to netlify 2022-05-10 19:11:45 +01:00
David Baker
04780ab7aa Seems we can turn noUnusedLocals on after all 2022-05-10 18:12:10 +01:00
David Baker
b7df8019f0 Import request typings
to tell tsc about _Request which has abort()
2022-05-10 18:09:17 +01:00
David Baker
0a9115248d Include more js-sdk types 2022-05-10 17:45:54 +01:00
David Baker
27d492e9e2 Pin js-sdk commit & add olm for types 2022-05-10 17:41:50 +01:00
David Baker
bc22d36ef8 Use the merged js-sdk 2022-05-10 17:22:20 +01:00
David Baker
cf9625f33e Final type fixes
Revert previous type import hack and type a couple more bits
2022-05-10 17:18:26 +01:00
David Baker
446fd9c7c0 Make tsc happy with the js-sdk 2022-05-07 19:02:54 +01:00
David Baker
adc7892d8c Enable type checking & disable lint warning 2022-05-06 22:55:47 +01:00
David Baker
f805f4ead6 Remove some unnecessary tsconfig bits 2022-05-06 22:44:03 +01:00
David Baker
00ffa1b6cd Add types from merge & fix import order 2022-05-06 22:43:22 +01:00
David Baker
055fbe786d Merge remote-tracking branch 'origin/main' into dbkr/typescript_round_1 2022-05-06 21:41:14 +01:00
David Baker
7a561bd034 Merge pull request #315 from vector-im/dbkr/fix_lint_errors
Fix lint errors
2022-05-06 21:35:55 +01:00
David Baker
5fb1f556d5 Prettier 2022-05-06 21:34:58 +01:00
David Baker
f4ba315cef Add more hook dependencies 2022-05-06 21:33:49 +01:00
David Baker
9ba12da544 Merge branch 'main' into dbkr/fix_lint_errors 2022-05-06 21:31:35 +01:00
David Baker
657096fd9a Merge pull request #314 from vector-im/dbkr/vscode_prettier
Set formatter to prettier
2022-05-06 21:29:00 +01:00
David Baker
9374900ce0 Merge pull request #313 from vector-im/dbkr/fix_time_limit
Return to normal state when time limit reached
2022-05-06 21:28:40 +01:00
David Baker
7e5610eb36 Prettier 2022-05-06 21:27:07 +01:00
David Baker
1253638861 Store unmuteError in main state 2022-05-06 21:23:29 +01:00
David Baker
83feb28909 Merge pull request #312 from vector-im/dbkr/license_headers
Add all the license headers
2022-05-06 21:19:44 +01:00
David Baker
5422cb76f1 Merge branch 'main' into dbkr/license_headers 2022-05-06 21:18:45 +01:00
David Baker
a6eb52ae76 Merge pull request #311 from vector-im/dbkr/fix_pt_button_behaviour
Fix mouseup/down behaviour of PTT button
2022-05-06 21:17:50 +01:00
David Baker
4488947eed Initial round of typescripting 2022-05-06 11:32:09 +01:00
David Baker
bf8f164f55 Fix lint errors
Various hooks either missing dependencies or with extra ones.

Two remaining errors are from the recapcta code where I can't
work out if the extra dependency is intentional or not.
2022-05-05 13:15:07 +01:00
David Baker
5487fbc048 Set formatter to prettier
Although this will only work with the extension installed
2022-05-05 12:31:09 +01:00
David Baker
a70dbb130f Run prettier 2022-05-05 12:26:30 +01:00
David Baker
7edf544d73 Return to normal state when time limit reached 2022-05-05 12:22:51 +01:00
David Baker
ad3bde9920 Undo unintentionally commented line 2022-05-04 17:36:35 +01:00
David Baker
85a98b3706 Remove onWindowBlur
we already do this in usePTT
2022-05-04 17:35:43 +01:00
David Baker
85e3f3761a Add all the license headers 2022-05-04 17:09:48 +01:00
David Baker
f0b116714b Fix mouseup/down behaviour of PTT button
Handle mouseup events anywhere so the button releases if you move
the cursor out of the button & release. Likewsie for window losing
focus.
2022-05-04 16:52:45 +01:00
David Baker
dbef06269b Merge pull request #310 from vector-im/ptt
Add feature-flagged support for Radio/PTT Mode
2022-05-04 11:40:26 +01:00
David Baker
894815268a Merge remote-tracking branch 'origin/main' into ptt 2022-05-04 11:37:52 +01:00
David Baker
8ecec0bc7e 3 more warnings in the PTT stuff 2022-05-04 11:35:33 +01:00
David Baker
66839e02f6 Add ESLint support too 2022-05-04 11:35:15 +01:00
David Baker
bad8f36bf5 Add prettier support
+ CI to check formatting, and fix the couple of instances that
were not in prettier format (case in HTML clour codes).
2022-05-04 11:35:15 +01:00
Robert Long
f5c50230a9 Enable source-maps 2022-05-04 11:34:28 +01:00
David Baker
0136fd3cab Run prettier 2022-05-04 11:24:25 +01:00
Robert Long
2d18953344 Merge pull request #309 from vector-im/dbkr/prettier
Add prettier & ESLint support
2022-05-03 10:36:08 -07:00
Robert Long
d930ab869a Merge pull request #308 from vector-im/dbkr/ptt_enable_flag
Put PTT behind 'feature flag'
2022-05-03 10:34:01 -07:00
Robert Long
dbdb82bd74 Switch to useShouldShowPtt hook 2022-05-03 10:32:06 -07:00
David Baker
61309bacd9 Add ESLint support too 2022-05-03 15:32:16 +01:00
David Baker
b3e88d33a7 Add prettier support
+ CI to check formatting, and fix the couple of instances that
were not in prettier format (case in HTML clour codes).
2022-05-03 14:24:04 +01:00
David Baker
73fda641c8 Switch js-sdk depdendency back to group-call branch 2022-05-03 13:19:57 +01:00
David Baker
be01a4bd81 Commit missed file 2022-05-03 12:05:40 +01:00
David Baker
0814e3c905 Revert unintentional commit 2022-05-03 12:05:22 +01:00
Robert Long
c7dd2e2093 Merge pull request #307 from vector-im/dbkr/fix_toggle
Fix toggle button toggling
2022-05-02 11:30:05 -07:00
Robert Long
cfa525f957 Merge pull request #306 from vector-im/dbkr/button_for_ptt
Wire up pressing the PTT button to unmute as well as spacebar
2022-05-02 11:28:00 -07:00
David Baker
43d579744f Put PTT behind 'feature flag'
AKA does the URL hash start with '#ptt'

This will let us merge PTT back to the main branch
2022-04-29 19:25:00 +01:00
David Baker
48a008093b Fix toggle button toggling
Just use isSelected directly rather than makking the button have its
own state. Also, the isPressed from useToggleButton looks like its
whether the user has the mouse button down on it or not rather than
whether the toggle switch is on, which was making the state wrong.
2022-04-29 19:08:32 +01:00
David Baker
70c099c4b5 Wire up pressing the PTT button to unmute as well as spacebar 2022-04-29 18:56:17 +01:00
Robert Long
363f2340a0 Finish basic ptt implemenation 2022-04-28 17:44:50 -07:00
Robert Long
3a6346aa63 Create a voice group call when using ptt 2022-04-28 11:13:20 -07:00
Robert Long
9ef9680e07 Fix PTT button alignment 2022-04-28 11:13:01 -07:00
Robert Long
e3cec93669 Add basic mobile styling 2022-04-27 17:19:58 -07:00
Robert Long
b6c926d2c8 Additional in-room PTT styling 2022-04-27 16:47:23 -07:00
Robert Long
c430ebb3a3 Finish first pass at PTT lobby UI 2022-04-27 15:18:55 -07:00
Robert Long
ae13814449 Merge pull request #305 from vector-im/enable-source-maps
Enable source-maps
2022-04-27 13:51:40 -07:00
Robert Long
7a9ff98550 Add OLM_OPTIONS global TODO 2022-04-27 13:51:08 -07:00
Robert Long
3d54047f87 Fix Olm import 2022-04-27 13:38:16 -07:00
Robert Long
dc75c1cfb4 Enable source-maps 2022-04-27 12:11:59 -07:00
Robert Long
e2aee0be81 Fix olm import 2022-04-26 16:28:21 -07:00
Robert Long
44486aa62d Fix building olm library in production 2022-04-26 16:11:32 -07:00
Robert Long
a0e4de73cc Add support for to-device messages via OLM 2022-04-26 15:20:06 -07:00
Robert Long
38f9a79bd3 Initial PTT designs 2022-04-22 18:05:48 -07:00
Robert Long
fc1aaf02bf Use dbkr/ptt matrix-js-sdk package 2022-04-22 11:15:39 -07:00
135 changed files with 5135 additions and 1170 deletions

21
.env
View File

@@ -14,12 +14,15 @@
# VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 # VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
# VITE_CUSTOM_THEME=true # VITE_CUSTOM_THEME=true
# VITE_PRIMARY_COLOR=#0dbd8b # VITE_THEME_ACCENT=#0dbd8b
# VITE_BG_COLOR_1=#ffffff # VITE_THEME_ACCENT_20=#0dbd8b33
# VITE_BG_COLOR_2=#f0f1f4 # VITE_THEME_ALERT=#ff5b55
# VITE_BG_COLOR_3=#dbdfe4 # VITE_THEME_ALERT_20=#ff5b5533
# VITE_BG_COLOR_4=#d1d3d7 # VITE_THEME_LINKS=#0086e6
# VITE_INPUT_BORDER_COLOR=#e7e7e7 # VITE_THEME_PRIMARY_CONTENT=#ffffff
# VITE_INPUT_BORDER_COLOR_FOCUSED=#238cf5 # VITE_THEME_SECONDARY_CONTENT=#a9b2bc
# VITE_TEXT_COLOR_1=#17191c # VITE_THEME_TERTIARY_CONTENT=#8e99a4
# VITE_TEXT_COLOR_2=#61708b # VITE_THEME_QUATERNARY_CONTENT=#6f7882
# VITE_THEME_QUINARY_CONTENT=#394049
# VITE_THEME_SYSTEM=#21262c
# VITE_THEME_BACKGROUND=#15191e

38
.eslintrc.js Normal file
View File

@@ -0,0 +1,38 @@
module.exports = {
plugins: [
"matrix-org",
],
extends: [
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"prettier",
],
env: {
browser: true,
node: true,
},
parserOptions: {
"ecmaVersion": "latest",
"sourceType": "module",
},
rules: {
"jsx-a11y/media-has-caption": ["off"],
},
overrides: [
{
files: [
"src/**/*.{ts,tsx}",
],
extends: [
"plugin:matrix-org/typescript",
"plugin:matrix-org/react",
"prettier",
],
},
],
settings: {
react: {
version: "detect",
},
},
};

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @vector-im/element-call-reviewers

31
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Build
on:
push:
branches: [main]
env:
VITE_DEFAULT_HOMESERVER: "https://call.ems.host"
VITE_SENTRY_DSN: https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
VITE_SENTRY_ENVIRONMENT: main-branch-cd
VITE_RAGESHAKE_SUBMIT_URL: https://element.io/bugreports/submit
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Yarn cache
uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install dependencies
run: "yarn install"
- name: Build
run: "yarn run build"
- name: Upload Artifact
uses: actions/upload-artifact@v2
with:
name: build
path: dist
# We'll only use this in a triggered job, then we're done with it
retention-days: 1

22
.github/workflows/lint.yaml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Lint, format & type check
on:
pull_request: {}
jobs:
prettier:
name: Lint, format & type check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Yarn cache
uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install dependencies
run: "yarn install"
- name: Prettier
run: "yarn run prettier:check"
- name: ESLint
run: "yarn run lint:js"
- name: Type check
run: "yarn run lint:types"

79
.github/workflows/netlify-main.yaml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: Netlify Main
on:
workflow_run:
workflows: ["Build"]
types:
- completed
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
deployments: write
if: github.event.workflow_run.conclusion == 'success'
steps:
- name: Create Deployment
uses: bobheadxi/deployments@v1
id: deployment
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: main-branch-cd
ref: ${{ github.event.workflow_run.head_sha }}
- name: 'Download artifact'
uses: actions/github-script@v3.1.0
with:
script: |
const artifacts = await github.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "build"
})[0];
const download = await github.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
const fs = require('fs');
fs.writeFileSync('${{github.workspace}}/build.zip', Buffer.from(download.data));
- name: Extract Artifacts
run: unzip -d dist build.zip && rm build.zip
- name: Add redirects file
# We fetch from github directly as we don't bother checking out the repo
run: curl -s https://raw.githubusercontent.com/vector-im/element-call/main/config/netlify_redirects > dist/_redirects
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@v1.2.3
with:
publish-dir: dist
deploy-message: "Deploy from GitHub Actions"
production-branch: main
production-deploy: true
# These don't work because we're in workflow_run
enable-pull-request-comment: false
enable-commit-comment: false
github-deployment-environment: main
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
timeout-minutes: 1
- name: Update deployment status
uses: bobheadxi/deployments@v1
if: always()
with:
step: finish
override: false
token: ${{ secrets.GITHUB_TOKEN }}
status: ${{ job.status }}
env: ${{ steps.deployment.outputs.env }}
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
env_url: ${{ steps.netlify.outputs.deploy-url }}

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

1
.prettierrc.json Normal file
View File

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

View File

@@ -1,5 +1,6 @@
{ {
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.insertSpaces": true, "editor.insertSpaces": true,
"editor.tabSize": 2 "editor.tabSize": 2
} }

View File

@@ -9,7 +9,7 @@ RUN element-call/scripts/dockerbuild.sh
FROM nginxinc/nginx-unprivileged:alpine FROM nginxinc/nginx-unprivileged:alpine
COPY --from=builder /src/element-call/dist /app COPY --from=builder /src/element-call/dist /app
COPY scripts/default.conf /etc/nginx/conf.d/ COPY config/default.conf /etc/nginx/conf.d/
USER root USER root

4
config/netlify_redirects Normal file
View File

@@ -0,0 +1,4 @@
# This file is copied to the netlify deploy dir in the upload stage
# Redirect any unknown path to index.html
/* /index.html 200

View File

@@ -5,10 +5,15 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"storybook": "start-storybook -p 6006", "storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook" "build-storybook": "build-storybook",
"prettier:check": "prettier -c src",
"prettier:format": "prettier -w src",
"lint:js": "eslint --max-warnings 0 src",
"lint:types": "tsc"
}, },
"dependencies": { "dependencies": {
"@juggle/resize-observer": "^3.3.1", "@juggle/resize-observer": "^3.3.1",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@react-aria/button": "^3.3.4", "@react-aria/button": "^3.3.4",
"@react-aria/dialog": "^3.1.4", "@react-aria/dialog": "^3.1.4",
"@react-aria/focus": "^3.5.0", "@react-aria/focus": "^3.5.0",
@@ -26,12 +31,12 @@
"@react-stately/tree": "^3.2.0", "@react-stately/tree": "^3.2.0",
"@sentry/react": "^6.13.3", "@sentry/react": "^6.13.3",
"@sentry/tracing": "^6.13.3", "@sentry/tracing": "^6.13.3",
"@types/grecaptcha": "^3.0.4",
"@use-gesture/react": "^10.2.11", "@use-gesture/react": "^10.2.11",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"color-hash": "^2.0.1", "color-hash": "^2.0.1",
"events": "^3.3.0", "events": "^3.3.0",
"lodash-move": "^1.1.1", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#aa0d3bd1f5a006d151f826e6b8c5f286abb6e960",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#robertlong/group-call",
"mermaid": "^8.13.8", "mermaid": "^8.13.8",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"pako": "^2.0.4", "pako": "^2.0.4",
@@ -48,10 +53,24 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.16.5", "@babel/core": "^7.16.5",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@storybook/react": "^6.5.0-alpha.5", "@storybook/react": "^6.5.0-alpha.5",
"@types/request": "^2.48.8",
"@typescript-eslint/eslint-plugin": "^5.22.0",
"@typescript-eslint/parser": "^5.22.0",
"babel-loader": "^8.2.3", "babel-loader": "^8.2.3",
"eslint": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "^0.4.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0",
"prettier": "^2.6.2",
"sass": "^1.42.1", "sass": "^1.42.1",
"storybook-builder-vite": "^0.1.12", "storybook-builder-vite": "^0.1.12",
"typescript": "^4.6.4",
"vite": "^2.4.2", "vite": "^2.4.2",
"vite-plugin-html-template": "^1.1.0", "vite-plugin-html-template": "^1.1.0",
"vite-plugin-svgr": "^0.4.0" "vite-plugin-svgr": "^0.4.0"

24
src/@types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
/*
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 "matrix-js-sdk/src/@types/global";
declare global {
interface Window {
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
OLM_OPTIONS: Record<string, string>;
}
}

2
src/@types/modules.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />

View File

@@ -1,58 +0,0 @@
import React, { useMemo } from "react";
import classNames from "classnames";
import styles from "./Avatar.module.css";
const backgroundColors = [
"#5C56F5",
"#03B381",
"#368BD6",
"#AC3BA8",
"#E64F7A",
"#FF812D",
"#2DC2C5",
"#74D12C",
];
function hashStringToArrIndex(str, arrLength) {
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
return sum % arrLength;
}
export function Avatar({
bgKey,
src,
fallback,
size,
className,
style,
...rest
}) {
const backgroundColor = useMemo(() => {
const index = hashStringToArrIndex(
bgKey || fallback || src || "",
backgroundColors.length
);
return backgroundColors[index];
}, [bgKey, src, fallback]);
return (
<div
className={classNames(styles.avatar, styles[size || "md"], className)}
style={{ backgroundColor, ...style }}
{...rest}
>
{src ? (
<img src={src} />
) : typeof fallback === "string" ? (
<span>{fallback}</span>
) : (
fallback
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
.avatar { .avatar {
position: relative; position: relative;
color: #ffffff; color: var(--primary-content);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -17,7 +17,7 @@
} }
.avatar svg * { .avatar svg * {
fill: #ffffff; fill: var(--primary-content);
} }
.avatar span { .avatar span {

115
src/Avatar.tsx Normal file
View File

@@ -0,0 +1,115 @@
import React, { useMemo, CSSProperties } from "react";
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { getAvatarUrl } from "./matrix-utils";
import { useClient } from "./ClientContext";
import styles from "./Avatar.module.css";
const backgroundColors = [
"#5C56F5",
"#03B381",
"#368BD6",
"#AC3BA8",
"#E64F7A",
"#FF812D",
"#2DC2C5",
"#74D12C",
];
export enum Size {
XS = "xs",
SM = "sm",
MD = "md",
LG = "lg",
XL = "xl",
}
export const sizes = new Map([
[Size.XS, 22],
[Size.SM, 32],
[Size.MD, 36],
[Size.LG, 42],
[Size.XL, 90],
]);
function hashStringToArrIndex(str: string, arrLength: number) {
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
return sum % arrLength;
}
const resolveAvatarSrc = (client: MatrixClient, src: string, size: number) =>
src?.startsWith("mxc://") ? client && getAvatarUrl(client, src, size) : src;
interface Props extends React.HTMLAttributes<HTMLDivElement> {
bgKey?: string;
src: string;
fallback: string;
size?: Size | number;
className: string;
style?: CSSProperties;
}
export const Avatar: React.FC<Props> = ({
bgKey,
src,
fallback,
size = Size.MD,
className,
style = {},
...rest
}) => {
const { client } = useClient();
const [sizeClass, sizePx, sizeStyle] = useMemo(
() =>
Object.values(Size).includes(size as Size)
? [styles[size as string], sizes.get(size as Size), {}]
: [
null,
size as number,
{
width: size,
height: size,
borderRadius: size,
fontSize: Math.round((size as number) / 2),
},
],
[size]
);
const resolvedSrc = useMemo(
() => resolveAvatarSrc(client, src, sizePx),
[client, src, sizePx]
);
const backgroundColor = useMemo(() => {
const index = hashStringToArrIndex(
bgKey || fallback || src || "",
backgroundColors.length
);
return backgroundColors[index];
}, [bgKey, src, fallback]);
/* eslint-disable jsx-a11y/alt-text */
return (
<div
className={classNames(styles.avatar, sizeClass, className)}
style={{ backgroundColor, ...sizeStyle, ...style }}
{...rest}
>
{resolvedSrc ? (
<img src={resolvedSrc} />
) : typeof fallback === "string" ? (
<span>{fallback}</span>
) : (
fallback
)}
</div>
);
};

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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React, { import React, {
FC,
useCallback, useCallback,
useEffect, useEffect,
useState, useState,
@@ -23,17 +24,59 @@ import React, {
useContext, useContext,
} from "react"; } from "react";
import { useHistory } from "react-router-dom"; 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 { ErrorView } from "./FullScreenView"; import { ErrorView } from "./FullScreenView";
import { initClient, defaultHomeserver } from "./matrix-utils"; import { initClient, defaultHomeserver } from "./matrix-utils";
const ClientContext = createContext(); declare global {
interface Window {
matrixclient: MatrixClient;
}
}
export function ClientProvider({ children }) { export interface Session {
user_id: string;
device_id: string;
access_token: string;
passwordlessUser: boolean;
tempPassword?: string;
}
const loadSession = (): Session => {
const data = localStorage.getItem("matrix-auth-store");
if (data) return JSON.parse(data);
return null;
};
const saveSession = (session: Session) =>
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
const clearSession = () => localStorage.removeItem("matrix-auth-store");
interface ClientState {
loading: boolean;
isAuthenticated: boolean;
isPasswordlessUser: boolean;
client: MatrixClient;
userName: string;
changePassword: (password: string) => Promise<void>;
logout: () => void;
setClient: (client: MatrixClient, session: Session) => void;
}
const ClientContext = createContext<ClientState>(null);
type ClientProviderState = Omit<
ClientState,
"changePassword" | "logout" | "setClient"
> & { error?: Error };
export const ClientProvider: FC = ({ children }) => {
const history = useHistory(); const history = useHistory();
const [ const [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error }, { loading, isAuthenticated, isPasswordlessUser, client, userName, error },
setState, setState,
] = useState({ ] = useState<ClientProviderState>({
loading: true, loading: true,
isAuthenticated: false, isAuthenticated: false,
isPasswordlessUser: false, isPasswordlessUser: false,
@@ -43,18 +86,16 @@ export function ClientProvider({ children }) {
}); });
useEffect(() => { useEffect(() => {
async function restore() { const restore = async (): Promise<
Pick<ClientProviderState, "client" | "isPasswordlessUser">
> => {
try { try {
const authStore = localStorage.getItem("matrix-auth-store"); const session = loadSession();
if (authStore) { if (session) {
const { /* eslint-disable camelcase */
user_id, const { user_id, device_id, access_token, passwordlessUser } =
device_id, session;
access_token,
passwordlessUser,
tempPassword,
} = JSON.parse(authStore);
const client = await initClient({ const client = await initClient({
baseUrl: defaultHomeserver, baseUrl: defaultHomeserver,
@@ -62,37 +103,26 @@ export function ClientProvider({ children }) {
userId: user_id, userId: user_id,
deviceId: device_id, deviceId: device_id,
}); });
/* eslint-enable camelcase */
localStorage.setItem( return { client, isPasswordlessUser: passwordlessUser };
"matrix-auth-store",
JSON.stringify({
user_id,
device_id,
access_token,
passwordlessUser,
tempPassword,
})
);
return { client, passwordlessUser };
} }
return { client: undefined }; return { client: undefined, isPasswordlessUser: false };
} catch (err) { } catch (err) {
console.error(err); console.error(err);
localStorage.removeItem("matrix-auth-store"); clearSession();
throw err; throw err;
} }
} };
restore() restore()
.then(({ client, passwordlessUser }) => { .then(({ client, isPasswordlessUser }) => {
setState({ setState({
client, client,
loading: false, loading: false,
isAuthenticated: !!client, isAuthenticated: Boolean(client),
isPasswordlessUser: !!passwordlessUser, isPasswordlessUser,
userName: client?.getUserIdLocalpart(), userName: client?.getUserIdLocalpart(),
}); });
}) })
@@ -108,31 +138,23 @@ export function ClientProvider({ children }) {
}, []); }, []);
const changePassword = useCallback( const changePassword = useCallback(
async (password) => { async (password: string) => {
const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse( const { tempPassword, ...session } = loadSession();
localStorage.getItem("matrix-auth-store")
);
await client.setPassword( await client.setPassword(
{ {
type: "m.login.password", type: "m.login.password",
identifier: { identifier: {
type: "m.id.user", type: "m.id.user",
user: existingSession.user_id, user: session.user_id,
}, },
user: existingSession.user_id, user: session.user_id,
password: tempPassword, password: tempPassword,
}, },
password password
); );
localStorage.setItem( saveSession({ ...session, passwordlessUser: false });
"matrix-auth-store",
JSON.stringify({
...existingSession,
passwordlessUser: false,
})
);
setState({ setState({
client, client,
@@ -146,23 +168,23 @@ export function ClientProvider({ children }) {
); );
const setClient = useCallback( const setClient = useCallback(
(newClient, session) => { (newClient: MatrixClient, session: Session) => {
if (client && client !== newClient) { if (client && client !== newClient) {
client.stopClient(); client.stopClient();
} }
if (newClient) { if (newClient) {
localStorage.setItem("matrix-auth-store", JSON.stringify(session)); saveSession(session);
setState({ setState({
client: newClient, client: newClient,
loading: false, loading: false,
isAuthenticated: true, isAuthenticated: true,
isPasswordlessUser: !!session.passwordlessUser, isPasswordlessUser: session.passwordlessUser,
userName: newClient.getUserIdLocalpart(), userName: newClient.getUserIdLocalpart(),
}); });
} else { } else {
localStorage.removeItem("matrix-auth-store"); clearSession();
setState({ setState({
client: undefined, client: undefined,
@@ -177,29 +199,23 @@ export function ClientProvider({ children }) {
); );
const logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem("matrix-auth-store"); clearSession();
window.location = "/"; history.push("/");
}, [history]); }, [history]);
useEffect(() => { useEffect(() => {
if (client) { if (client) {
const loadTime = Date.now(); const loadTime = Date.now();
const onToDeviceEvent = (event) => { const onToDeviceEvent = (event: MatrixEvent) => {
if (event.getType() !== "org.matrix.call_duplicate_session") { if (event.getType() !== "org.matrix.call_duplicate_session") return;
return;
}
const content = event.getContent(); const content = event.getContent();
if (content.session_id === client.getSessionId()) { if (content.session_id === client.getSessionId()) return;
return;
}
if (content.timestamp > loadTime) { if (content.timestamp > loadTime) {
if (client) { client?.stopClient();
client.stopClient();
}
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
@@ -210,7 +226,7 @@ export function ClientProvider({ children }) {
} }
}; };
client.on("toDeviceEvent", onToDeviceEvent); client.on(ClientEvent.ToDeviceEvent, onToDeviceEvent);
client.sendToDevice("org.matrix.call_duplicate_session", { client.sendToDevice("org.matrix.call_duplicate_session", {
[client.getUserId()]: { [client.getUserId()]: {
@@ -219,12 +235,12 @@ export function ClientProvider({ children }) {
}); });
return () => { return () => {
client.removeListener("toDeviceEvent", onToDeviceEvent); client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
}; };
} }
}, [client]); }, [client]);
const context = useMemo( const context = useMemo<ClientState>(
() => ({ () => ({
loading, loading,
isAuthenticated, isAuthenticated,
@@ -258,8 +274,6 @@ export function ClientProvider({ children }) {
return ( return (
<ClientContext.Provider value={context}>{children}</ClientContext.Provider> <ClientContext.Provider value={context}>{children}</ClientContext.Provider>
); );
} };
export function useClient() { export const useClient = () => useContext(ClientContext);
return useContext(ClientContext);
}

View File

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

View File

@@ -1,11 +1,26 @@
.facepile { .facepile {
width: 100%; width: 100%;
height: 24px;
position: relative; position: relative;
} }
.facepile.xs {
height: 24px;
}
.facepile.sm {
height: 32px;
}
.facepile.md {
height: 36px;
}
.facepile .avatar { .facepile .avatar {
position: absolute; position: absolute;
top: 0; top: 0;
border: 1px solid var(--bgColor2); border: 1px solid var(--system);
}
.facepile.md .avatar {
border-width: 2px;
} }

View File

@@ -57,12 +57,13 @@ export function HeaderLogo({ className }) {
); );
} }
export function RoomHeaderInfo({ roomName }) { export function RoomHeaderInfo({ roomName, avatarUrl }) {
return ( return (
<> <>
<div className={styles.roomAvatar}> <div className={styles.roomAvatar}>
<Avatar <Avatar
size="md" size="md"
src={avatarUrl}
bgKey={roomName} bgKey={roomName}
fallback={roomName.slice(0, 1).toUpperCase()} fallback={roomName.slice(0, 1).toUpperCase()}
/> />
@@ -73,13 +74,13 @@ export function RoomHeaderInfo({ roomName }) {
); );
} }
export function RoomSetupHeaderInfo({ roomName, ...rest }) { export function RoomSetupHeaderInfo({ roomName, avatarUrl, ...rest }) {
const ref = useRef(); const ref = useRef();
const { buttonProps } = useButton(rest, ref); const { buttonProps } = useButton(rest, ref);
return ( return (
<button className={styles.backButton} ref={ref} {...buttonProps}> <button className={styles.backButton} ref={ref} {...buttonProps}>
<ArrowLeftIcon width={16} height={16} /> <ArrowLeftIcon width={16} height={16} />
<RoomHeaderInfo roomName={roomName} /> <RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</button> </button>
); );
} }

View File

@@ -70,7 +70,7 @@
background: transparent; background: transparent;
border: none; border: none;
display: flex; display: flex;
color: var(--textColor1); color: var(--primary-content);
cursor: pointer; cursor: pointer;
align-items: center; align-items: center;
} }

5
src/IndexedDBWorker.js Normal file
View File

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

View File

@@ -5,8 +5,8 @@
overflow-y: auto; overflow-y: auto;
list-style: none; list-style: none;
background-color: transparent; background-color: transparent;
border: 1px solid var(--inputBorderColor); border: 1px solid var(--quinary-content);
background-color: var(--bgColor1); background-color: var(--background);
border-radius: 8px; border-radius: 8px;
} }
@@ -15,7 +15,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: transparent; background-color: transparent;
color: var(--textColor1); color: var(--primary-content);
padding: 8px 16px; padding: 8px 16px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
@@ -28,6 +28,6 @@
} }
.option.disabled { .option.disabled {
color: var(--textColor2); color: var(--quaternary-content);
background-color: var(--bgColor3); background-color: var(--bgColor3);
} }

View File

@@ -11,7 +11,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 12px; padding: 0 12px;
color: var(--textColor1); color: var(--primary-content);
font-size: 14px; font-size: 14px;
} }
@@ -25,7 +25,7 @@
.menuItem.focused, .menuItem.focused,
.menuItem:hover { .menuItem:hover {
background-color: var(--bgColor4); background-color: var(--quinary-content);
} }
.menuItem.focused:first-child, .menuItem.focused:first-child,
@@ -39,3 +39,12 @@
border-bottom-left-radius: 8px; border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px; border-bottom-right-radius: 8px;
} }
.checkIcon {
position: absolute;
right: 16px;
}
.checkIcon * {
stroke: var(--primary-content);
}

View File

@@ -1,10 +1,10 @@
.tooltip { .tooltip {
background-color: var(--bgColor2); background-color: var(--system);
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 8px 10px; padding: 8px 10px;
color: var(--textColor1); color: var(--primary-content);
border-radius: 8px; border-radius: 8px;
max-width: 135px; max-width: 135px;
width: max-content; width: max-content;

View File

@@ -4,7 +4,7 @@
} }
.userButton svg * { .userButton svg * {
fill: var(--textColor1); fill: var(--primary-content);
} }
.avatar { .avatar {

View File

@@ -65,7 +65,7 @@
} }
.authLinks a { .authLinks a {
color: #0dbd8b; color: var(--accent);
text-decoration: none; text-decoration: none;
font-weight: normal; font-weight: normal;
} }

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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -14,9 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useCallback, useRef, useState, useMemo } from "react"; import React, {
FC,
FormEvent,
useCallback,
useRef,
useState,
useMemo,
} from "react";
import { useHistory, useLocation, Link } from "react-router-dom"; import { useHistory, useLocation, Link } from "react-router-dom";
import { ReactComponent as Logo } from "../icons/LogoLarge.svg"; import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { useClient } from "../ClientContext";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils"; import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils";
@@ -24,27 +33,30 @@ import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin"; import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
export function LoginPage() { export const LoginPage: FC = () => {
usePageTitle("Login"); usePageTitle("Login");
const [_, login] = useInteractiveLogin(); const { setClient } = useClient();
const [homeserver, setHomeServer] = useState(defaultHomeserver); const login = useInteractiveLogin();
const usernameRef = useRef(); const homeserver = defaultHomeserver; // TODO: Make this configurable
const passwordRef = useRef(); const usernameRef = useRef<HTMLInputElement>();
const passwordRef = useRef<HTMLInputElement>();
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState<Error>();
// TODO: Handle hitting login page with authenticated client // TODO: Handle hitting login page with authenticated client
const onSubmitLoginForm = useCallback( const onSubmitLoginForm = useCallback(
(e) => { (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
login(homeserver, usernameRef.current.value, passwordRef.current.value) login(homeserver, usernameRef.current.value, passwordRef.current.value)
.then(() => { .then(([client, session]) => {
setClient(client, session);
if (location.state && location.state.from) { if (location.state && location.state.from) {
history.push(location.state.from); history.push(location.state.from);
} else { } else {
@@ -56,13 +68,13 @@ export function LoginPage() {
setLoading(false); setLoading(false);
}); });
}, },
[login, location, history, homeserver] [login, location, history, homeserver, setClient]
); );
const homeserverHost = useMemo(() => { const homeserverHost = useMemo(() => {
try { try {
return new URL(homeserver).host; return new URL(homeserver).host;
} catch (_error) { } catch (error) {
return defaultHomeserverHost; return defaultHomeserverHost;
} }
}, [homeserver]); }, [homeserver]);
@@ -121,4 +133,4 @@ export function LoginPage() {
</div> </div>
</> </>
); );
} };

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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -14,8 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, {
FC,
FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useHistory, useLocation } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom";
import { captureException } from "@sentry/react";
import { sleep } from "matrix-js-sdk/src/utils";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
@@ -28,67 +38,68 @@ import { useRecaptcha } from "./useRecaptcha";
import { Caption, Link } from "../typography/Typography"; import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
export function RegisterPage() { export const RegisterPage: FC = () => {
usePageTitle("Register"); usePageTitle("Register");
const { loading, isAuthenticated, isPasswordlessUser, client } = useClient(); const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
const confirmPasswordRef = useRef(); useClient();
const confirmPasswordRef = useRef<HTMLInputElement>();
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const [registering, setRegistering] = useState(false); const [registering, setRegistering] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState<Error>();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState(""); const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [{ privacyPolicyUrl, recaptchaKey }, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback( const onSubmitRegisterForm = useCallback(
(e) => { (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const data = new FormData(e.target); const data = new FormData(e.target as HTMLFormElement);
const userName = data.get("userName"); const userName = data.get("userName") as string;
const password = data.get("password"); const password = data.get("password") as string;
const passwordConfirmation = data.get("passwordConfirmation"); const passwordConfirmation = data.get("passwordConfirmation") as string;
if (password !== passwordConfirmation) { if (password !== passwordConfirmation) return;
return;
}
async function submit() { const submit = async () => {
setRegistering(true); setRegistering(true);
let roomIds;
if (client && isPasswordlessUser) {
const groupCalls = client.groupCallEventHandler.groupCalls.values();
roomIds = Array.from(groupCalls).map(
(groupCall) => groupCall.room.roomId
);
}
const recaptchaResponse = await execute(); const recaptchaResponse = await execute();
const newClient = await register( const [newClient, session] = await register(
userName, userName,
password, password,
userName, userName,
recaptchaResponse recaptchaResponse
); );
if (roomIds) { if (client && isPasswordlessUser) {
for (const roomId of roomIds) { // Migrate the user's rooms
for (const groupCall of client.groupCallEventHandler.groupCalls.values()) {
const roomId = groupCall.room.roomId;
try { try {
await newClient.joinRoom(roomId); await newClient.joinRoom(roomId);
} catch (error) { } catch (error) {
console.warn(`Couldn't join room ${roomId}`, error); if (error.errcode === "M_LIMIT_EXCEEDED") {
await sleep(error.data.retry_after_ms);
await newClient.joinRoom(roomId);
} else {
captureException(error);
console.error(`Couldn't join room ${roomId}`, error);
}
} }
} }
} }
}
setClient(newClient, session);
};
submit() submit()
.then(() => { .then(() => {
if (location.state && location.state.from) { if (location.state?.from) {
history.push(location.state.from); history.push(location.state.from);
} else { } else {
history.push("/"); history.push("/");
@@ -100,18 +111,23 @@ export function RegisterPage() {
reset(); reset();
}); });
}, },
[register, location, history, isPasswordlessUser, reset, execute, client] [
register,
location,
history,
isPasswordlessUser,
reset,
execute,
client,
setClient,
]
); );
useEffect(() => { useEffect(() => {
if (!confirmPasswordRef.current) {
return;
}
if (password && passwordConfirmation && password !== passwordConfirmation) { if (password && passwordConfirmation && password !== passwordConfirmation) {
confirmPasswordRef.current.setCustomValidity("Passwords must match"); confirmPasswordRef.current?.setCustomValidity("Passwords must match");
} else { } else {
confirmPasswordRef.current.setCustomValidity(""); confirmPasswordRef.current?.setCustomValidity("");
} }
}, [password, passwordConfirmation]); }, [password, passwordConfirmation]);
@@ -119,7 +135,7 @@ export function RegisterPage() {
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) { if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
history.push("/"); history.push("/");
} }
}, [history, isAuthenticated, isPasswordlessUser, registering]); }, [loading, history, isAuthenticated, isPasswordlessUser, registering]);
if (loading) { if (loading) {
return <LoadingView />; return <LoadingView />;
@@ -207,4 +223,4 @@ export function RegisterPage() {
</div> </div>
</> </>
); );
} };

View File

@@ -1,8 +1,25 @@
/*
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 { import {
uniqueNamesGenerator, uniqueNamesGenerator,
adjectives, adjectives,
colors, colors,
animals, animals,
Config,
} from "unique-names-generator"; } from "unique-names-generator";
const elements = [ const elements = [
@@ -126,7 +143,7 @@ const elements = [
"oganesson", "oganesson",
]; ];
export function generateRandomName(config) { export function generateRandomName(config: Config): string {
return uniqueNamesGenerator({ return uniqueNamesGenerator({
dictionaries: [colors, adjectives, animals, elements], dictionaries: [colors, adjectives, animals, elements],
style: "lowerCase", style: "lowerCase",

View File

@@ -1,45 +0,0 @@
import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
import { useState, useCallback } from "react";
import { useClient } from "../ClientContext";
import { initClient, defaultHomeserver } from "../matrix-utils";
export function useInteractiveLogin() {
const { setClient } = useClient();
const [state, setState] = useState({ loading: false });
const auth = useCallback(async (homeserver, username, password) => {
const authClient = matrix.createClient(homeserver);
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient,
busyChanged(loading) {
setState((prev) => ({ ...prev, loading }));
},
async doRequest(_auth, _background) {
return authClient.login("m.login.password", {
identifier: {
type: "m.id.user",
user: username,
},
password,
});
},
});
const { user_id, access_token, device_id } =
await interactiveAuth.attemptAuth();
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
setClient(client, { user_id, access_token, device_id });
return client;
}, []);
return [state, auth];
}

View File

@@ -0,0 +1,69 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback } from "react";
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import { initClient, defaultHomeserver } from "../matrix-utils";
import { Session } from "../ClientContext";
export const useInteractiveLogin = () =>
useCallback<
(
homeserver: string,
username: string,
password: string
) => Promise<[MatrixClient, Session]>
>(async (homeserver: string, username: string, password: string) => {
const authClient = createClient(homeserver);
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient,
doRequest: () =>
authClient.login("m.login.password", {
identifier: {
type: "m.id.user",
user: username,
},
password,
}),
stateUpdated: null,
requestEmailToken: null,
});
// XXX: This claims to return an IAuthData which contains none of these
// things - the js-sdk types may be wrong?
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
const { user_id, access_token, device_id } =
(await interactiveAuth.attemptAuth()) as any;
const session = {
user_id,
access_token,
device_id,
passwordlessUser: false,
};
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
/* eslint-enable camelcase */
return [client, session];
}, []);

View File

@@ -1,96 +0,0 @@
import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
import { useState, useEffect, useCallback, useRef } from "react";
import { useClient } from "../ClientContext";
import { initClient, defaultHomeserver } from "../matrix-utils";
export function useInteractiveRegistration() {
const { setClient } = useClient();
const [state, setState] = useState({ privacyPolicyUrl: "#", loading: false });
const authClientRef = useRef();
useEffect(() => {
authClientRef.current = matrix.createClient(defaultHomeserver);
authClientRef.current.registerRequest({}).catch((error) => {
const privacyPolicyUrl =
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url;
const recaptchaKey = error.data?.params["m.login.recaptcha"]?.public_key;
if (privacyPolicyUrl || recaptchaKey) {
setState((prev) => ({ ...prev, privacyPolicyUrl, recaptchaKey }));
}
});
}, []);
const register = useCallback(
async (
username,
password,
displayName,
recaptchaResponse,
passwordlessUser
) => {
const interactiveAuth = new InteractiveAuth({
matrixClient: authClientRef.current,
busyChanged(loading) {
setState((prev) => ({ ...prev, loading }));
},
async doRequest(auth, _background) {
return authClientRef.current.registerRequest({
username,
password,
auth: auth || undefined,
});
},
stateUpdated(nextStage, status) {
if (status.error) {
throw new Error(error);
}
if (nextStage === "m.login.terms") {
interactiveAuth.submitAuthDict({
type: "m.login.terms",
});
} else if (nextStage === "m.login.recaptcha") {
interactiveAuth.submitAuthDict({
type: "m.login.recaptcha",
response: recaptchaResponse,
});
}
},
});
const { user_id, access_token, device_id } =
await interactiveAuth.attemptAuth();
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
await client.setDisplayName(displayName);
const session = { user_id, device_id, access_token, passwordlessUser };
if (passwordlessUser) {
session.tempPassword = password;
}
setClient(client, session);
const user = client.getUser(client.getUserId());
user.setRawDisplayName(displayName);
user.setDisplayName(displayName);
return client;
},
[]
);
return [state, register];
}

View File

@@ -0,0 +1,124 @@
/*
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 { useState, useEffect, useCallback, useRef } from "react";
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import { initClient, defaultHomeserver } from "../matrix-utils";
import { Session } from "../ClientContext";
export const useInteractiveRegistration = (): [
string,
string,
(
username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser?: boolean
) => Promise<[MatrixClient, Session]>
] => {
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string>();
const [recaptchaKey, setRecaptchaKey] = useState<string>();
const authClient = useRef<MatrixClient>();
if (!authClient.current) {
authClient.current = createClient(defaultHomeserver);
}
useEffect(() => {
authClient.current.registerRequest({}).catch((error) => {
setPrivacyPolicyUrl(
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url
);
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
});
}, []);
const register = useCallback(
async (
username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser?: boolean
): Promise<[MatrixClient, Session]> => {
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient.current,
doRequest: (auth) =>
authClient.current.registerRequest({
username,
password,
auth: auth || undefined,
}),
stateUpdated: (nextStage, status) => {
if (status.error) {
throw new Error(status.error);
}
if (nextStage === "m.login.terms") {
interactiveAuth.submitAuthDict({
type: "m.login.terms",
});
} else if (nextStage === "m.login.recaptcha") {
interactiveAuth.submitAuthDict({
type: "m.login.recaptcha",
response: recaptchaResponse,
});
}
},
requestEmailToken: null,
});
// XXX: This claims to return an IAuthData which contains none of these
// things - the js-sdk types may be wrong?
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
const { user_id, access_token, device_id } =
(await interactiveAuth.attemptAuth()) as any;
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
await client.setDisplayName(displayName);
const session: Session = {
user_id,
device_id,
access_token,
passwordlessUser,
};
/* eslint-enable camelcase */
if (passwordlessUser) {
session.tempPassword = password;
}
const user = client.getUser(client.getUserId());
user.setRawDisplayName(displayName);
user.setDisplayName(displayName);
return [client, session];
},
[]
);
return [privacyPolicyUrl, recaptchaKey, register];
};

View File

@@ -1,49 +1,62 @@
import { randomString } from "matrix-js-sdk/src/randomstring"; /*
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 { useEffect, useCallback, useRef, useState } from "react"; import { useEffect, useCallback, useRef, useState } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
declare global {
interface Window {
mxOnRecaptchaLoaded: () => void;
}
}
const RECAPTCHA_SCRIPT_URL = const RECAPTCHA_SCRIPT_URL =
"https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit"; "https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit";
export function useRecaptcha(sitekey) { interface RecaptchaPromiseRef {
resolve: (response: string) => void;
reject: (error: Error) => void;
}
export const useRecaptcha = (sitekey: string) => {
const [recaptchaId] = useState(() => randomString(16)); const [recaptchaId] = useState(() => randomString(16));
const promiseRef = useRef(); const promiseRef = useRef<RecaptchaPromiseRef>();
useEffect(() => { useEffect(() => {
if (!sitekey) { if (!sitekey) return;
return;
}
const onRecaptchaLoaded = () => { const onRecaptchaLoaded = () => {
if (!document.getElementById(recaptchaId)) { if (!document.getElementById(recaptchaId)) return;
return;
}
window.grecaptcha.render(recaptchaId, { window.grecaptcha.render(recaptchaId, {
sitekey, sitekey,
size: "invisible", size: "invisible",
callback: (response) => { callback: (response: string) => promiseRef.current?.resolve(response),
if (promiseRef.current) { // eslint-disable-next-line @typescript-eslint/naming-convention
promiseRef.current.resolve(response); "error-callback": () => promiseRef.current?.reject(new Error()),
}
},
"error-callback": (error) => {
if (promiseRef.current) {
promiseRef.current.reject(error);
}
},
}); });
}; };
if ( if (typeof window.grecaptcha?.render === "function") {
typeof window.grecaptcha !== "undefined" &&
typeof window.grecaptcha.render === "function"
) {
onRecaptchaLoaded(); onRecaptchaLoaded();
} else { } else {
window.mxOnRecaptchaLoaded = onRecaptchaLoaded; window.mxOnRecaptchaLoaded = onRecaptchaLoaded;
if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) { if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) {
const scriptTag = document.createElement("script"); const scriptTag = document.createElement("script") as HTMLScriptElement;
scriptTag.src = RECAPTCHA_SCRIPT_URL; scriptTag.src = RECAPTCHA_SCRIPT_URL;
scriptTag.async = true; scriptTag.async = true;
document.body.appendChild(scriptTag); document.body.appendChild(scriptTag);
@@ -64,7 +77,7 @@ export function useRecaptcha(sitekey) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const observer = new MutationObserver((mutationsList) => { const observer = new MutationObserver((mutationsList) => {
for (const item of mutationsList) { for (const item of mutationsList) {
if (item.target?.style?.visibility !== "visible") { if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
reject(new Error("Recaptcha dismissed")); reject(new Error("Recaptcha dismissed"));
observer.disconnect(); observer.disconnect();
return; return;
@@ -85,7 +98,7 @@ export function useRecaptcha(sitekey) {
window.grecaptcha.execute(); window.grecaptcha.execute();
const iframe = document.querySelector( const iframe = document.querySelector<HTMLIFrameElement>(
'iframe[src*="recaptcha/api2/bframe"]' 'iframe[src*="recaptcha/api2/bframe"]'
); );
@@ -95,13 +108,11 @@ export function useRecaptcha(sitekey) {
}); });
} }
}); });
}, [recaptchaId, sitekey]); }, [sitekey]);
const reset = useCallback(() => { const reset = useCallback(() => {
if (window.grecaptcha) { window.grecaptcha?.reset();
window.grecaptcha.reset(); }, []);
}
}, [recaptchaId]);
return { execute, reset, recaptchaId }; return { execute, reset, recaptchaId };
} };

View File

@@ -1,3 +1,19 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import styles from "./Button.module.css"; import styles from "./Button.module.css";
@@ -7,6 +23,9 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg"; import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg"; import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg"; import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
import { useButton } from "@react-aria/button"; import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils"; import { mergeProps, useObjectRef } from "@react-aria/utils";
import { TooltipTrigger } from "../Tooltip"; import { TooltipTrigger } from "../Tooltip";
@@ -18,8 +37,10 @@ export const variantToClassName = {
icon: [styles.iconButton], icon: [styles.iconButton],
secondary: [styles.secondary], secondary: [styles.secondary],
copy: [styles.copyButton], copy: [styles.copyButton],
secondaryCopy: [styles.secondaryCopy, styles.copyButton],
iconCopy: [styles.iconCopyButton], iconCopy: [styles.iconCopyButton],
secondaryCopy: [styles.copyButton], secondaryHangup: [styles.secondaryHangup],
dropdown: [styles.dropdownButton],
}; };
export const sizeToClassName = { export const sizeToClassName = {
@@ -67,13 +88,13 @@ export const Button = forwardRef(
{ {
[styles.on]: on, [styles.on]: on,
[styles.off]: off, [styles.off]: off,
[styles.secondaryCopy]: variant === "secondaryCopy",
} }
)} )}
{...mergeProps(rest, filteredButtonProps)} {...mergeProps(rest, filteredButtonProps)}
ref={buttonRef} ref={buttonRef}
> >
{children} {children}
{variant === "dropdown" && <ArrowDownIcon />}
</button> </button>
); );
} }
@@ -126,3 +147,25 @@ export function HangupButton({ className, ...rest }) {
</TooltipTrigger> </TooltipTrigger>
); );
} }
export function SettingsButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
<SettingsIcon />
</Button>
{() => "Settings"}
</TooltipTrigger>
);
}
export function InviteButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
<AddUserIcon />
</Button>
{() => "Invite"}
</TooltipTrigger>
);
}

View File

@@ -20,7 +20,9 @@ limitations under the License.
.iconButton, .iconButton,
.iconCopyButton, .iconCopyButton,
.secondary, .secondary,
.copyButton { .secondaryHangup,
.copyButton,
.dropdownButton {
position: relative; position: relative;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -34,6 +36,7 @@ limitations under the License.
} }
.secondary, .secondary,
.secondaryHangup,
.button, .button,
.copyButton { .copyButton {
padding: 7px 15px; padding: 7px 15px;
@@ -43,8 +46,8 @@ limitations under the License.
} }
.button { .button {
color: #fff; color: var(--primary-content);
background-color: var(--primaryColor); background-color: var(--accent);
} }
.button:focus, .button:focus,
@@ -53,6 +56,7 @@ limitations under the License.
.iconButton:focus, .iconButton:focus,
.iconCopyButton:focus, .iconCopyButton:focus,
.secondary:focus, .secondary:focus,
.secondaryHangup:focus,
.copyButton:focus { .copyButton:focus {
outline: auto; outline: auto;
} }
@@ -62,46 +66,46 @@ limitations under the License.
width: 50px; width: 50px;
height: 50px; height: 50px;
border-radius: 50px; border-radius: 50px;
background-color: var(--bgColor2); background-color: var(--system);
} }
.toolbarButton:hover, .toolbarButton:hover,
.toolbarButtonSecondary:hover { .toolbarButtonSecondary:hover {
background-color: var(--bgColor4); background-color: var(--quinary-content);
} }
.toolbarButton.on, .toolbarButton.on,
.toolbarButton.off { .toolbarButton.off {
background-color: #ffffff; background-color: var(--primary-content);
} }
.toolbarButtonSecondary.on { .toolbarButtonSecondary.on {
background-color: #0dbd8b; background-color: var(--accent);
} }
.iconButton:not(.stroke) svg * { .iconButton:not(.stroke) svg * {
fill: #ffffff; fill: var(--primary-content);
} }
.iconButton:not(.stroke):hover svg * { .iconButton:not(.stroke):hover svg * {
fill: #0dbd8b; fill: var(--accent);
} }
.iconButton.on:not(.stroke) svg * { .iconButton.on:not(.stroke) svg * {
fill: #0dbd8b; fill: var(--accent);
} }
.iconButton.on.stroke svg * { .iconButton.on.stroke svg * {
stroke: #0dbd8b; stroke: var(--accent);
} }
.hangupButton, .hangupButton,
.hangupButton:hover { .hangupButton:hover {
background-color: #ff5b55; background-color: var(--alert);
} }
.toolbarButton.on svg * { .toolbarButton.on svg * {
fill: #0dbd8b; fill: var(--accent);
} }
.toolbarButton.off svg * { .toolbarButton.off svg * {
@@ -109,19 +113,25 @@ limitations under the License.
} }
.toolbarButtonSecondary.on svg * { .toolbarButtonSecondary.on svg * {
fill: #ffffff; fill: var(--primary-content);
} }
.secondary, .secondary,
.copyButton { .copyButton {
color: #0dbd8b; color: var(--accent);
border: 2px solid #0dbd8b; border: 2px solid var(--accent);
background-color: transparent;
}
.secondaryHangup {
color: var(--alert);
border: 2px solid var(--alert);
background-color: transparent; background-color: transparent;
} }
.copyButton.secondaryCopy { .copyButton.secondaryCopy {
color: var(--textColor1); color: var(--primary-content);
border-color: var(--textColor1); border-color: var(--primary-content);
} }
.copyButton { .copyButton {
@@ -144,12 +154,12 @@ limitations under the License.
} }
.copyButton:not(.on) svg * { .copyButton:not(.on) svg * {
fill: #0dbd8b; fill: var(--accent);
} }
.copyButton.on { .copyButton.on {
border-color: transparent; border-color: transparent;
background-color: #0dbd8b; background-color: var(--accent);
color: white; color: white;
} }
@@ -158,21 +168,40 @@ limitations under the License.
} }
.copyButton.secondaryCopy:not(.on) svg * { .copyButton.secondaryCopy:not(.on) svg * {
fill: var(--textColor1); fill: var(--primary-content);
} }
.iconCopyButton svg * { .iconCopyButton svg * {
fill: var(--textColor3); fill: var(--tertiary-content);
} }
.iconCopyButton:hover svg * { .iconCopyButton:hover svg * {
fill: #0dbd8b; fill: var(--accent);
} }
.iconCopyButton.on svg *, .iconCopyButton.on svg *,
.iconCopyButton.on:hover svg * { .iconCopyButton.on:hover svg * {
fill: transparent; fill: transparent;
stroke: #0dbd8b; stroke: var(--accent);
}
.dropdownButton {
color: var(--primary-content);
padding: 2px 8px;
border-radius: 8px;
}
.dropdownButton:hover,
.dropdownButton.on {
background-color: var(--quinary-content);
}
.dropdownButton svg {
margin-left: 8px;
}
.dropdownButton svg * {
fill: var(--primary-content);
} }
.lg { .lg {

View File

@@ -1,3 +1,19 @@
/*
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 from "react"; import React from "react";
import useClipboard from "react-use-clipboard"; import useClipboard from "react-use-clipboard";
import { ReactComponent as CheckIcon } from "../icons/Check.svg"; import { ReactComponent as CheckIcon } from "../icons/Check.svg";

View File

@@ -1,3 +1,19 @@
/*
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 from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import classNames from "classnames"; import classNames from "classnames";

View File

@@ -1,3 +1,19 @@
/*
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.
*/
export * from "./Button"; export * from "./Button";
export * from "./CopyButton"; export * from "./CopyButton";
export * from "./LinkButton"; export * from "./LinkButton";

View File

@@ -1,3 +1,19 @@
/*
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 classNames from "classnames";
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import styles from "./Form.module.css"; import styles from "./Form.module.css";

View File

@@ -1,3 +1,19 @@
/*
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 from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CopyButton } from "../button"; import { CopyButton } from "../button";

View File

@@ -10,7 +10,7 @@
.callTile { .callTile {
height: 95px; height: 95px;
padding: 12px; padding: 12px;
background-color: var(--bgColor2); background-color: var(--system);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
@@ -36,7 +36,7 @@
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
padding: 0 16px; padding: 0 16px;
color: var(--textColor1); color: var(--primary-content);
min-width: 0; min-width: 0;
} }

View File

@@ -0,0 +1,3 @@
.label {
margin-bottom: 0;
}

View File

@@ -0,0 +1,69 @@
/*
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, { FC } from "react";
import { Item } from "@react-stately/collections";
import { Headline } from "../typography/Typography";
import { Button } from "../button";
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import styles from "./CallTypeDropdown.module.css";
import commonStyles from "./common.module.css";
import menuStyles from "../Menu.module.css";
import { Menu } from "../Menu";
export enum CallType {
Video = "video",
Radio = "radio",
}
interface Props {
callType: CallType;
setCallType: (value: CallType) => void;
}
export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
return (
<PopoverMenuTrigger placement="bottom">
<Button variant="dropdown" className={commonStyles.headline}>
<Headline className={styles.label}>
{callType === CallType.Video ? "Video call" : "Walkie-talkie call"}
</Headline>
</Button>
{(props) => (
<Menu {...props} label="Call type menu" onAction={setCallType}>
<Item key={CallType.Video} textValue="Video call">
<VideoIcon />
<span>Video call</span>
{callType === CallType.Video && (
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item>
<Item key={CallType.Radio} textValue="Walkie-talkie call">
<MicIcon />
<span>Walkie-talkie call</span>
{callType === CallType.Radio && (
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item>
</Menu>
)}
</PopoverMenuTrigger>
);
};

View File

@@ -1,3 +1,19 @@
/*
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 from "react"; import React from "react";
import { Modal, ModalContent } from "../Modal"; import { Modal, ModalContent } from "../Modal";
import { Button } from "../button"; import { Button } from "../button";

View File

@@ -1,5 +1,21 @@
/*
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, { useState, useCallback } from "react"; import React, { useState, useCallback } from "react";
import { createRoom, roomAliasFromRoomName } from "../matrix-utils"; import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useGroupCallRooms } from "./useGroupCallRooms"; import { useGroupCallRooms } from "./useGroupCallRooms";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import commonStyles from "./common.module.css"; import commonStyles from "./common.module.css";
@@ -11,10 +27,12 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal"; import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { Headline, Title } from "../typography/Typography"; import { Title } from "../typography/Typography";
import { Form } from "../form/Form"; import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
export function RegisteredView({ client }) { export function RegisteredView({ client }) {
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState();
const history = useHistory(); const history = useHistory();
@@ -23,12 +41,13 @@ export function RegisteredView({ client }) {
e.preventDefault(); e.preventDefault();
const data = new FormData(e.target); const data = new FormData(e.target);
const roomName = data.get("callName"); const roomName = data.get("callName");
const ptt = callType === CallType.Radio;
async function submit() { async function submit() {
setError(undefined); setError(undefined);
setLoading(true); setLoading(true);
const roomIdOrAlias = await createRoom(client, roomName); const roomIdOrAlias = await createRoom(client, roomName, ptt);
if (roomIdOrAlias) { if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`); history.push(`/room/${roomIdOrAlias}`);
@@ -37,7 +56,7 @@ export function RegisteredView({ client }) {
submit().catch((error) => { submit().catch((error) => {
if (error.errcode === "M_ROOM_IN_USE") { if (error.errcode === "M_ROOM_IN_USE") {
setExistingRoomId(roomAliasFromRoomName(roomName)); setExistingRoomId(roomAliasLocalpartFromRoomName(roomName));
setLoading(false); setLoading(false);
setError(undefined); setError(undefined);
modalState.open(); modalState.open();
@@ -49,7 +68,7 @@ export function RegisteredView({ client }) {
} }
}); });
}, },
[client] [client, callType]
); );
const recentRooms = useGroupCallRooms(client); const recentRooms = useGroupCallRooms(client);
@@ -60,6 +79,9 @@ export function RegisteredView({ client }) {
history.push(`/${existingRoomId}`); history.push(`/${existingRoomId}`);
}, [history, existingRoomId]); }, [history, existingRoomId]);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
return ( return (
<> <>
<Header> <Header>
@@ -73,20 +95,19 @@ export function RegisteredView({ client }) {
<div className={commonStyles.container}> <div className={commonStyles.container}>
<main className={commonStyles.main}> <main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} /> <HeaderLogo className={commonStyles.logo} />
<Headline className={commonStyles.headline}> <CallTypeDropdown callType={callType} setCallType={setCallType} />
Enter a call name
</Headline>
<Form className={styles.form} onSubmit={onSubmit}> <Form className={styles.form} onSubmit={onSubmit}>
<FieldRow className={styles.fieldRow}> <FieldRow className={styles.fieldRow}>
<InputField <InputField
id="callName" id="callName"
name="callName" name="callName"
label="Call name" label={callNameLabel}
placeholder="Call name" placeholder={callNameLabel}
type="text" type="text"
required required
autoComplete="off" autoComplete="off"
/> />
<Button <Button
type="submit" type="submit"
size="lg" size="lg"

View File

@@ -7,6 +7,10 @@
} }
.fieldRow { .fieldRow {
margin-bottom: 24px;
}
.fieldRow:last-child {
margin-bottom: 0; margin-bottom: 0;
} }

View File

@@ -1,76 +1,110 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { useClient } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { UserMenuContainer } from "../UserMenuContainer"; import { UserMenuContainer } from "../UserMenuContainer";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
import { randomString } from "matrix-js-sdk/src/randomstring"; import { randomString } from "matrix-js-sdk/src/randomstring";
import { createRoom, roomAliasFromRoomName } from "../matrix-utils"; import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal"; import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useRecaptcha } from "../auth/useRecaptcha"; import { useRecaptcha } from "../auth/useRecaptcha";
import { Body, Caption, Link, Headline } from "../typography/Typography"; import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Form } from "../form/Form"; import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import styles from "./UnauthenticatedView.module.css"; import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css"; import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName"; import { generateRandomName } from "../auth/generateRandomName";
export function UnauthenticatedView() { export function UnauthenticatedView() {
const { setClient } = useClient();
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState();
const [{ privacyPolicyUrl, recaptchaKey }, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const { modalState, modalProps } = useModalTriggerState();
const [onFinished, setOnFinished] = useState();
const history = useHistory();
const onSubmit = useCallback( const onSubmit = useCallback(
(e) => { (e) => {
e.preventDefault(); e.preventDefault();
const data = new FormData(e.target); const data = new FormData(e.target);
const roomName = data.get("callName"); const roomName = data.get("callName");
const displayName = data.get("displayName"); const displayName = data.get("displayName");
const ptt = callType === CallType.Radio;
async function submit() { async function submit() {
setError(undefined); setError(undefined);
setLoading(true); setLoading(true);
const recaptchaResponse = await execute(); const recaptchaResponse = await execute();
const userName = generateRandomName(); const userName = generateRandomName();
const client = await register( const [client, session] = await register(
userName, userName,
randomString(16), randomString(16),
displayName, displayName,
recaptchaResponse, recaptchaResponse,
true true
); );
const roomIdOrAlias = await createRoom(client, roomName);
if (roomIdOrAlias) { let roomIdOrAlias;
history.push(`/room/${roomIdOrAlias}`); try {
roomIdOrAlias = await createRoom(client, roomName, ptt);
} catch (error) {
if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => () => {
setClient(client, session);
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
const [, serverName] = client.getUserId().split(":");
history.push(`/room/#${aliasLocalpart}:${serverName}`);
});
setLoading(false);
modalState.open();
return;
} else {
throw error;
}
} }
// Only consider the registration successful if we managed to create the room, too
setClient(client, session);
history.push(`/room/${roomIdOrAlias}`);
} }
submit().catch((error) => { submit().catch((error) => {
if (error.errcode === "M_ROOM_IN_USE") { console.error(error);
setExistingRoomId(roomAliasFromRoomName(roomName)); setLoading(false);
setLoading(false); setError(error);
setError(undefined); reset();
modalState.open();
} else {
console.error(error);
setLoading(false);
setError(error);
reset();
}
}); });
}, },
[register, reset, execute] [register, reset, execute, history, callType]
); );
const { modalState, modalProps } = useModalTriggerState(); const callNameLabel =
const [existingRoomId, setExistingRoomId] = useState(); callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
const history = useHistory();
const onJoinExistingRoom = useCallback(() => {
history.push(`/${existingRoomId}`);
}, [history, existingRoomId]);
return ( return (
<> <>
@@ -85,16 +119,14 @@ export function UnauthenticatedView() {
<div className={commonStyles.container}> <div className={commonStyles.container}>
<main className={commonStyles.main}> <main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} /> <HeaderLogo className={commonStyles.logo} />
<Headline className={commonStyles.headline}> <CallTypeDropdown callType={callType} setCallType={setCallType} />
Enter a call name
</Headline>
<Form className={styles.form} onSubmit={onSubmit}> <Form className={styles.form} onSubmit={onSubmit}>
<FieldRow> <FieldRow>
<InputField <InputField
id="callName" id="callName"
name="callName" name="callName"
label="Call name" label={callNameLabel}
placeholder="Call name" placeholder={callNameLabel}
type="text" type="text"
required required
autoComplete="off" autoComplete="off"
@@ -141,7 +173,7 @@ export function UnauthenticatedView() {
</footer> </footer>
</div> </div>
{modalState.isOpen && ( {modalState.isOpen && (
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} /> <JoinExistingCallModal onJoin={onFinished} {...modalProps} />
)} )}
</> </>
); );

View File

@@ -1,3 +1,19 @@
/*
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 { useState, useEffect } from "react"; import { useState, useEffect } from "react";
const tsCache = {}; const tsCache = {};
@@ -63,7 +79,7 @@ export function useGroupCallRooms(client) {
return { return {
roomId: room.getCanonicalAlias() || room.roomId, roomId: room.getCanonicalAlias() || room.roomId,
roomName: room.name, roomName: room.name,
avatarUrl: null, avatarUrl: room.getMxcAvatarUrl(),
room, room,
groupCall, groupCall,
participants: [...groupCall.participants], participants: [...groupCall.participants],
@@ -81,7 +97,7 @@ export function useGroupCallRooms(client) {
client.removeListener("GroupCall.incoming", updateRooms); client.removeListener("GroupCall.incoming", updateRooms);
client.removeListener("GroupCall.participants", updateRooms); client.removeListener("GroupCall.participants", updateRooms);
}; };
}, []); }, [client]);
return rooms; return rooms;
} }

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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -25,19 +25,19 @@ limitations under the License.
:root { :root {
--inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, --inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f,
U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF; U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF;
--primaryColor: #0dbd8b; --accent: #0dbd8b;
--bgColor1: #15191e; --accent-20: #0dbd8b33;
--bgColor2: #21262c; --alert: #ff5b55;
--bgColor3: #444; --alert-20: #ff5b5533;
--bgColor4: #394049; --links: #0086e6;
--bgColor5: #8d97a5; --primary-content: #ffffff;
--textColor1: #fff; --secondary-content: #a9b2bc;
--textColor2: #6f7882; --tertiary-content: #8e99a4;
--textColor3: #8e99a4; --quaternary-content: #6f7882;
--textColor4: #a9b2bc; --quinary-content: #394049;
--inputBorderColor: #394049; --system: #21262c;
--inputBorderColorFocused: #0086e6; --background: #15191e;
--linkColor: #0086e6; --bgColor3: #444; /* This isn't found anywhere in the designs or Compound */
} }
@font-face { @font-face {
@@ -121,8 +121,9 @@ limitations under the License.
} }
body { body {
background-color: var(--bgColor1); background-color: var(--background);
color: var(--textColor1); color: var(--primary-content);
color-scheme: dark;
margin: 0; margin: 0;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
@@ -181,7 +182,7 @@ p {
} }
a { a {
color: var(--primaryColor); color: var(--accent);
text-decoration: none; text-decoration: none;
} }
@@ -193,8 +194,8 @@ a:active {
hr { hr {
width: calc(100% - 24px); width: calc(100% - 24px);
border: none; border: none;
border-top: 1px solid var(--bgColor4); border-top: 1px solid var(--quinary-content);
color: var(--textColor2); color: var(--quaternary-content);
overflow: visible; overflow: visible;
text-align: center; text-align: center;
height: 5px; height: 5px;

View File

@@ -1,3 +1,19 @@
/*
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 { useObjectRef } from "@react-aria/utils"; import { useObjectRef } from "@react-aria/utils";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useCallback } from "react"; import { useCallback } from "react";

View File

@@ -26,7 +26,7 @@
position: absolute; position: absolute;
bottom: 11px; bottom: 11px;
right: -4px; right: -4px;
background-color: var(--bgColor4); background-color: var(--quinary-content);
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 10px; border-radius: 10px;
@@ -37,5 +37,5 @@
} }
.removeButton { .removeButton {
color: #0dbd8b; color: var(--accent);
} }

View File

@@ -1,3 +1,19 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import styles from "./Input.module.css"; import styles from "./Input.module.css";

View File

@@ -26,7 +26,7 @@
.inputField { .inputField {
border-radius: 4px; border-radius: 4px;
transition: border-color 0.25s; transition: border-color 0.25s;
border: 1px solid var(--inputBorderColor); border: 1px solid var(--quinary-content);
} }
.inputField input, .inputField input,
@@ -36,8 +36,8 @@
border: none; border: none;
border-radius: 4px; border-radius: 4px;
padding: 12px 9px 10px 9px; padding: 12px 9px 10px 9px;
color: var(--textColor1); color: var(--primary-content);
background-color: var(--bgColor1); background-color: var(--background);
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
@@ -45,7 +45,7 @@
.inputField.disabled input, .inputField.disabled input,
.inputField.disabled textarea, .inputField.disabled textarea,
.inputField.disabled span { .inputField.disabled span {
color: var(--textColor2); color: var(--quaternary-content);
} }
.inputField span { .inputField span {
@@ -65,13 +65,13 @@
.inputField input:placeholder-shown:focus::placeholder, .inputField input:placeholder-shown:focus::placeholder,
.inputField textarea:placeholder-shown:focus::placeholder { .inputField textarea:placeholder-shown:focus::placeholder {
transition: color 0.25s ease-in 0.1s; transition: color 0.25s ease-in 0.1s;
color: var(--textColor2); color: var(--quaternary-content);
} }
.inputField label { .inputField label {
transition: font-size 0.25s ease-out 0.1s, color 0.25s ease-out 0.1s, transition: font-size 0.25s ease-out 0.1s, color 0.25s ease-out 0.1s,
top 0.25s ease-out 0.1s, background-color 0.25s ease-out 0.1s; top 0.25s ease-out 0.1s, background-color 0.25s ease-out 0.1s;
color: var(--textColor3); color: var(--tertiary-content);
background-color: transparent; background-color: transparent;
font-size: 15px; font-size: 15px;
position: absolute; position: absolute;
@@ -87,7 +87,7 @@
} }
.inputField:focus-within { .inputField:focus-within {
border-color: var(--inputBorderColorFocused); border-color: var(--links);
} }
.inputField input:focus, .inputField input:focus,
@@ -101,7 +101,7 @@
.inputField textarea:focus + label, .inputField textarea:focus + label,
.inputField textarea:not(:placeholder-shown) + label, .inputField textarea:not(:placeholder-shown) + label,
.inputField.prefix textarea + label { .inputField.prefix textarea + label {
background-color: var(--bgColor2); background-color: var(--system);
transition: font-size 0.25s ease-out 0s, color 0.25s ease-out 0s, transition: font-size 0.25s ease-out 0s, color 0.25s ease-out 0s,
top 0.25s ease-out 0s, background-color 0.25s ease-out 0s; top 0.25s ease-out 0s, background-color 0.25s ease-out 0s;
font-size: 10px; font-size: 10px;
@@ -112,7 +112,7 @@
.inputField input:focus + label, .inputField input:focus + label,
.inputField textarea:focus + label { .inputField textarea:focus + label {
color: var(--inputBorderColorFocused); color: var(--links);
} }
.checkboxField { .checkboxField {
@@ -154,12 +154,12 @@
} }
.checkbox svg * { .checkbox svg * {
stroke: #fff; stroke: var(--primary-content);
} }
.checkboxField input[type="checkbox"]:checked + label > .checkbox { .checkboxField input[type="checkbox"]:checked + label > .checkbox {
background: var(--primaryColor); background: var(--accent);
border-color: var(--primaryColor); border-color: var(--accent);
} }
.checkboxField input[type="checkbox"]:checked + label > .checkbox svg { .checkboxField input[type="checkbox"]:checked + label > .checkbox svg {
@@ -167,12 +167,12 @@
} }
.checkboxField:focus-within .checkbox { .checkboxField:focus-within .checkbox {
border: 1.5px solid var(--inputBorderColorFocused) !important; border: 1.5px solid var(--links) !important;
} }
.errorMessage { .errorMessage {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
color: #ff5b55; color: var(--alert);
font-weight: 600; font-weight: 600;
} }

View File

@@ -1,3 +1,19 @@
/*
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, { useRef } from "react"; import React, { useRef } from "react";
import { HiddenSelect, useSelect } from "@react-aria/select"; import { HiddenSelect, useSelect } from "@react-aria/select";
import { useButton } from "@react-aria/button"; import { useButton } from "@react-aria/button";

View File

@@ -17,11 +17,11 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 12px; padding: 0 12px;
background-color: var(--bgColor1); background-color: var(--background);
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--inputBorderColor); border: 1px solid var(--quinary-content);
font-size: 15px; font-size: 15px;
color: var(--textColor1); color: var(--primary-content);
height: 40px; height: 40px;
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;

57
src/input/Toggle.jsx Normal file
View File

@@ -0,0 +1,57 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useRef } from "react";
import styles from "./Toggle.module.css";
import { useToggleButton } from "@react-aria/button";
import classNames from "classnames";
import { Field } from "./Input";
export function Toggle({ id, label, className, onChange, isSelected }) {
const buttonRef = useRef();
const toggle = useCallback(() => {
onChange(!isSelected);
});
const { buttonProps } = useToggleButton(
{ isSelected },
{ toggle },
buttonRef
);
return (
<Field
className={classNames(
styles.toggle,
{ [styles.on]: isSelected },
className
)}
>
<button
{...buttonProps}
ref={buttonRef}
id={id}
className={classNames(styles.button, {
[styles.isPressed]: isSelected,
})}
>
<div className={styles.ball} />
</button>
<label className={styles.label} htmlFor={id}>
{label}
</label>
</Field>
);
}

View File

@@ -0,0 +1,46 @@
.toggle {
align-items: center;
margin-bottom: 20px;
}
.button {
position: relative;
padding: 0;
transition: background-color 0.2s ease-out 0.1s;
width: 44px;
height: 24px;
border: none;
border-radius: 21px;
background-color: var(--quaternary-content);
cursor: pointer;
margin-right: 8px;
}
.ball {
transition: left 0.15s ease-out 0.1s;
position: absolute;
width: 20px;
height: 20px;
border-radius: 21px;
background-color: var(--background);
left: 2px;
top: 2px;
}
.label {
padding: 10px 8px;
line-height: 24px;
color: var(--quaternary-content);
}
.toggle.on .button {
background-color: var(--accent);
}
.toggle.on .ball {
left: 22px;
}
.toggle.on .label {
color: var(--primary-content);
}

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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -14,13 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// We need to import this somewhere, once, so that the correct 'request'
// function gets set. It needs to be not in the same file as we use
// createClient, or the typescript transpiler gets confused about
// dependency references.
import "matrix-js-sdk/src/browser-index";
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { createBrowserHistory } from "history"; import { createBrowserHistory } from "history";
import "./index.css";
import App from "./App";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing"; import { Integrations } from "@sentry/tracing";
import "./index.css";
import App from "./App";
import { ErrorView } from "./FullScreenView"; import { ErrorView } from "./FullScreenView";
import { init as initRageshake } from "./settings/rageshake"; import { init as initRageshake } from "./settings/rageshake";
import { InspectorContextProvider } from "./room/GroupCallInspector"; import { InspectorContextProvider } from "./room/GroupCallInspector";
@@ -31,29 +38,50 @@ console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`);
if (import.meta.env.VITE_CUSTOM_THEME) { if (import.meta.env.VITE_CUSTOM_THEME) {
const style = document.documentElement.style; const style = document.documentElement.style;
style.setProperty("--primaryColor", import.meta.env.VITE_PRIMARY_COLOR); style.setProperty("--accent", import.meta.env.VITE_THEME_ACCENT as string);
style.setProperty("--bgColor1", import.meta.env.VITE_BG_COLOR_1);
style.setProperty("--bgColor2", import.meta.env.VITE_BG_COLOR_2);
style.setProperty("--bgColor3", import.meta.env.VITE_BG_COLOR_3);
style.setProperty("--bgColor4", import.meta.env.VITE_BG_COLOR_4);
style.setProperty("--bgColor5", import.meta.env.VITE_BG_COLOR_5);
style.setProperty("--textColor1", import.meta.env.VITE_TEXT_COLOR_1);
style.setProperty("--textColor2", import.meta.env.VITE_TEXT_COLOR_2);
style.setProperty("--textColor4", import.meta.env.VITE_TEXT_COLOR_4);
style.setProperty( style.setProperty(
"--inputBorderColor", "--accent-20",
import.meta.env.VITE_INPUT_BORDER_COLOR import.meta.env.VITE_THEME_ACCENT_20 as string
);
style.setProperty("--alert", import.meta.env.VITE_THEME_ALERT as string);
style.setProperty(
"--alert-20",
import.meta.env.VITE_THEME_ALERT_20 as string
);
style.setProperty("--links", import.meta.env.VITE_THEME_LINKS as string);
style.setProperty(
"--primary-content",
import.meta.env.VITE_THEME_PRIMARY_CONTENT as string
); );
style.setProperty( style.setProperty(
"--inputBorderColorFocused", "--secondary-content",
import.meta.env.VITE_INPUT_BORDER_COLOR_FOCUSED import.meta.env.VITE_THEME_SECONDARY_CONTENT as string
);
style.setProperty(
"--tertiary-content",
import.meta.env.VITE_THEME_TERTIARY_CONTENT as string
);
style.setProperty(
"--quaternary-content",
import.meta.env.VITE_THEME_QUATERNARY_CONTENT as string
);
style.setProperty(
"--quinary-content",
import.meta.env.VITE_THEME_QUINARY_CONTENT as string
);
style.setProperty("--system", import.meta.env.VITE_THEME_SYSTEM as string);
style.setProperty(
"--background",
import.meta.env.VITE_THEME_BACKGROUND as string
); );
} }
const history = createBrowserHistory(); const history = createBrowserHistory();
Sentry.init({ Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN, dsn: import.meta.env.VITE_SENTRY_DSN as string,
environment:
(import.meta.env.VITE_SENTRY_ENVIRONMENT as string) ?? "production",
integrations: [ integrations: [
new Integrations.BrowserTracing({ new Integrations.BrowserTracing({
routingInstrumentation: Sentry.reactRouterV5Instrumentation(history), routingInstrumentation: Sentry.reactRouterV5Instrumentation(history),

View File

@@ -1,137 +0,0 @@
import matrix from "matrix-js-sdk/src/browser-index";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/browser-index";
export const defaultHomeserver =
import.meta.env.VITE_DEFAULT_HOMESERVER ||
`${window.location.protocol}//${window.location.host}`;
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
function waitForSync(client) {
return new Promise((resolve, reject) => {
const onSync = (state, _old, data) => {
if (state === "PREPARED") {
resolve();
client.removeListener("sync", onSync);
} else if (state === "ERROR") {
reject(data?.error);
client.removeListener("sync", onSync);
}
};
client.on("sync", onSync);
});
}
export async function initClient(clientOptions) {
const client = matrix.createClient({
...clientOptions,
useAuthorizationHeader: true,
});
await client.startClient({
// dirty hack to reduce chance of gappy syncs
// should be fixed by spotting gaps and backpaginating
initialSyncLimit: 50,
});
await waitForSync(client);
return client;
}
export function roomAliasFromRoomName(roomName) {
return roomName
.trim()
.replace(/\s/g, "-")
.replace(/[^\w-]/g, "")
.toLowerCase();
}
export function roomNameFromRoomId(roomId) {
return roomId
.match(/([^:]+):.*$/)[1]
.substring(1)
.split("-")
.map((part) =>
part.length > 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part
)
.join(" ")
.toLowerCase();
}
export function isLocalRoomId(roomId) {
if (!roomId) {
return false;
}
const parts = roomId.match(/[^:]+:(.*)$/);
if (parts.length < 2) {
return false;
}
return parts[1] === defaultHomeserverHost;
}
export async function createRoom(client, name) {
const { room_id, room_alias } = await client.createRoom({
visibility: "private",
preset: "public_chat",
name,
room_alias_name: roomAliasFromRoomName(name),
power_level_content_override: {
invite: 100,
kick: 100,
ban: 100,
redact: 50,
state_default: 0,
events_default: 0,
users_default: 0,
events: {
"m.room.power_levels": 100,
"m.room.history_visibility": 100,
"m.room.tombstone": 100,
"m.room.encryption": 100,
"m.room.name": 50,
"m.room.message": 0,
"m.room.encrypted": 50,
"m.sticker": 50,
"org.matrix.msc3401.call.member": 0,
},
users: {
[client.getUserId()]: 100,
},
},
});
await client.createGroupCall(
room_id,
GroupCallType.Video,
GroupCallIntent.Prompt
);
return room_alias || room_id;
}
export function getRoomUrl(roomId) {
if (roomId.startsWith("#")) {
const [localPart, host] = roomId.replace("#", "").split(":");
if (host !== defaultHomeserverHost) {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
} else {
return `${window.location.protocol}//${window.location.host}/${localPart}`;
}
} else {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
}
}
export function getAvatarUrl(client, mxcUrl, avatarSize = 96) {
const width = Math.floor(avatarSize * window.devicePixelRatio);
const height = Math.floor(avatarSize * window.devicePixelRatio);
return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, "crop");
}

224
src/matrix-utils.ts Normal file
View File

@@ -0,0 +1,224 @@
import Olm from "@matrix-org/olm";
import olmWasmPath from "@matrix-org/olm/olm.wasm?url";
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
import { WebStorageSessionStore } from "matrix-js-sdk/src/store/session/webstorage";
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import { ICreateClientOpts } from "matrix-js-sdk/src/matrix";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import IndexedDBWorker from "./IndexedDBWorker?worker";
export const defaultHomeserver =
(import.meta.env.VITE_DEFAULT_HOMESERVER as string) ??
`${window.location.protocol}//${window.location.host}`;
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
function waitForSync(client: MatrixClient) {
return new Promise<void>((resolve, reject) => {
const onSync = (
state: SyncState,
_old: SyncState,
data: ISyncStateData
) => {
if (state === "PREPARED") {
resolve();
client.removeListener(ClientEvent.Sync, onSync);
} else if (state === "ERROR") {
reject(data?.error);
client.removeListener(ClientEvent.Sync, onSync);
}
};
client.on(ClientEvent.Sync, onSync);
});
}
export async function initClient(
clientOptions: ICreateClientOpts
): Promise<MatrixClient> {
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
window.OLM_OPTIONS = {};
await Olm.init({ locateFile: () => olmWasmPath });
let indexedDB: IDBFactory;
try {
indexedDB = window.indexedDB;
} catch (e) {}
const storeOpts = {} as ICreateClientOpts;
if (indexedDB && localStorage && !import.meta.env.DEV) {
storeOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage: window.localStorage,
dbName: "element-call-sync",
workerFactory: () => new IndexedDBWorker(),
});
}
if (localStorage) {
storeOpts.sessionStore = new WebStorageSessionStore(localStorage);
}
if (indexedDB) {
storeOpts.cryptoStore = new IndexedDBCryptoStore(
indexedDB,
"matrix-js-sdk:crypto"
);
}
const client = createClient({
...storeOpts,
...clientOptions,
useAuthorizationHeader: true,
// Use a relatively low timeout for API calls: this is a realtime application
// so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000,
});
try {
await client.store.startup();
} catch (error) {
console.error(
"Error starting matrix client store. Falling back to memory store.",
error
);
client.store = new MemoryStore({ localStorage });
await client.store.startup();
}
if (client.initCrypto) {
await client.initCrypto();
}
await client.startClient({
// dirty hack to reduce chance of gappy syncs
// should be fixed by spotting gaps and backpaginating
initialSyncLimit: 50,
});
await waitForSync(client);
return client;
}
export function roomAliasLocalpartFromRoomName(roomName: string): string {
return roomName
.trim()
.replace(/\s/g, "-")
.replace(/[^\w-]/g, "")
.toLowerCase();
}
export function fullAliasFromRoomName(
roomName: string,
client: MatrixClient
): string {
return `#${roomAliasLocalpartFromRoomName(roomName)}:${client.getDomain()}`;
}
export function roomNameFromRoomId(roomId: string): string {
return roomId
.match(/([^:]+):.*$/)[1]
.substring(1)
.split("-")
.map((part) =>
part.length > 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part
)
.join(" ")
.toLowerCase();
}
export function isLocalRoomId(roomId: string): boolean {
if (!roomId) {
return false;
}
const parts = roomId.match(/[^:]+:(.*)$/);
if (parts.length < 2) {
return false;
}
return parts[1] === defaultHomeserverHost;
}
export async function createRoom(
client: MatrixClient,
name: string,
isPtt = false
): Promise<string> {
const createRoomResult = await client.createRoom({
visibility: Visibility.Private,
preset: Preset.PublicChat,
name,
room_alias_name: roomAliasLocalpartFromRoomName(name),
power_level_content_override: {
invite: 100,
kick: 100,
ban: 100,
redact: 50,
state_default: 0,
events_default: 0,
users_default: 0,
events: {
"m.room.power_levels": 100,
"m.room.history_visibility": 100,
"m.room.tombstone": 100,
"m.room.encryption": 100,
"m.room.name": 50,
"m.room.message": 0,
"m.room.encrypted": 50,
"m.sticker": 50,
"org.matrix.msc3401.call.member": 0,
},
users: {
[client.getUserId()]: 100,
},
},
});
console.log(`Creating ${isPtt ? "PTT" : "video"} group call room`);
await client.createGroupCall(
createRoomResult.room_id,
isPtt ? GroupCallType.Voice : GroupCallType.Video,
isPtt,
GroupCallIntent.Prompt
);
return fullAliasFromRoomName(name, client);
}
export function getRoomUrl(roomId: string): string {
if (roomId.startsWith("#")) {
const [localPart, host] = roomId.replace("#", "").split(":");
if (host !== defaultHomeserverHost) {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
} else {
return `${window.location.protocol}//${window.location.host}/${localPart}`;
}
} else {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
}
}
export function getAvatarUrl(
client: MatrixClient,
mxcUrl: string,
avatarSize = 96
): string {
const width = Math.floor(avatarSize * window.devicePixelRatio);
const height = Math.floor(avatarSize * window.devicePixelRatio);
return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, "crop");
}

View File

@@ -1,3 +1,19 @@
/*
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 React, { forwardRef, useRef } from "react";
import { DismissButton, useOverlay } from "@react-aria/overlays"; import { DismissButton, useOverlay } from "@react-aria/overlays";
import { FocusScope } from "@react-aria/focus"; import { FocusScope } from "@react-aria/focus";

View File

@@ -2,7 +2,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 194px; width: 194px;
background: var(--bgColor2); background: var(--system);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 8px; border-radius: 8px;
} }

View File

@@ -1,3 +1,19 @@
/*
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 React, { forwardRef, useRef } from "react";
import styles from "./PopoverMenu.module.css"; import styles from "./PopoverMenu.module.css";
import { useMenuTriggerState } from "@react-stately/menu"; import { useMenuTriggerState } from "@react-stately/menu";

View File

@@ -1,3 +1,19 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { Button } from "../button"; import { Button } from "../button";
import { useProfile } from "./useProfile"; import { useProfile } from "./useProfile";

View File

@@ -1,5 +1,20 @@
/*
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 { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { getAvatarUrl } from "../matrix-utils";
export function useProfile(client) { export function useProfile(client) {
const [{ loading, displayName, avatarUrl, error, success }, setState] = const [{ loading, displayName, avatarUrl, error, success }, setState] =
@@ -10,7 +25,7 @@ export function useProfile(client) {
success: false, success: false,
loading: false, loading: false,
displayName: user?.rawDisplayName, displayName: user?.rawDisplayName,
avatarUrl: user && client && getAvatarUrl(client, user.avatarUrl), avatarUrl: user?.avatarUrl,
error: null, error: null,
}; };
}); });
@@ -21,7 +36,7 @@ export function useProfile(client) {
success: false, success: false,
loading: false, loading: false,
displayName, displayName,
avatarUrl: getAvatarUrl(client, avatarUrl), avatarUrl,
error: null, error: null,
}); });
}; };
@@ -68,11 +83,7 @@ export function useProfile(client) {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
displayName, displayName,
avatarUrl: removeAvatar avatarUrl: removeAvatar ? null : mxcAvatarUrl ?? prev.avatarUrl,
? null
: mxcAvatarUrl
? getAvatarUrl(client, mxcAvatarUrl)
: prev.avatarUrl,
loading: false, loading: false,
success: true, success: true,
})); }));

77
src/room/AudioPreview.jsx Normal file
View File

@@ -0,0 +1,77 @@
/*
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 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 { Body } from "../typography/Typography";
export function AudioPreview({
state,
roomName,
audioInput,
audioInputs,
setAudioInput,
audioOutput,
audioOutputs,
setAudioOutput,
}) {
return (
<>
<h1>{`${roomName} - Walkie-talkie call`}</h1>
<div className={styles.preview}>
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
Microphone permissions needed to join the call.
</Body>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
Accept microphone permissions to join the call.
</Body>
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<>
<SelectInput
label="Microphone"
selectedKey={audioInput}
onSelectionChange={setAudioInput}
className={styles.inputField}
>
{audioInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
))}
</SelectInput>
{audioOutputs.length > 0 && (
<SelectInput
label="Speaker"
selectedKey={audioOutput}
onSelectionChange={setAudioOutput}
className={styles.inputField}
>
{audioOutputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
))}
</SelectInput>
)}
</>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,27 @@
.preview {
margin: 20px 0;
padding: 24px 20px;
border-radius: 8px;
width: calc(100% - 40px);
max-width: 414px;
}
.inputField {
width: 100%;
}
.inputField:last-child {
margin-bottom: 0;
}
.microphonePermissions {
margin: 20px;
text-align: center;
}
@media (min-width: 800px) {
.preview {
margin-top: 40px;
background-color: #21262c;
}
}

View File

@@ -1,3 +1,19 @@
/*
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 from "react"; import React from "react";
import styles from "./CallEndedView.module.css"; import styles from "./CallEndedView.module.css";
import { LinkButton } from "../button"; import { LinkButton } from "../button";

View File

@@ -1,3 +1,19 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect } from "react"; import React, { useCallback, useEffect } from "react";
import { Modal, ModalContent } from "../Modal"; import { Modal, ModalContent } from "../Modal";
import { Button } from "../button"; import { Button } from "../button";

View File

@@ -1,10 +1,26 @@
/*
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 from "react"; import React from "react";
import { Button } from "../button"; import { Button } from "../button";
import { PopoverMenuTrigger } from "../popover/PopoverMenu"; import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { ReactComponent as SpotlightIcon } from "../icons/Spotlight.svg"; import { ReactComponent as SpotlightIcon } from "../icons/Spotlight.svg";
import { ReactComponent as FreedomIcon } from "../icons/Freedom.svg"; import { ReactComponent as FreedomIcon } from "../icons/Freedom.svg";
import { ReactComponent as CheckIcon } from "../icons/Check.svg"; import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import styles from "./GridLayoutMenu.module.css"; import menuStyles from "../Menu.module.css";
import { Menu } from "../Menu"; import { Menu } from "../Menu";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { Tooltip, TooltipTrigger } from "../Tooltip"; import { Tooltip, TooltipTrigger } from "../Tooltip";
@@ -23,13 +39,15 @@ export function GridLayoutMenu({ layout, setLayout }) {
<Item key="freedom" textValue="Freedom"> <Item key="freedom" textValue="Freedom">
<FreedomIcon /> <FreedomIcon />
<span>Freedom</span> <span>Freedom</span>
{layout === "freedom" && <CheckIcon className={styles.checkIcon} />} {layout === "freedom" && (
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item> </Item>
<Item key="spotlight" textValue="Spotlight"> <Item key="spotlight" textValue="Spotlight">
<SpotlightIcon /> <SpotlightIcon />
<span>Spotlight</span> <span>Spotlight</span>
{layout === "spotlight" && ( {layout === "spotlight" && (
<CheckIcon className={styles.checkIcon} /> <CheckIcon className={menuStyles.checkIcon} />
)} )}
</Item> </Item>
</Menu> </Menu>

View File

@@ -1,8 +0,0 @@
.checkIcon {
position: absolute;
right: 16px;
}
.checkIcon * {
stroke: var(--textColor1);
}

View File

@@ -1,3 +1,19 @@
/*
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 { Resizable } from "re-resizable"; import { Resizable } from "re-resizable";
import React, { import React, {
useEffect, useEffect,

View File

@@ -1,5 +1,5 @@
.inspector { .inspector {
background-color: var(--bgColor2); background-color: var(--system);
} }
.scrollContainer { .scrollContainer {
@@ -20,6 +20,6 @@
.sequenceDiagramViewer :global(.messageText) { .sequenceDiagramViewer :global(.messageText) {
font-size: 12px; font-size: 12px;
fill: var(--textColor1) !important; fill: var(--primary-content) !important;
stroke: var(--textColor1) !important; stroke: var(--primary-content) !important;
} }

View File

@@ -1,3 +1,19 @@
/*
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 from "react"; import React from "react";
import { useLoadGroupCall } from "./useLoadGroupCall"; import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView"; import { ErrorView, FullScreenView } from "../FullScreenView";

View File

@@ -1,3 +1,19 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
@@ -5,7 +21,9 @@ import { useGroupCall } from "./useGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView"; import { ErrorView, FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView"; import { LobbyView } from "./LobbyView";
import { InCallView } from "./InCallView"; import { InCallView } from "./InCallView";
import { PTTCallView } from "./PTTCallView";
import { CallEndedView } from "./CallEndedView"; import { CallEndedView } from "./CallEndedView";
import { useRoomAvatar } from "./useRoomAvatar";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler"; import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation"; import { useLocationNavigation } from "../useLocationNavigation";
@@ -15,19 +33,6 @@ export function GroupCallView({
roomId, roomId,
groupCall, groupCall,
}) { }) {
const [showInspector, setShowInspector] = useState(
() => !!localStorage.getItem("matrix-group-call-inspector")
);
const onChangeShowInspector = useCallback((show) => {
setShowInspector(show);
if (show) {
localStorage.setItem("matrix-group-call-inspector", "true");
} else {
localStorage.removeItem("matrix-group-call-inspector");
}
}, []);
const { const {
state, state,
error, error,
@@ -47,8 +52,11 @@ export function GroupCallView({
localScreenshareFeed, localScreenshareFeed,
screenshareFeeds, screenshareFeeds,
hasLocalParticipant, hasLocalParticipant,
participants,
} = useGroupCall(groupCall); } = useGroupCall(groupCall);
const avatarUrl = useRoomAvatar(groupCall.room);
useEffect(() => { useEffect(() => {
window.groupCall = groupCall; window.groupCall = groupCall;
}, [groupCall]); }, [groupCall]);
@@ -72,27 +80,41 @@ export function GroupCallView({
if (error) { if (error) {
return <ErrorView error={error} />; return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered) { } else if (state === GroupCallState.Entered) {
return ( if (groupCall.isPtt) {
<InCallView return (
groupCall={groupCall} <PTTCallView
client={client} client={client}
roomName={groupCall.room.name} roomId={roomId}
microphoneMuted={microphoneMuted} roomName={groupCall.room.name}
localVideoMuted={localVideoMuted} avatarUrl={avatarUrl}
toggleLocalVideoMuted={toggleLocalVideoMuted} groupCall={groupCall}
toggleMicrophoneMuted={toggleMicrophoneMuted} participants={participants}
userMediaFeeds={userMediaFeeds} userMediaFeeds={userMediaFeeds}
activeSpeaker={activeSpeaker} onLeave={onLeave}
onLeave={onLeave} />
toggleScreensharing={toggleScreensharing} );
isScreensharing={isScreensharing} } else {
localScreenshareFeed={localScreenshareFeed} return (
screenshareFeeds={screenshareFeeds} <InCallView
setShowInspector={onChangeShowInspector} groupCall={groupCall}
showInspector={showInspector} client={client}
roomId={roomId} roomName={groupCall.room.name}
/> avatarUrl={avatarUrl}
); microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
userMediaFeeds={userMediaFeeds}
activeSpeaker={activeSpeaker}
onLeave={onLeave}
toggleScreensharing={toggleScreensharing}
isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
roomId={roomId}
/>
);
}
} else if (state === GroupCallState.Entering) { } else if (state === GroupCallState.Entering) {
return ( return (
<FullScreenView> <FullScreenView>
@@ -105,8 +127,10 @@ export function GroupCallView({
return ( return (
<LobbyView <LobbyView
client={client} client={client}
groupCall={groupCall}
hasLocalParticipant={hasLocalParticipant} hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name} roomName={groupCall.room.name}
avatarUrl={avatarUrl}
state={state} state={state}
onInitLocalCallFeed={initLocalCallFeed} onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed} localCallFeed={localCallFeed}
@@ -115,8 +139,6 @@ export function GroupCallView({
localVideoMuted={localVideoMuted} localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted} toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted} toggleMicrophoneMuted={toggleMicrophoneMuted}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
roomId={roomId} roomId={roomId}
/> />
); );

View File

@@ -1,4 +1,20 @@
import React, { useCallback, useMemo } from "react"; /*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useMemo, useRef } from "react";
import styles from "./InCallView.module.css"; import styles from "./InCallView.module.css";
import { import {
HangupButton, HangupButton,
@@ -9,7 +25,6 @@ import {
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid"; import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
import { VideoTileContainer } from "../video-grid/VideoTileContainer"; import { VideoTileContainer } from "../video-grid/VideoTileContainer";
import { getAvatarUrl } from "../matrix-utils";
import { GroupCallInspector } from "./GroupCallInspector"; import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu"; import { OverflowMenu } from "./OverflowMenu";
import { GridLayoutMenu } from "./GridLayoutMenu"; import { GridLayoutMenu } from "./GridLayoutMenu";
@@ -19,6 +34,8 @@ import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal"; import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { usePreventScroll } from "@react-aria/overlays"; import { usePreventScroll } from "@react-aria/overlays";
import { useMediaHandler } from "../settings/useMediaHandler"; import { useMediaHandler } from "../settings/useMediaHandler";
import { useShowInspector } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices; const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
// There is currently a bug in Safari our our code with cloning and sending MediaStreams // There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -30,6 +47,7 @@ export function InCallView({
client, client,
groupCall, groupCall,
roomName, roomName,
avatarUrl,
microphoneMuted, microphoneMuted,
localVideoMuted, localVideoMuted,
toggleLocalVideoMuted, toggleLocalVideoMuted,
@@ -40,14 +58,19 @@ export function InCallView({
toggleScreensharing, toggleScreensharing,
isScreensharing, isScreensharing,
screenshareFeeds, screenshareFeeds,
setShowInspector,
showInspector,
roomId, roomId,
}) { }) {
usePreventScroll(); usePreventScroll();
const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0); const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0);
const { audioOutput } = useMediaHandler(); const { audioOutput } = useMediaHandler();
const [showInspector] = useShowInspector();
const audioContext = useRef();
if (!audioContext.current) audioContext.current = new AudioContext();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const items = useMemo(() => { const items = useMemo(() => {
const participants = []; const participants = [];
@@ -84,23 +107,6 @@ export function InCallView({
return participants; return participants;
}, [userMediaFeeds, activeSpeaker, screenshareFeeds, layout]); }, [userMediaFeeds, activeSpeaker, screenshareFeeds, layout]);
const onFocusTile = useCallback(
(tiles, focusedTile) => {
if (layout === "freedom") {
return tiles.map((tile) => {
if (tile === focusedTile) {
return { ...tile, focused: !tile.focused };
}
return tile;
});
} else {
return tiles;
}
},
[layout, setLayout]
);
const renderAvatar = useCallback( const renderAvatar = useCallback(
(roomMember, width, height) => { (roomMember, width, height) => {
const avatarUrl = roomMember.user?.avatarUrl; const avatarUrl = roomMember.user?.avatarUrl;
@@ -109,13 +115,8 @@ export function InCallView({
return ( return (
<Avatar <Avatar
key={roomMember.userId} key={roomMember.userId}
style={{ size={size}
width: size, src={avatarUrl}
height: size,
borderRadius: size,
fontSize: Math.round(size / 2),
}}
src={avatarUrl && getAvatarUrl(client, avatarUrl, 96)}
fallback={roomMember.name.slice(0, 1).toUpperCase()} fallback={roomMember.name.slice(0, 1).toUpperCase()}
className={styles.avatar} className={styles.avatar}
/> />
@@ -133,7 +134,7 @@ export function InCallView({
<div className={styles.inRoom}> <div className={styles.inRoom}>
<Header> <Header>
<LeftNav> <LeftNav>
<RoomHeaderInfo roomName={roomName} /> <RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</LeftNav> </LeftNav>
<RightNav> <RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} /> <GridLayoutMenu layout={layout} setLayout={setLayout} />
@@ -145,12 +146,7 @@ export function InCallView({
<p>Waiting for other participants...</p> <p>Waiting for other participants...</p>
</div> </div>
) : ( ) : (
<VideoGrid <VideoGrid items={items} layout={layout} disableAnimations={isSafari}>
items={items}
layout={layout}
onFocusTile={onFocusTile}
disableAnimations={isSafari}
>
{({ item, ...rest }) => ( {({ item, ...rest }) => (
<VideoTileContainer <VideoTileContainer
key={item.id} key={item.id}
@@ -158,6 +154,7 @@ export function InCallView({
getAvatar={renderAvatar} getAvatar={renderAvatar}
showName={items.length > 2 || item.focused} showName={items.length > 2 || item.focused}
audioOutputDevice={audioOutput} audioOutputDevice={audioOutput}
audioContext={audioContext.current}
disableSpeakingIndicator={items.length < 3} disableSpeakingIndicator={items.length < 3}
{...rest} {...rest}
/> />
@@ -176,10 +173,11 @@ export function InCallView({
<OverflowMenu <OverflowMenu
inCall inCall
roomId={roomId} roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client} client={client}
groupCall={groupCall} groupCall={groupCall}
showInvite={true}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/> />
<HangupButton onPress={onLeave} /> <HangupButton onPress={onLeave} />
</div> </div>

View File

@@ -1,3 +1,19 @@
/*
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 from "react"; import React from "react";
import { Modal, ModalContent } from "../Modal"; import { Modal, ModalContent } from "../Modal";
import { CopyButton } from "../button"; import { CopyButton } from "../button";

View File

@@ -1,24 +1,38 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import styles from "./LobbyView.module.css"; import styles from "./LobbyView.module.css";
import { Button, CopyButton, MicButton, VideoButton } from "../button"; import { Button, CopyButton } from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { useCallFeed } from "../video-grid/useCallFeed"; import { useCallFeed } from "../video-grid/useCallFeed";
import { useMediaStream } from "../video-grid/useMediaStream";
import { getRoomUrl } from "../matrix-utils"; import { getRoomUrl } from "../matrix-utils";
import { OverflowMenu } from "./OverflowMenu";
import { UserMenuContainer } from "../UserMenuContainer"; import { UserMenuContainer } from "../UserMenuContainer";
import { Body, Link } from "../typography/Typography"; import { Body, Link } from "../typography/Typography";
import { Avatar } from "../Avatar";
import { useProfile } from "../profile/useProfile";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { useLocationNavigation } from "../useLocationNavigation"; import { useLocationNavigation } from "../useLocationNavigation";
import { useMediaHandler } from "../settings/useMediaHandler"; import { useMediaHandler } from "../settings/useMediaHandler";
import { VideoPreview } from "./VideoPreview";
import { AudioPreview } from "./AudioPreview";
export function LobbyView({ export function LobbyView({
client, client,
groupCall,
roomName, roomName,
avatarUrl,
state, state,
onInitLocalCallFeed, onInitLocalCallFeed,
onEnter, onEnter,
@@ -27,16 +41,17 @@ export function LobbyView({
localVideoMuted, localVideoMuted,
toggleLocalVideoMuted, toggleLocalVideoMuted,
toggleMicrophoneMuted, toggleMicrophoneMuted,
setShowInspector,
showInspector,
roomId, roomId,
}) { }) {
const { stream } = useCallFeed(localCallFeed); const { stream } = useCallFeed(localCallFeed);
const { audioOutput } = useMediaHandler(); const {
const videoRef = useMediaStream(stream, audioOutput, true); audioInput,
const { displayName, avatarUrl } = useProfile(client); audioInputs,
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver }); setAudioInput,
const avatarSize = (previewBounds.height - 66) / 2; audioOutput,
audioOutputs,
setAudioOutput,
} = useMediaHandler();
useEffect(() => { useEffect(() => {
onInitLocalCallFeed(); onInitLocalCallFeed();
@@ -56,7 +71,7 @@ export function LobbyView({
<div className={styles.room}> <div className={styles.room}>
<Header> <Header>
<LeftNav> <LeftNav>
<RoomHeaderInfo roomName={roomName} /> <RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</LeftNav> </LeftNav>
<RightNav> <RightNav>
<UserMenuContainer /> <UserMenuContainer />
@@ -64,53 +79,30 @@ export function LobbyView({
</Header> </Header>
<div className={styles.joinRoom}> <div className={styles.joinRoom}>
<div className={styles.joinRoomContent}> <div className={styles.joinRoomContent}>
<div className={styles.preview} ref={previewRef}> {groupCall.isPtt ? (
<video ref={videoRef} muted playsInline disablePictureInPicture /> <AudioPreview
{state === GroupCallState.LocalCallFeedUninitialized && ( roomName={roomName}
<Body fontWeight="semiBold" className={styles.webcamPermissions}> state={state}
Webcam/microphone permissions needed to join the call. audioInput={audioInput}
</Body> audioInputs={audioInputs}
)} setAudioInput={setAudioInput}
{state === GroupCallState.InitializingLocalCallFeed && ( audioOutput={audioOutput}
<Body fontWeight="semiBold" className={styles.webcamPermissions}> audioOutputs={audioOutputs}
Accept webcam/microphone permissions to join the call. setAudioOutput={setAudioOutput}
</Body> />
)} ) : (
{state === GroupCallState.LocalCallFeedInitialized && ( <VideoPreview
<> state={state}
{localVideoMuted && ( client={client}
<div className={styles.avatarContainer}> roomId={roomId}
<Avatar microphoneMuted={microphoneMuted}
style={{ localVideoMuted={localVideoMuted}
width: avatarSize, toggleLocalVideoMuted={toggleLocalVideoMuted}
height: avatarSize, toggleMicrophoneMuted={toggleMicrophoneMuted}
borderRadius: avatarSize, stream={stream}
fontSize: Math.round(avatarSize / 2), audioOutput={audioOutput}
}} />
src={avatarUrl} )}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
</div>
)}
<div className={styles.previewButtons}>
<MicButton
muted={microphoneMuted}
onPress={toggleMicrophoneMuted}
/>
<VideoButton
muted={localVideoMuted}
onPress={toggleLocalVideoMuted}
/>
<OverflowMenu
roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
/>
</div>
</>
)}
</div>
<Button <Button
ref={joinCallButtonRef} ref={joinCallButtonRef}
className={styles.copyButton} className={styles.copyButton}

View File

@@ -46,58 +46,6 @@ limitations under the License.
margin-top: 50px; margin-top: 50px;
} }
.preview {
position: relative;
min-height: 280px;
height: 50vh;
border-radius: 24px;
overflow: hidden;
background-color: var(--bgColor3);
margin: 20px;
}
.preview video {
width: calc(100% + 1px);
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);
}
.avatarContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--bgColor3);
}
.webcamPermissions {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
text-align: center;
}
.previewButtons {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(23, 25, 28, 0.9);
}
.joinCallButton { .joinCallButton {
position: absolute; position: absolute;
width: 100%; width: 100%;
@@ -118,17 +66,3 @@ limitations under the License.
.copyButton:last-child { .copyButton:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.previewButtons > * {
margin-right: 30px;
}
.previewButtons > :last-child {
margin-right: 0px;
}
@media (min-width: 800px) {
.preview {
margin-top: 40px;
}
}

View File

@@ -1,3 +1,19 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Button } from "../button"; import { Button } from "../button";
import { Menu } from "../Menu"; import { Menu } from "../Menu";
@@ -15,17 +31,16 @@ import { FeedbackModal } from "./FeedbackModal";
export function OverflowMenu({ export function OverflowMenu({
roomId, roomId,
setShowInspector,
showInspector,
inCall, inCall,
groupCall, groupCall,
showInvite,
feedbackModalState,
feedbackModalProps,
}) { }) {
const { modalState: inviteModalState, modalProps: inviteModalProps } = const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState(); useModalTriggerState();
const { modalState: settingsModalState, modalProps: settingsModalProps } = const { modalState: settingsModalState, modalProps: settingsModalProps } =
useModalTriggerState(); useModalTriggerState();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
// TODO: On closing modal, focus should be restored to the trigger button // TODO: On closing modal, focus should be restored to the trigger button
// https://github.com/adobe/react-spectrum/issues/2444 // https://github.com/adobe/react-spectrum/issues/2444
@@ -54,10 +69,12 @@ export function OverflowMenu({
</TooltipTrigger> </TooltipTrigger>
{(props) => ( {(props) => (
<Menu {...props} label="More menu" onAction={onAction}> <Menu {...props} label="More menu" onAction={onAction}>
<Item key="invite" textValue="Invite people"> {showInvite && (
<AddUserIcon /> <Item key="invite" textValue="Invite people">
<span>Invite people</span> <AddUserIcon />
</Item> <span>Invite people</span>
</Item>
)}
<Item key="settings" textValue="Settings"> <Item key="settings" textValue="Settings">
<SettingsIcon /> <SettingsIcon />
<span>Settings</span> <span>Settings</span>
@@ -69,13 +86,7 @@ export function OverflowMenu({
</Menu> </Menu>
)} )}
</PopoverMenuTrigger> </PopoverMenuTrigger>
{settingsModalState.isOpen && ( {settingsModalState.isOpen && <SettingsModal {...settingsModalProps} />}
<SettingsModal
{...settingsModalProps}
setShowInspector={setShowInspector}
showInspector={showInspector}
/>
)}
{inviteModalState.isOpen && ( {inviteModalState.isOpen && (
<InviteModal roomId={roomId} {...inviteModalProps} /> <InviteModal roomId={roomId} {...inviteModalProps} />
)} )}

View File

@@ -0,0 +1,23 @@
.pttButton {
width: 100vw;
height: 100vh;
max-height: 232px;
max-width: 232px;
border-radius: 116px;
color: var(--primary-content);
border: 6px solid var(--accent);
background-color: #21262c;
position: relative;
padding: 0;
cursor: pointer;
}
.talking {
background-color: var(--accent);
cursor: unset;
}
.error {
background-color: var(--alert);
border-color: var(--alert);
}

182
src/room/PTTButton.tsx Normal file
View File

@@ -0,0 +1,182 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState, createRef } from "react";
import classNames from "classnames";
import { useSpring, animated } from "@react-spring/web";
import styles from "./PTTButton.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { Avatar } from "../Avatar";
interface Props {
showTalkOverError: boolean;
activeSpeakerUserId: string;
activeSpeakerDisplayName: string;
activeSpeakerAvatarUrl: string;
activeSpeakerIsLocalUser: boolean;
activeSpeakerVolume: number;
size: number;
startTalking: () => void;
stopTalking: () => void;
}
interface State {
isHeld: boolean;
// If the button is being pressed by touch, the ID of that touch
activeTouchID: number | null;
}
export const PTTButton: React.FC<Props> = ({
showTalkOverError,
activeSpeakerUserId,
activeSpeakerDisplayName,
activeSpeakerAvatarUrl,
activeSpeakerIsLocalUser,
activeSpeakerVolume,
size,
startTalking,
stopTalking,
}) => {
const buttonRef = createRef<HTMLButtonElement>();
const [{ isHeld, activeTouchID }, setState] = useState<State>({
isHeld: false,
activeTouchID: null,
});
const onWindowMouseUp = useCallback(
(e) => {
if (isHeld) stopTalking();
setState({ isHeld: false, activeTouchID: null });
},
[isHeld, setState, stopTalking]
);
const onWindowTouchEnd = useCallback(
(e: TouchEvent) => {
// ignore any ended touches that weren't the one pressing the
// button (bafflingly the TouchList isn't an iterable so we
// have to do this a really old-school way).
let touchFound = false;
for (let i = 0; i < e.changedTouches.length; ++i) {
if (e.changedTouches.item(i).identifier === activeTouchID) {
touchFound = true;
break;
}
}
if (!touchFound) return;
e.preventDefault();
if (isHeld) stopTalking();
setState({ isHeld: false, activeTouchID: null });
},
[isHeld, activeTouchID, setState, stopTalking]
);
const onButtonMouseDown = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setState({ isHeld: true, activeTouchID: null });
startTalking();
},
[setState, startTalking]
);
const onButtonTouchStart = useCallback(
(e: TouchEvent) => {
e.preventDefault();
if (isHeld) return;
setState({
isHeld: true,
activeTouchID: e.changedTouches.item(0).identifier,
});
startTalking();
},
[isHeld, setState, startTalking]
);
useEffect(() => {
const currentButtonElement = buttonRef.current;
// These listeners go on the window so even if the user's cursor / finger
// leaves the button while holding it, the button stays pushed until
// they stop clicking / tapping.
window.addEventListener("mouseup", onWindowMouseUp);
window.addEventListener("touchend", onWindowTouchEnd);
// This is a native DOM listener too because we want to preventDefault in it
// to stop also getting a click event, so we need it to be non-passive.
currentButtonElement.addEventListener("touchstart", onButtonTouchStart, {
passive: false,
});
return () => {
window.removeEventListener("mouseup", onWindowMouseUp);
window.removeEventListener("touchend", onWindowTouchEnd);
currentButtonElement.removeEventListener(
"touchstart",
onButtonTouchStart
);
};
}, [onWindowMouseUp, onWindowTouchEnd, onButtonTouchStart, buttonRef]);
const { shadow } = useSpring({
shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.6,
config: {
clamp: true,
tension: 300,
},
});
const shadowColor = showTalkOverError
? "var(--alert-20)"
: "var(--accent-20)";
return (
<animated.button
className={classNames(styles.pttButton, {
[styles.talking]: activeSpeakerUserId,
[styles.error]: showTalkOverError,
})}
style={{
boxShadow: shadow.to(
(s) =>
`0px 0px 0px ${s}px ${shadowColor}, 0px 0px 0px ${
2 * s
}px ${shadowColor}`
),
}}
onMouseDown={onButtonMouseDown}
ref={buttonRef}
>
{activeSpeakerIsLocalUser || !activeSpeakerUserId ? (
<MicIcon
className={styles.micIcon}
width={size / 3}
height={size / 3}
/>
) : (
<Avatar
key={activeSpeakerUserId}
size={size - 12}
src={activeSpeakerAvatarUrl}
fallback={activeSpeakerDisplayName.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
)}
</animated.button>
);
};

View File

@@ -0,0 +1,112 @@
.pttCallView {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
min-height: 100%;
position: fixed;
height: 100%;
width: 100%;
}
@media (hover: none) {
.pttCallView {
user-select: none;
}
}
.center {
width: 100%;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
}
.participants {
display: flex;
flex-direction: column;
margin: 20px;
}
.participants > p {
color: var(--secondary-content);
margin-bottom: 8px;
}
.facepile {
align-self: center;
}
.talkingInfo {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
height: 88px;
}
.speakerIcon {
margin-right: 8px;
}
.pttButtonContainer {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
justify-content: center;
}
.actionTip {
margin-top: 20px;
margin-bottom: 20px;
font-size: 17px;
}
.footer {
position: relative;
display: flex;
justify-content: center;
height: 64px;
margin-bottom: 20px;
}
.footer > * {
margin-right: 30px;
}
.footer > :last-child {
margin-right: 0px;
}
@media (min-width: 800px) {
.participants {
margin-bottom: 67px;
}
.talkingInfo {
margin-bottom: 38px;
}
.center {
margin-top: 48px;
}
.actionTip {
margin-top: 42px;
margin-bottom: 45px;
}
.pttButtonContainer {
flex: 0;
margin-bottom: 0;
justify-content: flex-start;
}
.footer {
flex: auto;
order: 4;
}
}

264
src/room/PTTCallView.tsx Normal file
View File

@@ -0,0 +1,264 @@
/*
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 from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCall, MatrixClient, RoomMember } from "matrix-js-sdk";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useModalTriggerState } from "../Modal";
import { InviteModal } from "./InviteModal";
import { HangupButton, InviteButton } from "../button";
import { Header, LeftNav, RightNav, RoomSetupHeaderInfo } from "../Header";
import styles from "./PTTCallView.module.css";
import { Facepile } from "../Facepile";
import { PTTButton } from "./PTTButton";
import { PTTFeed } from "./PTTFeed";
import { useMediaHandler } from "../settings/useMediaHandler";
import { usePTT } from "./usePTT";
import { Timer } from "./Timer";
import { Toggle } from "../input/Toggle";
import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
import { usePTTSounds } from "../sound/usePttSounds";
import { PTTClips } from "../sound/PTTClips";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
function getPromptText(
showTalkOverError: boolean,
pttButtonHeld: boolean,
activeSpeakerIsLocalUser: boolean,
talkOverEnabled: boolean,
activeSpeakerUserId: string,
activeSpeakerDisplayName: string,
connected: boolean
): string {
if (!connected) return "Connection Lost";
const isTouchScreen = Boolean(window.ontouchstart !== undefined);
if (showTalkOverError) {
return "You can't talk at the same time";
}
if (pttButtonHeld && activeSpeakerIsLocalUser) {
if (isTouchScreen) {
return "Release to stop";
} else {
return "Release spacebar key to stop";
}
}
if (talkOverEnabled && activeSpeakerUserId && !activeSpeakerIsLocalUser) {
if (isTouchScreen) {
return `Press and hold to talk over ${activeSpeakerDisplayName}`;
} else {
return `Press and hold spacebar to talk over ${activeSpeakerDisplayName}`;
}
}
if (isTouchScreen) {
return "Press and hold to talk";
} else {
return "Press and hold spacebar to talk";
}
}
interface Props {
client: MatrixClient;
roomId: string;
roomName: string;
avatarUrl: string;
groupCall: GroupCall;
participants: RoomMember[];
userMediaFeeds: CallFeed[];
onLeave: () => void;
}
export const PTTCallView: React.FC<Props> = ({
client,
roomId,
roomName,
avatarUrl,
groupCall,
participants,
userMediaFeeds,
onLeave,
}) => {
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
const facepileSize = bounds.width < 800 ? "sm" : "md";
const pttButtonSize = 232;
const { audioOutput } = useMediaHandler();
const {
startTalkingLocalRef,
startTalkingRemoteRef,
blockedRef,
endTalkingRef,
playClip,
} = usePTTSounds();
const {
pttButtonHeld,
isAdmin,
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
activeSpeakerVolume,
startTalking,
stopTalking,
transmitBlocked,
connected,
} = usePTT(
client,
groupCall,
userMediaFeeds,
playClip,
!feedbackModalState.isOpen
);
const showTalkOverError = pttButtonHeld && transmitBlocked;
const activeSpeakerIsLocalUser =
activeSpeakerUserId && client.getUserId() === activeSpeakerUserId;
const activeSpeakerUser = activeSpeakerUserId
? client.getUser(activeSpeakerUserId)
: null;
const activeSpeakerAvatarUrl = activeSpeakerUser?.avatarUrl;
const activeSpeakerDisplayName = activeSpeakerUser
? activeSpeakerUser.displayName
: "";
return (
<div className={styles.pttCallView} ref={containerRef}>
<PTTClips
startTalkingLocalRef={startTalkingLocalRef}
startTalkingRemoteRef={startTalkingRemoteRef}
endTalkingRef={endTalkingRef}
blockedRef={blockedRef}
/>
<GroupCallInspector
client={client}
groupCall={groupCall}
// Never shown in PTT mode, but must be present to collect call state
// https://github.com/vector-im/element-call/issues/328
show={false}
/>
<Header className={styles.header}>
<LeftNav>
<RoomSetupHeaderInfo
roomName={roomName}
avatarUrl={avatarUrl}
onPress={onLeave}
/>
</LeftNav>
<RightNav />
</Header>
<div className={styles.center}>
<div className={styles.participants}>
<p>{`${participants.length} ${
participants.length > 1 ? "people" : "person"
} connected`}</p>
<Facepile
size={facepileSize}
max={8}
className={styles.facepile}
client={client}
participants={participants}
/>
</div>
<div className={styles.footer}>
<OverflowMenu
inCall
roomId={roomId}
client={client}
groupCall={groupCall}
showInvite={false}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
<HangupButton onPress={onLeave} />
<InviteButton onPress={() => inviteModalState.open()} />
</div>
<div className={styles.pttButtonContainer}>
{activeSpeakerUserId ? (
<div className={styles.talkingInfo}>
<h2>
{!activeSpeakerIsLocalUser && (
<AudioIcon className={styles.speakerIcon} />
)}
{activeSpeakerIsLocalUser
? "Talking..."
: `${activeSpeakerDisplayName} is talking...`}
</h2>
<Timer value={activeSpeakerUserId} />
</div>
) : (
<div className={styles.talkingInfo} />
)}
<PTTButton
showTalkOverError={showTalkOverError}
activeSpeakerUserId={activeSpeakerUserId}
activeSpeakerDisplayName={activeSpeakerDisplayName}
activeSpeakerAvatarUrl={activeSpeakerAvatarUrl}
activeSpeakerIsLocalUser={activeSpeakerIsLocalUser}
activeSpeakerVolume={activeSpeakerVolume}
size={pttButtonSize}
startTalking={startTalking}
stopTalking={stopTalking}
/>
<p className={styles.actionTip}>
{getPromptText(
showTalkOverError,
pttButtonHeld,
activeSpeakerIsLocalUser,
talkOverEnabled,
activeSpeakerUserId,
activeSpeakerDisplayName,
connected
)}
</p>
{userMediaFeeds.map((callFeed) => (
<PTTFeed
key={callFeed.userId}
callFeed={callFeed}
audioOutputDevice={audioOutput}
/>
))}
{isAdmin && (
<Toggle
isSelected={talkOverEnabled}
onChange={setTalkOverEnabled}
label="Talk over speaker"
id="talkOverEnabled"
/>
)}
</div>
</div>
{inviteModalState.isOpen && (
<InviteModal roomId={roomId} {...inviteModalProps} />
)}
</div>
);
};

26
src/room/PTTFeed.jsx Normal file
View File

@@ -0,0 +1,26 @@
/*
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 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 }) {
const { isLocal, stream } = useCallFeed(callFeed);
const mediaRef = useMediaStream(stream, audioOutputDevice, isLocal);
return <audio ref={mediaRef} className={styles.audioFeed} playsInline />;
}

View File

@@ -0,0 +1,3 @@
.audioFeed {
display: none;
}

View File

@@ -1,3 +1,19 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Modal, ModalContent } from "../Modal"; import { Modal, ModalContent } from "../Modal";
import { Button } from "../button"; import { Button } from "../button";

View File

@@ -1,5 +1,22 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import styles from "./RoomAuthView.module.css"; import styles from "./RoomAuthView.module.css";
import { useClient } from "../ClientContext";
import { Button } from "../button"; import { Button } from "../button";
import { Body, Caption, Link, Headline } from "../typography/Typography"; import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
@@ -13,11 +30,13 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { generateRandomName } from "../auth/generateRandomName"; import { generateRandomName } from "../auth/generateRandomName";
export function RoomAuthView() { export function RoomAuthView() {
const { setClient } = useClient();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState();
const [{ privacyPolicyUrl, recaptchaKey }, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmit = useCallback( const onSubmit = useCallback(
(e) => { (e) => {
e.preventDefault(); e.preventDefault();
@@ -29,13 +48,14 @@ export function RoomAuthView() {
setLoading(true); setLoading(true);
const recaptchaResponse = await execute(); const recaptchaResponse = await execute();
const userName = generateRandomName(); const userName = generateRandomName();
await register( const [client, session] = await register(
userName, userName,
randomString(16), randomString(16),
displayName, displayName,
recaptchaResponse, recaptchaResponse,
true true
); );
setClient(client, session);
} }
submit().catch((error) => { submit().catch((error) => {

View File

@@ -1,3 +1,19 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useLocation, useHistory } from "react-router-dom"; import { useLocation, useHistory } from "react-router-dom";
import { defaultHomeserverHost } from "../matrix-utils"; import { defaultHomeserverHost } from "../matrix-utils";

53
src/room/Timer.jsx Normal file
View File

@@ -0,0 +1,53 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useState } from "react";
function leftPad(value) {
return value < 10 ? "0" + value : value;
}
function formatTime(msElapsed) {
const secondsElapsed = msElapsed / 1000;
const hours = Math.floor(secondsElapsed / 3600);
const minutes = Math.floor(secondsElapsed / 60) - hours * 60;
const seconds = Math.floor(secondsElapsed - hours * 3600 - minutes * 60);
return `${leftPad(hours)}:${leftPad(minutes)}:${leftPad(seconds)}`;
}
export function Timer({ value }) {
const [timestamp, setTimestamp] = useState();
useEffect(() => {
const startTimeMs = performance.now();
let animationFrame;
function onUpdate(curTimeMs) {
const msElapsed = curTimeMs - startTimeMs;
setTimestamp(formatTime(msElapsed));
animationFrame = requestAnimationFrame(onUpdate);
}
onUpdate(startTimeMs);
return () => {
cancelAnimationFrame(animationFrame);
};
}, [value]);
return <p>{timestamp}</p>;
}

93
src/room/VideoPreview.jsx Normal file
View File

@@ -0,0 +1,93 @@
/*
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 from "react";
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";
export function VideoPreview({
client,
state,
roomId,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
audioOutput,
stream,
}) {
const videoRef = useMediaStream(stream, audioOutput, true);
const { displayName, avatarUrl } = useProfile(client);
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
const avatarSize = (previewBounds.height - 66) / 2;
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
return (
<div className={styles.preview} ref={previewRef}>
<video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
Camera/microphone permissions needed to join the call.
</Body>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
Accept camera/microphone permissions to join the call.
</Body>
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<>
{localVideoMuted && (
<div className={styles.avatarContainer}>
<Avatar
size={avatarSize}
src={avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
</div>
)}
<div className={styles.previewButtons}>
<MicButton
muted={microphoneMuted}
onPress={toggleMicrophoneMuted}
/>
<VideoButton
muted={localVideoMuted}
onPress={toggleLocalVideoMuted}
/>
<OverflowMenu
roomId={roomId}
client={client}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
.preview {
position: relative;
min-height: 280px;
height: 50vh;
border-radius: 24px;
overflow: hidden;
background-color: var(--bgColor3);
margin: 20px;
}
.preview video {
width: calc(100% + 1px);
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);
}
.avatarContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--bgColor3);
}
.cameraPermissions {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
text-align: center;
}
.previewButtons {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(23, 25, 28, 0.9);
}
.previewButtons > * {
margin-right: 30px;
}
.previewButtons > :last-child {
margin-right: 0px;
}
@media (min-width: 800px) {
.preview {
margin-top: 40px;
}
}

View File

@@ -1,11 +1,74 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
GroupCallEvent, GroupCallEvent,
GroupCallState, GroupCallState,
GroupCall,
} from "matrix-js-sdk/src/webrtc/groupCall"; } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { usePageUnload } from "./usePageUnload"; import { usePageUnload } from "./usePageUnload";
export function useGroupCall(groupCall) { export interface UseGroupCallType {
state: GroupCallState;
calls: MatrixCall[];
localCallFeed: CallFeed;
activeSpeaker: string;
userMediaFeeds: CallFeed[];
microphoneMuted: boolean;
localVideoMuted: boolean;
error: Error;
initLocalCallFeed: () => void;
enter: () => void;
leave: () => void;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
toggleScreensharing: () => void;
requestingScreenshare: boolean;
isScreensharing: boolean;
screenshareFeeds: CallFeed[];
localScreenshareFeed: CallFeed;
localDesktopCapturerSourceId: string;
participants: RoomMember[];
hasLocalParticipant: boolean;
}
interface State {
state: GroupCallState;
calls: MatrixCall[];
localCallFeed: CallFeed;
activeSpeaker: string;
userMediaFeeds: CallFeed[];
error: Error;
microphoneMuted: boolean;
localVideoMuted: boolean;
screenshareFeeds: CallFeed[];
localScreenshareFeed: CallFeed;
localDesktopCapturerSourceId: string;
isScreensharing: boolean;
requestingScreenshare: boolean;
participants: RoomMember[];
hasLocalParticipant: boolean;
}
export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
const [ const [
{ {
state, state,
@@ -25,20 +88,25 @@ export function useGroupCall(groupCall) {
requestingScreenshare, requestingScreenshare,
}, },
setState, setState,
] = useState({ ] = useState<State>({
state: GroupCallState.LocalCallFeedUninitialized, state: GroupCallState.LocalCallFeedUninitialized,
calls: [], calls: [],
localCallFeed: null,
activeSpeaker: null,
userMediaFeeds: [], userMediaFeeds: [],
error: null,
microphoneMuted: false, microphoneMuted: false,
localVideoMuted: false, localVideoMuted: false,
screenshareFeeds: [],
isScreensharing: false, isScreensharing: false,
screenshareFeeds: [],
localScreenshareFeed: null,
localDesktopCapturerSourceId: null,
requestingScreenshare: false, requestingScreenshare: false,
participants: [], participants: [],
hasLocalParticipant: false, hasLocalParticipant: false,
}); });
const updateState = (state) => const updateState = (state: Partial<State>) =>
setState((prevState) => ({ ...prevState, ...state })); setState((prevState) => ({ ...prevState, ...state }));
useEffect(() => { useEffect(() => {
@@ -59,25 +127,28 @@ export function useGroupCall(groupCall) {
}); });
} }
function onUserMediaFeedsChanged(userMediaFeeds) { function onUserMediaFeedsChanged(userMediaFeeds: CallFeed[]): void {
updateState({ updateState({
userMediaFeeds: [...userMediaFeeds], userMediaFeeds: [...userMediaFeeds],
}); });
} }
function onScreenshareFeedsChanged(screenshareFeeds) { function onScreenshareFeedsChanged(screenshareFeeds: CallFeed[]): void {
updateState({ updateState({
screenshareFeeds: [...screenshareFeeds], screenshareFeeds: [...screenshareFeeds],
}); });
} }
function onActiveSpeakerChanged(activeSpeaker) { function onActiveSpeakerChanged(activeSpeaker: string): void {
updateState({ updateState({
activeSpeaker: activeSpeaker, activeSpeaker: activeSpeaker,
}); });
} }
function onLocalMuteStateChanged(microphoneMuted, localVideoMuted) { function onLocalMuteStateChanged(
microphoneMuted: boolean,
localVideoMuted: boolean
): void {
updateState({ updateState({
microphoneMuted, microphoneMuted,
localVideoMuted, localVideoMuted,
@@ -85,10 +156,10 @@ export function useGroupCall(groupCall) {
} }
function onLocalScreenshareStateChanged( function onLocalScreenshareStateChanged(
isScreensharing, isScreensharing: boolean,
localScreenshareFeed, localScreenshareFeed: CallFeed,
localDesktopCapturerSourceId localDesktopCapturerSourceId: string
) { ): void {
updateState({ updateState({
isScreensharing, isScreensharing,
localScreenshareFeed, localScreenshareFeed,
@@ -96,13 +167,13 @@ export function useGroupCall(groupCall) {
}); });
} }
function onCallsChanged(calls) { function onCallsChanged(calls: MatrixCall[]): void {
updateState({ updateState({
calls: [...calls], calls: [...calls],
}); });
} }
function onParticipantsChanged(participants) { function onParticipantsChanged(participants: RoomMember[]): void {
updateState({ updateState({
participants: [...participants], participants: [...participants],
hasLocalParticipant: groupCall.hasLocalParticipant(), hasLocalParticipant: groupCall.hasLocalParticipant(),

View File

@@ -1,3 +1,19 @@
/*
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 { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils"; import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
@@ -86,7 +102,7 @@ export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
.catch((error) => .catch((error) =>
setState((prevState) => ({ ...prevState, loading: false, error })) setState((prevState) => ({ ...prevState, loading: false, error }))
); );
}, [client, roomId, state.reloadId]); }, [client, roomId, state.reloadId, createIfNotFound, viaServers]);
return state; return state;
} }

341
src/room/usePTT.ts Normal file
View File

@@ -0,0 +1,341 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback, useEffect, useState } from "react";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
import { logger } from "matrix-js-sdk/src/logger";
import { SyncState } from "matrix-js-sdk/src/sync";
import { PlayClipFunction, PTTClipID } from "../sound/usePttSounds";
// Works out who the active speaker should be given what feeds are active and
// the power level of each user.
function getActiveSpeakerFeed(
feeds: CallFeed[],
groupCall: GroupCall
): CallFeed | null {
const activeSpeakerFeeds = feeds.filter((f) => !f.isAudioMuted());
// make sure the feeds are in a deterministic order so every client picks
// the same one as the active speaker. The custom sort function sorts
// by user ID, so needs a collator of some kind to compare. We make a
// specific one to help ensure every client sorts the same way
// although of course user IDs shouldn't contain accented characters etc.
// anyway).
const collator = new Intl.Collator("en", {
sensitivity: "variant",
usage: "sort",
ignorePunctuation: false,
});
activeSpeakerFeeds.sort((a: CallFeed, b: CallFeed): number =>
collator.compare(a.userId, b.userId)
);
let activeSpeakerFeed = null;
let highestPowerLevel = null;
for (const feed of activeSpeakerFeeds) {
const member = groupCall.room.getMember(feed.userId);
if (highestPowerLevel === null || member.powerLevel > highestPowerLevel) {
highestPowerLevel = member.powerLevel;
activeSpeakerFeed = feed;
}
}
return activeSpeakerFeed;
}
export interface PTTState {
pttButtonHeld: boolean;
isAdmin: boolean;
talkOverEnabled: boolean;
setTalkOverEnabled: (boolean) => void;
activeSpeakerUserId: string;
activeSpeakerVolume: number;
startTalking: () => void;
stopTalking: () => void;
transmitBlocked: boolean;
// connected is actually an indication of whether we're connected to the HS
// (ie. the client's syncing state) rather than media connection, since
// it's peer to peer so we can't really say which peer is 'disconnected' if
// there's only one other person in the call and they've lost Internet.
connected: boolean;
}
export const usePTT = (
client: MatrixClient,
groupCall: GroupCall,
userMediaFeeds: CallFeed[],
playClip: PlayClipFunction,
enablePTTButton: boolean
): PTTState => {
// Used to serialise all the mute calls so they don't race. It has
// its own state as its always set separately from anything else.
const [mutePromise, setMutePromise] = useState(
Promise.resolve<boolean | void>(false)
);
// Wrapper to serialise all the mute operations on the promise
const setMicMuteWrapper = useCallback(
(muted: boolean) => {
setMutePromise(
mutePromise.then(() => {
return groupCall.setMicrophoneMuted(muted).catch((e) => {
logger.error("Failed to unmute microphone", e);
});
})
);
},
[groupCall, mutePromise]
);
const [
{
pttButtonHeld,
isAdmin,
talkOverEnabled,
activeSpeakerUserId,
activeSpeakerVolume,
transmitBlocked,
},
setState,
] = useState(() => {
const roomMember = groupCall.room.getMember(client.getUserId());
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
return {
isAdmin: roomMember.powerLevel >= 100,
talkOverEnabled: false,
pttButtonHeld: false,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
activeSpeakerVolume: -Infinity,
transmitBlocked: false,
};
});
const onMuteStateChanged = useCallback(() => {
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
let blocked = false;
if (activeSpeakerUserId === null && activeSpeakerFeed !== null) {
if (activeSpeakerFeed.userId === client.getUserId()) {
playClip(PTTClipID.START_TALKING_LOCAL);
} else {
playClip(PTTClipID.START_TALKING_REMOTE);
}
} else if (activeSpeakerUserId !== null && activeSpeakerFeed === null) {
playClip(PTTClipID.END_TALKING);
} else if (
pttButtonHeld &&
activeSpeakerUserId === client.getUserId() &&
activeSpeakerFeed?.userId !== client.getUserId()
) {
// We were talking but we've been cut off: mute our own mic
// (this is the easier way of cutting other speakers off if an
// admin barges in: we could also mute the non-admin speaker
// on all receivers, but we'd have to make sure we unmuted them
// correctly.)
setMicMuteWrapper(true);
blocked = true;
playClip(PTTClipID.BLOCKED);
}
setState((prevState) => ({
...prevState,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
transmitBlocked: blocked,
}));
}, [
playClip,
groupCall,
pttButtonHeld,
activeSpeakerUserId,
client,
userMediaFeeds,
setMicMuteWrapper,
]);
useEffect(() => {
for (const callFeed of userMediaFeeds) {
callFeed.on(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
}
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
setState((prevState) => ({
...prevState,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
}));
return () => {
for (const callFeed of userMediaFeeds) {
callFeed.off(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
}
};
}, [userMediaFeeds, onMuteStateChanged, groupCall]);
const onVolumeChanged = useCallback((volume: number) => {
setState((prevState) => ({
...prevState,
activeSpeakerVolume: volume,
}));
}, []);
useEffect(() => {
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
activeSpeakerFeed?.on(CallFeedEvent.VolumeChanged, onVolumeChanged);
return () => {
activeSpeakerFeed?.off(CallFeedEvent.VolumeChanged, onVolumeChanged);
setState((prevState) => ({
...prevState,
activeSpeakerVolume: -Infinity,
}));
};
}, [activeSpeakerUserId, onVolumeChanged, userMediaFeeds, groupCall]);
const startTalking = useCallback(async () => {
if (pttButtonHeld) return;
let blocked = false;
if (activeSpeakerUserId && !(isAdmin && talkOverEnabled)) {
playClip(PTTClipID.BLOCKED);
blocked = true;
}
// setstate before doing the async call to mute / unmute the mic
setState((prevState) => ({
...prevState,
pttButtonHeld: true,
transmitBlocked: blocked,
}));
if (!blocked && groupCall.isMicrophoneMuted()) {
setMicMuteWrapper(false);
}
}, [
pttButtonHeld,
groupCall,
activeSpeakerUserId,
isAdmin,
talkOverEnabled,
setState,
playClip,
setMicMuteWrapper,
]);
const stopTalking = useCallback(async () => {
setState((prevState) => ({
...prevState,
pttButtonHeld: false,
transmitBlocked: false,
}));
setMicMuteWrapper(true);
}, [setMicMuteWrapper]);
// separate state for connected: we set it separately from other things
// in the client sync callback
const [connected, setConnected] = useState(true);
const onClientSync = useCallback(
(syncState: SyncState) => {
setConnected(syncState !== SyncState.Error);
},
[setConnected]
);
useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
if (event.code === "Space") {
if (!enablePTTButton) return;
event.preventDefault();
if (pttButtonHeld) return;
startTalking();
}
}
function onKeyUp(event: KeyboardEvent): void {
if (event.code === "Space") {
event.preventDefault();
stopTalking();
}
}
function onBlur(): void {
// TODO: We will need to disable this for a global PTT hotkey to work
if (!groupCall.isMicrophoneMuted()) {
setMicMuteWrapper(true);
}
setState((prevState) => ({ ...prevState, pttButtonHeld: false }));
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
};
}, [
groupCall,
startTalking,
stopTalking,
activeSpeakerUserId,
isAdmin,
talkOverEnabled,
pttButtonHeld,
enablePTTButton,
setMicMuteWrapper,
client,
onClientSync,
]);
useEffect(() => {
client.on(ClientEvent.Sync, onClientSync);
return () => {
client.removeListener(ClientEvent.Sync, onClientSync);
};
}, [client, onClientSync]);
const setTalkOverEnabled = useCallback((talkOverEnabled) => {
setState((prevState) => ({
...prevState,
talkOverEnabled,
}));
}, []);
return {
pttButtonHeld,
isAdmin,
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
activeSpeakerVolume,
startTalking,
stopTalking,
transmitBlocked,
connected,
};
};

View File

@@ -1,3 +1,19 @@
/*
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 { useEffect } from "react"; import { useEffect } from "react";
// https://stackoverflow.com/a/9039885 // https://stackoverflow.com/a/9039885

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