Compare commits

...

354 Commits

Author SHA1 Message Date
Robin
21ec08ffbd Merge pull request #378 from robintown/lint-shortcut
Add a shortcut lint script
2022-06-07 09:26:28 -04:00
Robin
1a7211198b Merge pull request #377 from robintown/spatial-audio-copy
Tweak spatial audio copy
2022-06-07 09:25:56 -04:00
Matthew Hodgson
4f9efb3563 last minute s/radio call/walkie-talkie call/ig 2022-06-07 13:31:19 +01:00
Robin Townsend
2667e78b43 sound → seem 2022-06-06 11:26:48 -04:00
Robin Townsend
878b48aa7a Add a shortcut lint script 2022-06-06 11:21:51 -04:00
Robin Townsend
b314e047c1 Tweak spatial audio copy 2022-06-06 11:19:40 -04: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
Robert Long
c05b6c5118 Merge pull request #291 from vector-im/remove-matrix-react-sdk-dep
Remove dependency on matrix-react-sdk
2022-04-07 15:43:55 -07:00
Robert Long
72197c1a0a Remove dependency on matrix-react-sdk 2022-04-07 14:22:36 -07:00
Robert Long
46bcb8ac75 Merge pull request #285 from vector-im/fix-title
Fix Title
2022-03-29 11:15:42 -07:00
Robert Long
2ba1bab82d Fix title 2022-03-29 11:14:31 -07:00
Matthew Hodgson
3c56f7f481 Merge pull request #274 from vector-im/travis/idea-gitignore
Add .idea to gitignore
2022-03-20 11:03:15 +00:00
Travis Ralston
fcd8a41fc9 Add .idea to gitignore
For those of us using WebStorm
2022-03-16 13:44:38 -06:00
Matthew Hodgson
35f8b1ed85 link to #webrtc:matrix.org 2022-03-04 14:55:24 +00:00
Matthew Hodgson
7969e13fc1 copyright 2022-03-04 14:50:36 +00:00
Matthew Hodgson
4d433ab22d more renaming 2022-03-04 14:48:57 +00:00
Matthew Hodgson
d7f46607ad link 3401 2022-03-04 14:48:21 +00:00
Matthew Hodgson
1e59390599 s/matrix-video-chat/element-call/ 2022-03-04 14:47:44 +00:00
Robert Long
2457476bae Still capitalize words in snake case room ids 2022-03-03 17:09:31 -08:00
Robert Long
35fb1e710b Create room when not found and lowercase name 2022-03-03 16:56:45 -08:00
Robert Long
014b740e47 Update logo 2022-03-03 16:14:07 -08:00
Robert Long
2b3c04592b Only show remove button when there is an avatar to remove 2022-03-02 19:18:23 -08:00
Robert Long
ae50d57814 Set display name after interactive registration 2022-03-02 19:12:18 -08:00
Robert Long
9900d661be Fix avatar background when bgKey undefined 2022-03-01 11:37:45 -08:00
Robert Long
369b59a203 Fix ordering in 1-1 calls 2022-02-25 16:26:21 -08:00
Robert Long
6a18ba0110 Use raw display names for members 2022-02-23 16:41:12 -08:00
Robert Long
0a49ddb31e Fix input prefix 2022-02-23 16:34:11 -08:00
Robert Long
25385edf12 Use a textarea in the feedback modal 2022-02-23 16:07:14 -08:00
Robert Long
721cccf152 Disable speaking indicator in 1-1 calls 2022-02-23 15:59:16 -08:00
Robert Long
3b017eb92b Add room_id and display_name to rageshakes 2022-02-23 15:52:53 -08:00
Robert Long
641b82dc45 Fix creating rooms from not found screen 2022-02-23 15:36:38 -08:00
Robert Long
42e2041d6f Fix media handler device changes 2022-02-23 15:07:51 -08:00
Robert Long
2c3ebd4c03 Add audio output setting when available 2022-02-22 18:32:51 -08:00
Robert Long
81a763f17f Avoid duplicate sessions across devices/browsers 2022-02-18 16:23:37 -08:00
Robert Long
1ab7d27ba9 Finish user avatars 2022-02-18 16:02:27 -08:00
Robert Long
e76a805c8f Better logging of to device events / usernames 2022-02-17 14:08:53 -08:00
Robert Long
9fc4af2bd7 Add version to console, rageshake, and settings modal 2022-02-16 11:29:43 -08:00
Robert Long
0f3a7f9fd9 Prevent scroll in call view 2022-02-16 11:17:33 -08:00
Robert Long
1cc634509b Add protocol to copied room url 2022-02-16 10:52:07 -08:00
Robert Long
cb07ce32cb Fix room not found view 2022-02-15 15:00:06 -08:00
Robert Long
6866d662f7 Automatically switch to spotlight layout on screenshare 2022-02-15 14:49:50 -08:00
Robert Long
51a2027d64 Fix screenshare button styling 2022-02-15 12:58:55 -08:00
Robert Long
0f6b8f9bb1 New incremental auth 2022-02-15 12:46:58 -08:00
Robert Long
63229ce2d7 Fix video grid story 2022-02-14 14:49:19 -08:00
Robert Long
1d620910c5 Only show name when focused or more than 2 participants 2022-02-14 14:48:12 -08:00
Robert Long
47357b3fc6 Add room not found view 2022-02-14 13:53:19 -08:00
Robert Long
3ed35f9477 Fix deprecated usage of substr 2022-02-14 12:35:39 -08:00
Robert Long
a369444b62 Convert room id to lowercase 2022-02-14 12:35:07 -08:00
Robert Long
742d658021 Center align call tile contents 2022-02-14 12:19:54 -08:00
Robert Long
681c24a0ca Fix focusing in freedom layout 2022-02-14 11:14:09 -08:00
Robert Long
fc057bf988 Prevent opening multiple tabs of the same account 2022-02-10 17:10:36 -08:00
Robert Long
51561e2f4e Set rageshake submit url for prod 2022-02-07 16:16:51 -08:00
Robert Long
4168540017 Added group_call_rageshake_request_id for rageshake grouping 2022-02-07 15:24:43 -08:00
Robert Long
942630c2fc Merge pull request #207 from vector-im/revert-206-michaelk/rename_groupcall.txt
Revert "Rename groupcall.txt -> groupcall.json."
2022-02-07 15:23:14 -08:00
Robert Long
9251cd9964 Revert "Rename groupcall.txt -> groupcall.json." 2022-02-07 15:23:00 -08:00
Robert Long
145826d1f3 Merge pull request #206 from michaelkaye/michaelk/rename_groupcall.txt
Rename groupcall.txt -> groupcall.json.
2022-02-07 15:09:13 -08:00
Michael Kaye
5e42881c5c Rename groupcall.txt -> groupcall.json.
This will stop groupcall.txt being handled as a 'log file' and instead
indicate it's an artifact to be stored alongside the rageshake.

The file will still be stored on the rageshake server but the extension
will indicate it's not a log file.
2022-02-07 15:28:46 +00:00
Robert Long
0824bfb4ed Update copy and feedback icon 2022-02-04 17:00:58 -08:00
Robert Long
6ec9e4b666 Add rageshake request modal 2022-02-04 16:55:57 -08:00
Robert Long
ec447429c5 Autofocus join call button when ready 2022-02-04 12:38:40 -08:00
Robert Long
9c3e4907c8 Move join room button 2022-02-04 12:31:59 -08:00
Robert Long
cde352bcae Merge branch 'main' of github.com:vector-im/matrix-video-chat 2022-02-03 17:28:13 -08:00
Robert Long
2d2400edae Fix active speaker focusing 2022-02-03 17:28:10 -08:00
Matthew Hodgson
7c80682b08 quick hack to improve ILAG copy 2022-02-04 01:24:05 +00:00
Robert Long
1d8cd8c3c8 Add useLocationNavigation to fix navigation during browser media prompts 2022-02-03 16:56:13 -08:00
Robert Long
a33d1364b6 Fix null recaptcha target 2022-02-03 14:18:34 -08:00
Robert Long
a189f3ad98 Fix page titles 2022-02-02 21:48:44 -08:00
Robert Long
f3cee359c0 Update VideoGrid story 2022-02-02 18:32:23 -08:00
Robert Long
be45c0319e Fix InCallView 2022-02-02 16:05:15 -08:00
Robert Long
dec47d21c0 Merge branch 'main' into robertlong/spotlight-layout 2022-02-02 15:15:39 -08:00
Robert Long
c4f335ebb6 Prevent navigation from login / logout links in user menu in room 2022-02-02 15:09:16 -08:00
Robert Long
35c11660a3 Add configurable / dynamic page title 2022-02-02 15:02:40 -08:00
Robert Long
089c891a55 Fix TOS copy 2022-02-02 14:31:11 -08:00
Robert Long
3f60cd0386 Add feedback description input 2022-02-02 13:30:36 -08:00
Robert Long
ef8021e1a8 callId -> conf_id 2022-02-02 13:23:09 -08:00
Robert Long
8ab68ed8c8 Add rageshake submit state 2022-02-01 15:39:45 -08:00
Robert Long
76b2e8b29e Add debug log inspector / rageshake 2022-02-01 15:11:06 -08:00
Matthew Hodgson
91366585ff CONTRIBUTING.md 2022-01-26 17:55:52 +00:00
Robert Long
21e4516bc3 Fix tab padding 2022-01-21 17:02:24 -08:00
Robert Long
f8b4331ec7 Use avatar component for rooms 2022-01-21 16:41:00 -08:00
Robert Long
f7cb015390 Add copyable User Id field 2022-01-21 16:36:21 -08:00
Robert Long
f2c3c82d3a Fix uploading avatars 2022-01-21 16:33:43 -08:00
Robert Long
48f3f430da Update tabs for mobile 2022-01-21 15:43:03 -08:00
Robert Long
d6fb0e836d Fix storybook 2022-01-21 15:42:21 -08:00
Robert Long
fddc8a1209 Don't center content in call ended screen 2022-01-21 13:30:23 -08:00
Robert Long
6032f6ba44 Fix avatar sizing 2022-01-21 13:21:23 -08:00
Robert Long
d1368f4622 Add avatar when muted in lobby 2022-01-21 11:55:10 -08:00
Robert Long
6da369f3fe Disable facepile 2022-01-21 11:10:12 -08:00
Robert Long
289f7285ae Your Name -> Username 2022-01-21 11:06:43 -08:00
Robert Long
da69dd8320 Fix selected select input text color 2022-01-21 11:04:01 -08:00
Robert Long
d7d38c1ba9 Fix button tooltips 2022-01-20 13:03:54 -08:00
Robert Long
abae58489c Fix focus styles 2022-01-18 16:03:49 -08:00
Robert Long
d4fec73d64 Revert filtering onClick in Button 2022-01-18 16:03:40 -08:00
Robert Long
a8cb9f290a Remove console log 2022-01-18 15:25:16 -08:00
Robert Long
251f6a92a9 Filter onClick from button props 2022-01-18 15:25:02 -08:00
Robert Long
9163d5a25d Clean up vite warning messages about server.fs.strict 2022-01-18 15:16:34 -08:00
Robert Long
3ee4058dce Remove unnecessary typescript config 2022-01-18 15:16:10 -08:00
Robert Long
a8d6f21af9 Update dependencies 2022-01-18 15:15:59 -08:00
Robert Long
78eff5fa9e Ensure webcam is turned off when leaving 2022-01-18 14:56:15 -08:00
Robert Long
6311a869f9 Fix button prop warnings 2022-01-18 14:25:02 -08:00
Robert Long
98355edf92 Fix username regex 2022-01-18 13:52:16 -08:00
Robert Long
c71f37a8f8 Fix registration display name 2022-01-18 13:41:14 -08:00
Robert Long
97ab7ee2c0 Fix user menu styles 2022-01-18 13:34:42 -08:00
Robert Long
d6567658c0 Remove recaptcha debug logging 2022-01-18 11:53:45 -08:00
Robert Long
8df13ee7c8 Sitekey change should update execute method 2022-01-18 11:49:59 -08:00
Robert Long
36d59c98c0 Add recaptcha debugging 2022-01-18 11:47:10 -08:00
Robert Long
f6b3d6830e Fix logo height in chrome 2022-01-14 14:17:51 -08:00
Robert Long
a7ba511278 Fix remoteUserIds 2022-01-14 13:52:33 -08:00
Robert Long
5819654bc7 Update group call inspector 2022-01-14 13:40:02 -08:00
Robert Long
3d571a00c6 Add sequence diagrams to inspector 2022-01-13 14:11:06 -08:00
Robert Long
3c30ca5f95 Merge branch 'main' of github.com:vector-im/matrix-video-chat 2022-01-12 13:47:49 -08:00
Robert Long
cb2cce243a Update GroupCallInspector 2022-01-12 13:47:46 -08:00
Robert Long
19fe760833 Add VideoGrid storybook 2022-01-07 16:20:55 -08:00
Robert Long
5f4ac97787 Fix InCallView 2022-01-07 11:42:36 -08:00
Robert Long
e9fc90c55b Merge branch 'main' into robertlong/spotlight-layout 2022-01-07 11:42:23 -08:00
Robert Long
096460ecfe Make recaptcha optional 2022-01-07 11:31:53 -08:00
Robert Long
86ccc2431e Add TODO for duplicate calls to initLocalCallFeed 2022-01-06 16:52:15 -08:00
Robert Long
bcd58aae90 Set use authorizization header 2022-01-06 16:51:23 -08:00
Robert Long
c609d42554 Clean up recaptcha copy 2022-01-06 15:46:39 -08:00
Robert Long
b3b73e9874 Await changePassword on register page 2022-01-06 15:27:05 -08:00
Robert Long
f6c5484d1b Add sent voip events to debugger 2022-01-06 15:24:35 -08:00
Robert Long
4efcc53628 fix style undefined 2022-01-06 14:16:31 -08:00
Robert Long
4be14159c5 Add user menu to room auth view 2022-01-06 14:10:33 -08:00
Robert Long
22f8fef87d Fix login link 2022-01-06 14:02:23 -08:00
Robert Long
d1e645fbc0 Fix restoring sessions 2022-01-06 13:33:13 -08:00
Robert Long
aa6bbbaaa0 Fix link component 2022-01-06 13:14:15 -08:00
Robert Long
627c64dca3 Update spotlight icon 2022-01-06 11:13:52 -08:00
Robert Long
7d08ea2143 Fix useLoadGroupCall 2022-01-06 11:13:42 -08:00
Robert Long
3cb59aebf5 Move inputs and profile components 2022-01-05 17:27:01 -08:00
Robert Long
2b1a523973 Move popover 2022-01-05 17:21:30 -08:00
Robert Long
546ab06d60 Refactor matrix hooks 2022-01-05 17:19:03 -08:00
Robert Long
0e407c08df Remove public rooms 2022-01-05 17:02:56 -08:00
Robert Long
ca18873a1b Move join existing call modal 2022-01-05 17:00:02 -08:00
Robert Long
ebf61511f1 Clean up remaining room components 2022-01-05 16:58:55 -08:00
Robert Long
73eacdb23f Clean up overflow menu 2022-01-05 16:55:41 -08:00
Robert Long
3fac266013 Clean up settings modal 2022-01-05 16:54:13 -08:00
Robert Long
6621e20da3 Clean up useLoadGroupCall 2022-01-05 16:51:24 -08:00
Robert Long
0adc4b3d66 Clean up old auth logic 2022-01-05 16:47:53 -08:00
Robert Long
8a452d80e2 Refactor auth pages 2022-01-05 16:34:01 -08:00
Robert Long
71986f6001 Fix call list alignment 2022-01-05 16:12:58 -08:00
Robert Long
eb4207e41d Add room auth view 2022-01-05 16:09:51 -08:00
Robert Long
0fe38000f5 Refactor room loading components 2022-01-05 15:35:12 -08:00
Robert Long
550c45b69e Clean up room-related components 2022-01-05 15:06:51 -08:00
Robert Long
8be578763d Split out lobby view 2022-01-05 13:09:12 -08:00
Robert Long
3ec01293e6 Fix dismissing existing room modal 2022-01-05 11:52:50 -08:00
Robert Long
3d3663c540 Fix header font weight 2022-01-05 11:52:23 -08:00
Robert Long
095b0287f0 Fix call list styling 2022-01-05 11:52:13 -08:00
Robert Long
d59f0e748d Fix dismissing recaptcha 2022-01-04 17:57:23 -08:00
Robert Long
24ccfa0dd8 Fix creating room as registered user 2022-01-04 17:13:45 -08:00
Robert Long
0e273a6dc5 Fix go button style 2022-01-04 17:13:26 -08:00
Robert Long
f4936f221f Clean up registration page 2022-01-04 17:09:27 -08:00
Robert Long
ef8c28f274 Redesign homepage WIP 2022-01-04 16:00:13 -08:00
Robert Long
eb620e9220 Refactor header 2021-12-23 14:40:23 -08:00
Robert Long
87e5cafb77 Add storybook 2021-12-23 12:45:00 -08:00
Robert Long
ffc5208865 Sort tiles by presenter 2021-12-21 15:56:48 -08:00
Robert Long
fe724783ff Setup for spotlight layout refactor 2021-12-21 14:24:47 -08:00
Robert Long
658424efa0 Disable focusing tiles in spotlight layout 2021-12-21 11:57:26 -08:00
Robert Long
ab73a351f8 Add recaptcha 2021-12-20 15:56:39 -08:00
Robert Long
d45d37b18a Merge branch 'main' of github.com:vector-im/matrix-video-chat 2021-12-20 13:15:37 -08:00
Robert Long
66e5ec976b Add privacy policy flow 2021-12-20 13:15:35 -08:00
David Baker
b65874a6fc export variables 2021-12-20 21:00:06 +00:00
David Baker
28b7e76ce0 Merge pull request #166 from vector-im/dbkr/fix_nginx
Fix docker build
2021-12-20 20:24:53 +00:00
David Baker
31845dea10 Fix docker build
and add catchall route
2021-12-20 20:05:05 +00:00
David Baker
b1a75a5033 Merge pull request #165 from vector-im/dbkr/nginx_unpriv
Use unprivilged nginx image
2021-12-20 18:47:07 +00:00
David Baker
032d623acb Use unprivilged nginx image 2021-12-20 18:43:45 +00:00
193 changed files with 21751 additions and 5295 deletions

25
.env
View File

@@ -7,19 +7,22 @@
# Used for determining the homeserver to use for short urls etc.
# VITE_DEFAULT_HOMESERVER=http://localhost:8008
# The room id for the space to use for listing public group call rooms
# VITE_PUBLIC_SPACE_ROOM_ID=!hjdfshkdskjdsk:myhomeserver.com
# Used for submitting debug logs to an external rageshake server
# VITE_RAGESHAKE_SUBMIT_URL=http://localhost:9110/api/submit
# The Sentry DSN to use for error reporting. Leave undefined to disable.
# VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
# VITE_CUSTOM_THEME=true
# VITE_PRIMARY_COLOR=#0dbd8b
# VITE_BG_COLOR_1=#ffffff
# VITE_BG_COLOR_2=#f0f1f4
# VITE_BG_COLOR_3=#dbdfe4
# VITE_BG_COLOR_4=#d1d3d7
# VITE_INPUT_BORDER_COLOR=#e7e7e7
# VITE_INPUT_BORDER_COLOR_FOCUSED=#238cf5
# VITE_TEXT_COLOR_1=#17191c
# VITE_TEXT_COLOR_2=#61708b
# VITE_THEME_ACCENT=#0dbd8b
# VITE_THEME_ACCENT_20=#0dbd8b33
# VITE_THEME_ALERT=#ff5b55
# VITE_THEME_ALERT_20=#ff5b5533
# VITE_THEME_LINKS=#0086e6
# VITE_THEME_PRIMARY_CONTENT=#ffffff
# VITE_THEME_SECONDARY_CONTENT=#a9b2bc
# VITE_THEME_TERTIARY_CONTENT=#8e99a4
# 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 }}

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@ node_modules
.DS_Store
dist
dist-ssr
*.local
*.local
.idea/

2
.prettierignore Normal file
View File

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

1
.prettierrc.json Normal file
View File

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

25
.storybook/main.js Normal file
View File

@@ -0,0 +1,25 @@
const svgrPlugin = require("vite-plugin-svgr");
const path = require("path");
module.exports = {
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
framework: "@storybook/react",
core: {
builder: "storybook-builder-vite",
},
async viteFinal(config) {
config.plugins = config.plugins.filter(
(item) =>
!(
Array.isArray(item) &&
item.length > 0 &&
item[0].name === "vite-plugin-mdx"
)
);
config.plugins.push(svgrPlugin());
config.resolve = config.resolve || {};
config.resolve.dedupe = config.resolve.dedupe || [];
config.resolve.dedupe.push("react", "react-dom", "matrix-js-sdk");
return config;
},
};

25
.storybook/preview.jsx Normal file
View File

@@ -0,0 +1,25 @@
import React from "react";
import { addDecorator } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { usePageFocusStyle } from "../src/usePageFocusStyle";
import { OverlayProvider } from "@react-aria/overlays";
import "../src/index.css";
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
addDecorator((story) => {
usePageFocusStyle();
return (
<MemoryRouter initialEntries={["/"]}>
<OverlayProvider>{story()}</OverlayProvider>
</MemoryRouter>
);
});

View File

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

4
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,4 @@
Contributing code to Element
============================
Element follows the same pattern as the [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md).

View File

@@ -2,13 +2,17 @@ FROM node:16-buster as builder
WORKDIR /src
COPY . /src/matrix-video-chat
RUN matrix-video-chat/scripts/dockerbuild.sh
COPY . /src/element-call
RUN element-call/scripts/dockerbuild.sh
# App
FROM nginx:alpine
FROM nginxinc/nginx-unprivileged:alpine
COPY --from=builder /src/matrix-video-chat/dist /app
COPY --from=builder /src/element-call/dist /app
COPY config/default.conf /etc/nginx/conf.d/
RUN rm -rf /usr/share/nginx/html \
&& ln -s /app /usr/share/nginx/html
USER root
RUN rm -rf /usr/share/nginx/html
USER 101

View File

@@ -1,10 +1,12 @@
# Matrix Video Chat
# Element Call
Testbed for full mesh video chat.
Showcase for full mesh video chat powered by Matrix, implementing [MSC3401](https://github.com/matrix-org/matrix-spec-proposals/blob/matthew/group-voip/proposals/3401-group-voip.md).
Discussion in [#webrtc:matrix.org: ![#webrtc:matrix.org](https://img.shields.io/matrix/webrtc:matrix.org)](https://matrix.to/#/#webrtc:matrix.org)
## Getting Started
`matrix-video-chat` is built against the `robertlong/group-call` branch of both [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/pull/1902) and [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/pull/6848). Because of how these packages are configured and Vite's requirements, you will need to clone them locally and use `yarn link` to stich things together.
`element-call` is built against the `robertlong/group-call` branch of [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/pull/1902). Because of how this package is configured and Vite's requirements, you will need to clone it locally and use `yarn link` to stich things together.
First clone, install, and link `matrix-js-sdk`
@@ -16,30 +18,36 @@ yarn
yarn link
```
Then clone, install, link `matrix-js-sdk` into `matrix-react-sdk`, and link `matrix-react-sdk`
```
git clone https://github.com/matrix-org/matrix-react-sdk.git
cd matrix-react-sdk
git checkout robertlong/group-call
yarn
yarn link matrix-js-sdk
yarn link
```
Next you'll also need [Synapse](https://matrix-org.github.io/synapse/latest/setup/installation.html) installed locally and running on port 8008.
Finally we can set up this project.
```
git clone https://github.com/vector-im/matrix-video-chat.git
cd matrix-video-chat
git clone https://github.com/vector-im/element-call.git
cd element-call
yarn
yarn link matrix-js-sdk
yarn link matrix-react-sdk
yarn dev
```
## Config
Configuration options are documented in the `.env` file.
## License
All files in this project are:
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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.

10
config/default.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 8080;
server_name localhost;
location / {
root /app;
try_files $uri /$uri /index.html;
}
}

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

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>Matrix Video Chat</title>
<script>
window.global = window;
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -3,9 +3,18 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
"serve": "vite preview",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook",
"prettier:check": "prettier -c src",
"prettier:format": "prettier -w src",
"lint": "yarn lint:types && yarn lint:js",
"lint:js": "eslint --max-warnings 0 src",
"lint:types": "tsc"
},
"dependencies": {
"@juggle/resize-observer": "^3.3.1",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@react-aria/button": "^3.3.4",
"@react-aria/dialog": "^3.1.4",
"@react-aria/focus": "^3.5.0",
@@ -15,6 +24,7 @@
"@react-aria/tabs": "^3.1.0",
"@react-aria/tooltip": "^3.1.3",
"@react-aria/utils": "^3.10.0",
"@react-spring/web": "^9.4.4",
"@react-stately/collections": "^3.3.4",
"@react-stately/overlays": "^3.1.3",
"@react-stately/select": "^3.1.3",
@@ -22,11 +32,15 @@
"@react-stately/tree": "^3.2.0",
"@sentry/react": "^6.13.3",
"@sentry/tracing": "^6.13.3",
"@types/grecaptcha": "^3.0.4",
"@use-gesture/react": "^10.2.11",
"classnames": "^2.3.1",
"color-hash": "^2.0.1",
"events": "^3.3.0",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#robertlong/group-call",
"matrix-react-sdk": "github:matrix-org/matrix-react-sdk#robertlong/group-call",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#aa0d3bd1f5a006d151f826e6b8c5f286abb6e960",
"mermaid": "^8.13.8",
"normalize.css": "^8.0.1",
"pako": "^2.0.4",
"postcss-preset-env": "^6.7.0",
"re-resizable": "^6.9.0",
"react": "^17.0.0",
@@ -34,11 +48,32 @@
"react-json-view": "^1.21.3",
"react-router": "6",
"react-router-dom": "^5.2.0",
"react-use-clipboard": "^1.0.7"
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"unique-names-generator": "^4.6.0"
},
"devDependencies": {
"@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",
"@types/request": "^2.48.8",
"@typescript-eslint/eslint-plugin": "^5.22.0",
"@typescript-eslint/parser": "^5.22.0",
"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",
"storybook-builder-vite": "^0.1.12",
"typescript": "^4.6.4",
"vite": "^2.4.2",
"vite-plugin-html-template": "^1.1.0",
"vite-plugin-svgr": "^0.4.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

20
public/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>
<%- title %>
</title>
<script>
window.global = window;
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -2,8 +2,10 @@
set -ex
VITE_DEFAULT_HOMESERVER=https://call.ems.host
VITE_SENTRY_DSN=https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
export VITE_DEFAULT_HOMESERVER=https://call.ems.host
export VITE_SENTRY_DSN=https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
export VITE_RAGESHAKE_SUBMIT_URL=https://element.io/bugreports/submit
export VITE_PRODUCT_NAME="Element Call"
git clone https://github.com/matrix-org/matrix-js-sdk.git
cd matrix-js-sdk
@@ -11,19 +13,11 @@ git checkout robertlong/group-call
yarn install
yarn run build
yarn link
cd ..
git clone https://github.com/matrix-org/matrix-react-sdk.git
cd matrix-react-sdk
git checkout robertlong/group-call
cd ../element-call
export VITE_APP_VERSION=$(git describe --tags --abbrev=0)
yarn link matrix-js-sdk
yarn install
yarn run build
yarn link
cd ..
cd matrix-video-chat
yarn link matrix-js-sdk
yarn link matrix-react-sdk
yarn install
yarn run build

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

@@ -14,47 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useState } from "react";
import {
BrowserRouter as Router,
Switch,
Route,
useLocation,
useHistory,
} from "react-router-dom";
import React from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";
import { Home } from "./Home";
import { LoginPage } from "./LoginPage";
import { RegisterPage } from "./RegisterPage";
import { Room } from "./Room";
import {
ClientProvider,
defaultHomeserverHost,
} from "./ConferenceCallManagerHooks";
import { useFocusVisible } from "@react-aria/interactions";
import styles from "./App.module.css";
import { LoadingView } from "./FullScreenView";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { RoomRedirect } from "./room/RoomRedirect";
import { ClientProvider } from "./ClientContext";
import { usePageFocusStyle } from "./usePageFocusStyle";
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
const SentryRoute = Sentry.withSentryRouting(Route);
export default function App({ history }) {
const { isFocusVisible } = useFocusVisible();
useEffect(() => {
const classList = document.body.classList;
const hasClass = classList.contains(styles.hideFocus);
if (isFocusVisible && hasClass) {
classList.remove(styles.hideFocus);
} else if (!isFocusVisible && !hasClass) {
classList.add(styles.hideFocus);
}
return () => {
classList.remove(styles.hideFocus);
};
}, [isFocusVisible]);
usePageFocusStyle();
return (
<Router history={history}>
@@ -62,7 +38,7 @@ export default function App({ history }) {
<OverlayProvider>
<Switch>
<SentryRoute exact path="/">
<Home />
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
@@ -71,7 +47,10 @@ export default function App({ history }) {
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<Room />
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
@@ -82,24 +61,3 @@ export default function App({ history }) {
</Router>
);
}
function RoomRedirect() {
const { pathname } = useLocation();
const history = useHistory();
useEffect(() => {
let roomId = pathname;
if (pathname.startsWith("/")) {
roomId = roomId.substr(1, roomId.length);
}
if (!roomId.startsWith("#") && !roomId.startsWith("!")) {
roomId = `#${roomId}:${defaultHomeserverHost}`;
}
history.replace(`/room/${roomId}`);
}, [pathname, history]);
return <LoadingView />;
}

View File

@@ -1,3 +0,0 @@
.hideFocus * {
outline: none;
}

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 {
position: relative;
color: #ffffff;
color: var(--primary-content);
display: flex;
align-items: center;
justify-content: center;
@@ -17,7 +17,7 @@
}
.avatar svg * {
fill: #ffffff;
fill: var(--primary-content);
}
.avatar span {
@@ -49,11 +49,12 @@
width: 42px;
height: 42px;
border-radius: 42px;
font-size: 36px;
font-size: 24px;
}
.xl {
width: 90px;
height: 90px;
border-radius: 90px;
font-size: 48px;
}

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,57 +0,0 @@
import React, { useMemo } from "react";
import { Link } from "react-router-dom";
import { CopyButton } from "./button";
import { Facepile } from "./Facepile";
import { Avatar } from "./Avatar";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import styles from "./CallList.module.css";
import { getRoomUrl } from "./ConferenceCallManagerHooks";
export function CallList({ title, rooms, client }) {
return (
<>
<h3>{title}</h3>
<div className={styles.callList}>
{rooms.map(({ roomId, roomName, avatarUrl, participants }) => (
<CallTile
key={roomId}
client={client}
name={roomName}
avatarUrl={avatarUrl}
roomId={roomId}
participants={participants}
/>
))}
</div>
</>
);
}
function CallTile({ name, avatarUrl, roomId, participants, client }) {
return (
<div className={styles.callTile}>
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
<Avatar
size="md"
bgKey={name}
src={avatarUrl}
fallback={<VideoIcon width={16} height={16} />}
className={styles.avatar}
/>
<div className={styles.callInfo}>
<h5>{name}</h5>
<p>{getRoomUrl(roomId)}</p>
{participants && (
<Facepile client={client} participants={participants} />
)}
</div>
<div className={styles.copyButtonSpacer} />
</Link>
<CopyButton
className={styles.copyButton}
variant="icon"
value={getRoomUrl(roomId)}
/>
</div>
);
}

279
src/ClientContext.tsx Normal file
View File

@@ -0,0 +1,279 @@
/*
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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,
useCallback,
useEffect,
useState,
createContext,
useMemo,
useContext,
} from "react";
import { useHistory } from "react-router-dom";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { ErrorView } from "./FullScreenView";
import { initClient, defaultHomeserver } from "./matrix-utils";
declare global {
interface Window {
matrixclient: MatrixClient;
}
}
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 [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
setState,
] = useState<ClientProviderState>({
loading: true,
isAuthenticated: false,
isPasswordlessUser: false,
client: undefined,
userName: null,
error: undefined,
});
useEffect(() => {
const restore = async (): Promise<
Pick<ClientProviderState, "client" | "isPasswordlessUser">
> => {
try {
const session = loadSession();
if (session) {
/* eslint-disable camelcase */
const { user_id, device_id, access_token, passwordlessUser } =
session;
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
/* eslint-enable camelcase */
return { client, isPasswordlessUser: passwordlessUser };
}
return { client: undefined, isPasswordlessUser: false };
} catch (err) {
console.error(err);
clearSession();
throw err;
}
};
restore()
.then(({ client, isPasswordlessUser }) => {
setState({
client,
loading: false,
isAuthenticated: Boolean(client),
isPasswordlessUser,
userName: client?.getUserIdLocalpart(),
});
})
.catch(() => {
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
});
});
}, []);
const changePassword = useCallback(
async (password: string) => {
const { tempPassword, ...session } = loadSession();
await client.setPassword(
{
type: "m.login.password",
identifier: {
type: "m.id.user",
user: session.user_id,
},
user: session.user_id,
password: tempPassword,
},
password
);
saveSession({ ...session, passwordlessUser: false });
setState({
client,
loading: false,
isAuthenticated: true,
isPasswordlessUser: false,
userName: client.getUserIdLocalpart(),
});
},
[client]
);
const setClient = useCallback(
(newClient: MatrixClient, session: Session) => {
if (client && client !== newClient) {
client.stopClient();
}
if (newClient) {
saveSession(session);
setState({
client: newClient,
loading: false,
isAuthenticated: true,
isPasswordlessUser: session.passwordlessUser,
userName: newClient.getUserIdLocalpart(),
});
} else {
clearSession();
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
});
}
},
[client]
);
const logout = useCallback(() => {
clearSession();
history.push("/");
}, [history]);
useEffect(() => {
if (client) {
const loadTime = Date.now();
const onToDeviceEvent = (event: MatrixEvent) => {
if (event.getType() !== "org.matrix.call_duplicate_session") return;
const content = event.getContent();
if (content.session_id === client.getSessionId()) return;
if (content.timestamp > loadTime) {
client?.stopClient();
setState((prev) => ({
...prev,
error: new Error(
"This application has been opened in another tab."
),
}));
}
};
client.on(ClientEvent.ToDeviceEvent, onToDeviceEvent);
client.sendToDevice("org.matrix.call_duplicate_session", {
[client.getUserId()]: {
"*": { session_id: client.getSessionId(), timestamp: loadTime },
},
});
return () => {
client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
};
}
}, [client]);
const context = useMemo<ClientState>(
() => ({
loading,
isAuthenticated,
isPasswordlessUser,
client,
changePassword,
logout,
userName,
setClient,
}),
[
loading,
isAuthenticated,
isPasswordlessUser,
client,
changePassword,
logout,
userName,
setClient,
]
);
useEffect(() => {
window.matrixclient = client;
}, [client]);
if (error) {
return <ErrorView error={error} />;
}
return (
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
);
};
export const useClient = () => useContext(ClientContext);

View File

@@ -1,437 +0,0 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import EventEmitter from "events";
export class ConferenceCallDebugger extends EventEmitter {
constructor(client, groupCall) {
super();
this.client = client;
this.groupCall = groupCall;
this.debugState = {
users: new Map(),
calls: new Map(),
};
this.bufferedEvents = [];
client.on("event", this._onEvent);
groupCall.on("call", this._onCall);
groupCall.on("debugstate", this._onDebugStateChanged);
groupCall.on("entered", this._onEntered);
groupCall.on("left", this._onLeft);
}
_onEntered = () => {
const eventCount = this.bufferedEvents.length;
for (let i = 0; i < eventCount; i++) {
const event = this.bufferedEvents.pop();
this._onEvent(event);
}
};
_onLeft = () => {
this.bufferedEvents = [];
this.debugState = {
users: new Map(),
calls: new Map(),
};
this.emit("debug");
};
_onEvent = (event) => {
if (!this.groupCall.entered) {
this.bufferedEvents.push(event);
return;
}
const roomId = event.getRoomId();
const type = event.getType();
if (
roomId === this.groupCall.room.roomId &&
(type.startsWith("m.call.") ||
type === "me.robertlong.call.info" ||
type === "m.room.member")
) {
const sender = event.getSender();
const { call_id } = event.getContent();
if (call_id) {
if (this.debugState.calls.has(call_id)) {
const callState = this.debugState.calls.get(call_id);
callState.events.push(event);
} else {
this.debugState.calls.set(call_id, {
state: "unknown",
events: [event],
});
}
}
if (this.debugState.users.has(sender)) {
const userState = this.debugState.users.get(sender);
userState.events.push(event);
} else {
this.debugState.users.set(sender, {
state: "unknown",
events: [event],
});
}
this.emit("debug");
}
};
_onDebugStateChanged = (userId, callId, state) => {
if (userId) {
const userState = this.debugState.users.get(userId);
if (userState) {
userState.state = state;
} else {
this.debugState.users.set(userId, {
state,
events: [],
});
}
}
if (callId) {
const callState = this.debugState.calls.get(callId);
if (callState) {
callState.state = state;
} else {
this.debugState.calls.set(callId, {
state,
events: [],
});
}
}
this.emit("debug");
};
_onCall = (call) => {
const peerConnection = call.peerConn;
if (!peerConnection) {
return;
}
const sendWebRTCInfoEvent = async (eventType) => {
const event = {
call_id: call.callId,
eventType,
iceConnectionState: peerConnection.iceConnectionState,
iceGatheringState: peerConnection.iceGatheringState,
signalingState: peerConnection.signalingState,
selectedCandidatePair: null,
localCandidate: null,
remoteCandidate: null,
};
// getStats doesn't support selectors in Firefox so get all stats by passing null.
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats#browser_compatibility
const stats = await peerConnection.getStats(null);
const statsArr = Array.from(stats.values());
// Matrix doesn't support floats so we convert time in seconds to ms
function secToMs(time) {
if (time === undefined) {
return undefined;
}
return Math.round(time * 1000);
}
function processTransportStats(transportStats) {
if (!transportStats) {
return undefined;
}
return {
packetsSent: transportStats.packetsSent,
packetsReceived: transportStats.packetsReceived,
bytesSent: transportStats.bytesSent,
bytesReceived: transportStats.bytesReceived,
iceRole: transportStats.iceRole,
iceState: transportStats.iceState,
dtlsState: transportStats.dtlsState,
dtlsCipher: transportStats.dtlsCipher,
tlsVersion: transportStats.tlsVersion,
};
}
function processCandidateStats(candidateStats) {
if (!candidateStats) {
return undefined;
}
// TODO: Figure out how to normalize ip and address across browsers
// networkType property excluded for privacy reasons:
// https://www.w3.org/TR/webrtc-stats/#sotd
return {
priority:
candidateStats.priority && candidateStats.priority.toString(),
candidateType: candidateStats.candidateType,
protocol: candidateStats.protocol,
address: !!candidateStats.address
? candidateStats.address
: candidateStats.ip,
port: candidateStats.port,
url: candidateStats.url,
relayProtocol: candidateStats.relayProtocol,
};
}
function processCandidatePair(candidatePairStats) {
if (!candidatePairStats) {
return undefined;
}
const localCandidateStats = statsArr.find(
(stat) => stat.id === candidatePairStats.localCandidateId
);
event.localCandidate = processCandidateStats(localCandidateStats);
const remoteCandidateStats = statsArr.find(
(stat) => stat.id === candidatePairStats.remoteCandidateId
);
event.remoteCandidate = processCandidateStats(remoteCandidateStats);
const transportStats = statsArr.find(
(stat) => stat.id === candidatePairStats.transportId
);
event.transport = processTransportStats(transportStats);
return {
state: candidatePairStats.state,
bytesSent: candidatePairStats.bytesSent,
bytesReceived: candidatePairStats.bytesReceived,
requestsSent: candidatePairStats.requestsSent,
requestsReceived: candidatePairStats.requestsReceived,
responsesSent: candidatePairStats.responsesSent,
responsesReceived: candidatePairStats.responsesReceived,
currentRoundTripTime: secToMs(
candidatePairStats.currentRoundTripTime
),
totalRoundTripTime: secToMs(candidatePairStats.totalRoundTripTime),
};
}
// Firefox uses the deprecated "selected" property for the nominated ice candidate.
const selectedCandidatePair = statsArr.find(
(stat) =>
stat.type === "candidate-pair" && (stat.selected || stat.nominated)
);
event.selectedCandidatePair = processCandidatePair(selectedCandidatePair);
function processCodecStats(codecStats) {
if (!codecStats) {
return undefined;
}
// Payload type enums and MIME types listed here:
// https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml
return {
mimeType: codecStats.mimeType,
clockRate: codecStats.clockRate,
payloadType: codecStats.payloadType,
channels: codecStats.channels,
sdpFmtpLine: codecStats.sdpFmtpLine,
};
}
function processRTPStreamStats(rtpStreamStats) {
const codecStats = statsArr.find(
(stat) => stat.id === rtpStreamStats.codecId
);
const codec = processCodecStats(codecStats);
return {
kind: rtpStreamStats.kind,
codec,
};
}
function processInboundRTPStats(inboundRTPStats) {
const rtpStreamStats = processRTPStreamStats(inboundRTPStats);
return {
...rtpStreamStats,
decoderImplementation: inboundRTPStats.decoderImplementation,
bytesReceived: inboundRTPStats.bytesReceived,
packetsReceived: inboundRTPStats.packetsReceived,
packetsLost: inboundRTPStats.packetsLost,
jitter: secToMs(inboundRTPStats.jitter),
frameWidth: inboundRTPStats.frameWidth,
frameHeight: inboundRTPStats.frameHeight,
frameBitDepth: inboundRTPStats.frameBitDepth,
framesPerSecond:
inboundRTPStats.framesPerSecond &&
inboundRTPStats.framesPerSecond.toString(),
framesReceived: inboundRTPStats.framesReceived,
framesDecoded: inboundRTPStats.framesDecoded,
framesDropped: inboundRTPStats.framesDropped,
totalSamplesDecoded: inboundRTPStats.totalSamplesDecoded,
totalDecodeTime: secToMs(inboundRTPStats.totalDecodeTime),
totalProcessingDelay: secToMs(inboundRTPStats.totalProcessingDelay),
};
}
function processOutboundRTPStats(outboundRTPStats) {
const rtpStreamStats = processRTPStreamStats(outboundRTPStats);
return {
...rtpStreamStats,
encoderImplementation: outboundRTPStats.encoderImplementation,
bytesSent: outboundRTPStats.bytesSent,
packetsSent: outboundRTPStats.packetsSent,
frameWidth: outboundRTPStats.frameWidth,
frameHeight: outboundRTPStats.frameHeight,
frameBitDepth: outboundRTPStats.frameBitDepth,
framesPerSecond:
outboundRTPStats.framesPerSecond &&
outboundRTPStats.framesPerSecond.toString(),
framesSent: outboundRTPStats.framesSent,
framesEncoded: outboundRTPStats.framesEncoded,
qualityLimitationReason: outboundRTPStats.qualityLimitationReason,
qualityLimitationResolutionChanges:
outboundRTPStats.qualityLimitationResolutionChanges,
totalEncodeTime: secToMs(outboundRTPStats.totalEncodeTime),
totalPacketSendDelay: secToMs(outboundRTPStats.totalPacketSendDelay),
};
}
function processRemoteInboundRTPStats(remoteInboundRTPStats) {
const rtpStreamStats = processRTPStreamStats(remoteInboundRTPStats);
return {
...rtpStreamStats,
packetsReceived: remoteInboundRTPStats.packetsReceived,
packetsLost: remoteInboundRTPStats.packetsLost,
jitter: secToMs(remoteInboundRTPStats.jitter),
framesDropped: remoteInboundRTPStats.framesDropped,
roundTripTime: secToMs(remoteInboundRTPStats.roundTripTime),
totalRoundTripTime: secToMs(remoteInboundRTPStats.totalRoundTripTime),
fractionLost:
remoteInboundRTPStats.fractionLost !== undefined &&
remoteInboundRTPStats.fractionLost.toString(),
reportsReceived: remoteInboundRTPStats.reportsReceived,
roundTripTimeMeasurements:
remoteInboundRTPStats.roundTripTimeMeasurements,
};
}
function processRemoteOutboundRTPStats(remoteOutboundRTPStats) {
const rtpStreamStats = processRTPStreamStats(remoteOutboundRTPStats);
return {
...rtpStreamStats,
encoderImplementation: remoteOutboundRTPStats.encoderImplementation,
bytesSent: remoteOutboundRTPStats.bytesSent,
packetsSent: remoteOutboundRTPStats.packetsSent,
roundTripTime: secToMs(remoteOutboundRTPStats.roundTripTime),
totalRoundTripTime: secToMs(
remoteOutboundRTPStats.totalRoundTripTime
),
reportsSent: remoteOutboundRTPStats.reportsSent,
roundTripTimeMeasurements:
remoteOutboundRTPStats.roundTripTimeMeasurements,
};
}
event.inboundRTP = statsArr
.filter((stat) => stat.type === "inbound-rtp")
.map(processInboundRTPStats);
event.outboundRTP = statsArr
.filter((stat) => stat.type === "outbound-rtp")
.map(processOutboundRTPStats);
event.remoteInboundRTP = statsArr
.filter((stat) => stat.type === "remote-inbound-rtp")
.map(processRemoteInboundRTPStats);
event.remoteOutboundRTP = statsArr
.filter((stat) => stat.type === "remote-outbound-rtp")
.map(processRemoteOutboundRTPStats);
this.client.sendEvent(
this.groupCall.room.roomId,
"me.robertlong.call.info",
event
);
};
let statsTimeout;
const sendStats = () => {
if (
call.state === "ended" ||
peerConnection.connectionState === "closed"
) {
clearTimeout(statsTimeout);
return;
}
sendWebRTCInfoEvent("stats");
statsTimeout = setTimeout(sendStats, 30 * 1000);
};
setTimeout(sendStats, 30 * 1000);
peerConnection.addEventListener("iceconnectionstatechange", () => {
sendWebRTCInfoEvent("iceconnectionstatechange");
});
peerConnection.addEventListener("icegatheringstatechange", () => {
sendWebRTCInfoEvent("icegatheringstatechange");
});
peerConnection.addEventListener("negotiationneeded", () => {
sendWebRTCInfoEvent("negotiationneeded");
});
peerConnection.addEventListener("track", () => {
sendWebRTCInfoEvent("track");
});
// NOTE: Not available on Firefox
// https://bugzilla.mozilla.org/show_bug.cgi?id=1561441
peerConnection.addEventListener(
"icecandidateerror",
({ errorCode, url, errorText }) => {
this.client.sendEvent(
this.groupCall.room.roomId,
"me.robertlong.call.ice_error",
{
call_id: call.callId,
errorCode,
url,
errorText,
}
);
}
);
peerConnection.addEventListener("signalingstatechange", () => {
sendWebRTCInfoEvent("signalingstatechange");
});
};
}

View File

@@ -1,720 +0,0 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
useCallback,
useEffect,
useState,
createContext,
useMemo,
useContext,
} from "react";
import matrix from "matrix-js-sdk/src/browser-index";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/browser-index";
import { useHistory } from "react-router-dom";
export const defaultHomeserver =
import.meta.env.VITE_DEFAULT_HOMESERVER ||
`${window.location.protocol}//${window.location.host}`;
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
const ClientContext = createContext();
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);
});
}
async function initClient(clientOptions, guest) {
const client = matrix.createClient(clientOptions);
if (guest) {
client.setGuest(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 async function fetchGroupCall(
client,
roomIdOrAlias,
viaServers = undefined,
timeout = 5000
) {
const { roomId } = await client.joinRoom(roomIdOrAlias, { viaServers });
return new Promise((resolve, reject) => {
let timeoutId;
function onGroupCallIncoming(groupCall) {
if (groupCall && groupCall.room.roomId === roomId) {
clearTimeout(timeoutId);
client.removeListener("GroupCall.incoming", onGroupCallIncoming);
resolve(groupCall);
}
}
const groupCall = client.getGroupCallForRoom(roomId);
if (groupCall) {
resolve(groupCall);
}
client.on("GroupCall.incoming", onGroupCallIncoming);
if (timeout) {
timeoutId = setTimeout(() => {
client.removeListener("GroupCall.incoming", onGroupCallIncoming);
reject(new Error("Fetching group call timed out."));
}, timeout);
}
});
}
export function ClientProvider({ children }) {
const history = useHistory();
const [
{ loading, isAuthenticated, isPasswordlessUser, isGuest, client, userName },
setState,
] = useState({
loading: true,
isAuthenticated: false,
isPasswordlessUser: false,
isGuest: false,
client: undefined,
userName: null,
});
useEffect(() => {
async function restore() {
try {
const authStore = localStorage.getItem("matrix-auth-store");
if (authStore) {
const {
user_id,
device_id,
access_token,
guest,
passwordlessUser,
tempPassword,
} = JSON.parse(authStore);
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
guest
);
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({
user_id,
device_id,
access_token,
guest,
passwordlessUser,
tempPassword,
})
);
return { client, guest, passwordlessUser };
}
return { client: undefined, guest: false };
} catch (err) {
localStorage.removeItem("matrix-auth-store");
throw err;
}
}
restore()
.then(({ client, guest, passwordlessUser }) => {
setState({
client,
loading: false,
isAuthenticated: !!client,
isPasswordlessUser: !!passwordlessUser,
isGuest: guest,
userName: client?.getUserIdLocalpart(),
});
})
.catch(() => {
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
isGuest: false,
userName: null,
});
});
}, []);
const login = useCallback(async (homeserver, username, password) => {
try {
let loginHomeserverUrl = homeserver.trim();
if (!loginHomeserverUrl.includes("://")) {
loginHomeserverUrl = "https://" + loginHomeserverUrl;
}
try {
const wellKnownUrl = new URL(
"/.well-known/matrix/client",
window.location
);
const response = await fetch(wellKnownUrl);
const config = await response.json();
if (config["m.homeserver"]) {
loginHomeserverUrl = config["m.homeserver"];
}
} catch (error) {}
const registrationClient = matrix.createClient(loginHomeserverUrl);
const { user_id, device_id, access_token } =
await registrationClient.loginWithPassword(username, password);
const client = await initClient({
baseUrl: loginHomeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token })
);
setState({
client,
loading: false,
isAuthenticated: true,
isPasswordlessUser: false,
isGuest: false,
userName: client.getUserIdLocalpart(),
});
} catch (err) {
localStorage.removeItem("matrix-auth-store");
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
isGuest: false,
userName: null,
});
throw err;
}
}, []);
const registerGuest = useCallback(async () => {
try {
const registrationClient = matrix.createClient(defaultHomeserver);
const { user_id, device_id, access_token } =
await registrationClient.registerGuest({});
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
true
);
await client.setProfileInfo("displayname", {
displayname: `Guest ${client.getUserIdLocalpart()}`,
});
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token, guest: true })
);
setState({
client,
loading: false,
isAuthenticated: true,
isGuest: true,
isPasswordlessUser: false,
userName: client.getUserIdLocalpart(),
});
} catch (err) {
localStorage.removeItem("matrix-auth-store");
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
isGuest: false,
userName: null,
});
throw err;
}
}, []);
const register = useCallback(async (username, password, passwordlessUser) => {
try {
const registrationClient = matrix.createClient(defaultHomeserver);
const { user_id, device_id, access_token } =
await registrationClient.register(username, password, null, {
type: "m.login.dummy",
});
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
const session = { user_id, device_id, access_token, passwordlessUser };
if (passwordlessUser) {
session.tempPassword = password;
}
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
setState({
client,
loading: false,
isGuest: false,
isAuthenticated: true,
isPasswordlessUser: passwordlessUser,
userName: client.getUserIdLocalpart(),
});
return client;
} catch (err) {
localStorage.removeItem("matrix-auth-store");
setState({
client: undefined,
loading: false,
isGuest: false,
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
});
throw err;
}
}, []);
const changePassword = useCallback(
async (password) => {
const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse(
localStorage.getItem("matrix-auth-store")
);
await client.setPassword(
{
type: "m.login.password",
identifier: {
type: "m.id.user",
user: existingSession.user_id,
},
user: existingSession.user_id,
password: tempPassword,
},
password
);
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({
...existingSession,
passwordlessUser: false,
})
);
setState({
client,
loading: false,
isGuest: false,
isAuthenticated: true,
isPasswordlessUser: false,
userName: client.getUserIdLocalpart(),
});
},
[client]
);
const logout = useCallback(() => {
localStorage.removeItem("matrix-auth-store");
window.location = "/";
}, [history]);
const context = useMemo(
() => ({
loading,
isAuthenticated,
isPasswordlessUser,
isGuest,
client,
login,
registerGuest,
register,
changePassword,
logout,
userName,
}),
[
loading,
isAuthenticated,
isPasswordlessUser,
isGuest,
client,
login,
registerGuest,
register,
changePassword,
logout,
userName,
]
);
return (
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
);
}
export function useClient() {
return useContext(ClientContext);
}
export function roomAliasFromRoomName(roomName) {
return roomName
.trim()
.replace(/\s/g, "-")
.replace(/[^\w-]/g, "")
.toLowerCase();
}
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.setGuestAccess(room_id, {
allowJoin: true,
allowRead: true,
});
await client.createGroupCall(
room_id,
GroupCallType.Video,
GroupCallIntent.Prompt
);
return room_alias || room_id;
}
export function useLoadGroupCall(client, roomId, viaServers) {
const [state, setState] = useState({
loading: true,
error: undefined,
groupCall: undefined,
});
useEffect(() => {
setState({ loading: true });
fetchGroupCall(client, roomId, viaServers, 30000)
.then((groupCall) => setState({ loading: false, groupCall }))
.catch((error) => setState({ loading: false, error }));
}, [client, roomId]);
return state;
}
const tsCache = {};
function getLastTs(client, r) {
if (tsCache[r.roomId]) {
return tsCache[r.roomId];
}
if (!r || !r.timeline) {
const ts = Number.MAX_SAFE_INTEGER;
tsCache[r.roomId] = ts;
return ts;
}
const myUserId = client.getUserId();
if (r.getMyMembership() !== "join") {
const membershipEvent = r.currentState.getStateEvents(
"m.room.member",
myUserId
);
if (membershipEvent && !Array.isArray(membershipEvent)) {
const ts = membershipEvent.getTs();
tsCache[r.roomId] = ts;
return ts;
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i];
const ts = ev.getTs();
if (ts) {
tsCache[r.roomId] = ts;
return ts;
}
}
const ts = Number.MAX_SAFE_INTEGER;
tsCache[r.roomId] = ts;
return ts;
}
function sortRooms(client, rooms) {
return rooms.sort((a, b) => {
return getLastTs(client, b) - getLastTs(client, a);
});
}
export function useGroupCallRooms(client) {
const [rooms, setRooms] = useState([]);
useEffect(() => {
function updateRooms() {
const groupCalls = client.groupCallEventHandler.groupCalls.values();
const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room);
const sortedRooms = sortRooms(client, rooms);
const items = sortedRooms.map((room) => {
const groupCall = client.getGroupCallForRoom(room.roomId);
return {
roomId: room.getCanonicalAlias() || room.roomId,
roomName: room.name,
avatarUrl: null,
room,
groupCall,
participants: [...groupCall.participants],
};
});
setRooms(items);
}
updateRooms();
client.on("GroupCall.incoming", updateRooms);
client.on("GroupCall.participants", updateRooms);
return () => {
client.removeListener("GroupCall.incoming", updateRooms);
client.removeListener("GroupCall.participants", updateRooms);
};
}, []);
return rooms;
}
export function usePublicRooms(client, publicSpaceRoomId, maxRooms = 50) {
const [rooms, setRooms] = useState([]);
useEffect(() => {
if (publicSpaceRoomId) {
client.getRoomHierarchy(publicSpaceRoomId, maxRooms).then(({ rooms }) => {
const filteredRooms = rooms
.filter((room) => room.room_type !== "m.space")
.map((room) => ({
roomId: room.room_alias || room.room_id,
roomName: room.name,
avatarUrl: null,
room,
participants: [],
}));
setRooms(filteredRooms);
});
} else {
setRooms([]);
}
}, [publicSpaceRoomId]);
return rooms;
}
export function getRoomUrl(roomId) {
if (roomId.startsWith("#")) {
const [localPart, host] = roomId.replace("#", "").split(":");
if (host !== defaultHomeserverHost) {
return `${window.location.host}/room/${roomId}`;
} else {
return `${window.location.host}/${localPart}`;
}
} else {
return `${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");
}
export function useProfile(client) {
const [{ loading, displayName, avatarUrl, error, success }, setState] =
useState(() => {
const user = client?.getUser(client.getUserId());
return {
success: false,
loading: false,
displayName: user?.displayName,
avatarUrl: user && client && getAvatarUrl(client, user.avatarUrl),
error: null,
};
});
useEffect(() => {
const onChangeUser = (_event, { displayName, avatarUrl }) => {
setState({
success: false,
loading: false,
displayName,
avatarUrl: getAvatarUrl(client, avatarUrl),
error: null,
});
};
let user;
if (client) {
const userId = client.getUserId();
user = client.getUser(userId);
user.on("User.displayName", onChangeUser);
user.on("User.avatarUrl", onChangeUser);
}
return () => {
if (user) {
user.removeListener("User.displayName", onChangeUser);
user.removeListener("User.avatarUrl", onChangeUser);
}
};
}, [client]);
const saveProfile = useCallback(
async ({ displayName, avatar }) => {
if (client) {
setState((prev) => ({
...prev,
loading: true,
error: null,
success: false,
}));
try {
await client.setDisplayName(displayName);
let mxcAvatarUrl;
if (avatar) {
mxcAvatarUrl = await client.uploadContent(avatar);
await client.setAvatarUrl(mxcAvatarUrl);
}
setState((prev) => ({
...prev,
displayName,
avatarUrl: mxcAvatarUrl
? getAvatarUrl(client, mxcAvatarUrl)
: prev.avatarUrl,
loading: false,
success: true,
}));
} catch (error) {
setState((prev) => ({
...prev,
loading: false,
error,
success: false,
}));
}
} else {
console.error("Client not initialized before calling saveProfile");
}
},
[client]
);
return { loading, error, displayName, avatarUrl, saveProfile, success };
}

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
import React from "react";
import { Button } from "./button";
import { PopoverMenuTrigger } from "./PopoverMenu";
import { ReactComponent as SpotlightIcon } from "./icons/Spotlight.svg";
import { ReactComponent as FreedomIcon } from "./icons/Freedom.svg";
import { ReactComponent as CheckIcon } from "./icons/Check.svg";
import styles from "./GridLayoutMenu.module.css";
import { Menu } from "./Menu";
import { Item } from "@react-stately/collections";
import { Tooltip, TooltipTrigger } from "./Tooltip";
export function GridLayoutMenu({ layout, setLayout }) {
return (
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger>
<Button variant="icon">
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
</Button>
{(props) => (
<Tooltip position="bottom" {...props}>
Layout Type
</Tooltip>
)}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="Grid layout menu" onAction={setLayout}>
<Item key="freedom" textValue="Freedom">
<FreedomIcon />
<span>Freedom</span>
{layout === "freedom" && <CheckIcon className={styles.checkIcon} />}
</Item>
<Item key="spotlight" textValue="Spotlight">
<SpotlightIcon />
<span>Spotlight</span>
{layout === "spotlight" && (
<CheckIcon className={styles.checkIcon} />
)}
</Item>
</Menu>
)}
</PopoverMenuTrigger>
);
}

View File

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

View File

@@ -1,204 +0,0 @@
import { Resizable } from "re-resizable";
import React, { useEffect, useState, useMemo } from "react";
import { useCallback } from "react";
import ReactJson from "react-json-view";
function getCallUserId(call) {
return call.getOpponentMember()?.userId || call.invitee || null;
}
function getCallState(call) {
return {
id: call.callId,
opponentMemberId: getCallUserId(call),
state: call.state,
direction: call.direction,
};
}
function getHangupCallState(call) {
return {
...getCallState(call),
hangupReason: call.hangupReason,
};
}
export function GroupCallInspector({ client, groupCall, show }) {
const [roomStateEvents, setRoomStateEvents] = useState([]);
const [toDeviceEvents, setToDeviceEvents] = useState([]);
const [state, setState] = useState({
userId: client.getUserId(),
});
const updateState = useCallback(
(next) => setState((prev) => ({ ...prev, ...next })),
[]
);
useEffect(() => {
function onUpdateRoomState(event) {
if (event) {
setRoomStateEvents((prev) => [
...prev,
{
eventType: event.getType(),
stateKey: event.getStateKey(),
content: event.getContent(),
},
]);
}
const roomEvent = groupCall.room.currentState
.getStateEvents("org.matrix.msc3401.call", groupCall.groupCallId)
.getContent();
const memberEvents = Object.fromEntries(
groupCall.room.currentState
.getStateEvents("org.matrix.msc3401.call.member")
.map((event) => [event.getStateKey(), event.getContent()])
);
updateState({
["org.matrix.msc3401.call"]: roomEvent,
["org.matrix.msc3401.call.member"]: memberEvents,
});
}
function onCallsChanged() {
const calls = groupCall.calls.reduce((obj, call) => {
obj[
`${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
] = getCallState(call);
return obj;
}, {});
updateState({ calls });
}
function onCallHangup(call) {
setState(({ hangupCalls, ...rest }) => ({
...rest,
hangupCalls: {
...hangupCalls,
[`${call.callId} (${
call.getOpponentMember()?.userId || call.sender
})`]: getHangupCallState(call),
},
}));
}
function onToDeviceEvent(event) {
const eventType = event.getType();
if (
!(
eventType.startsWith("m.call.") ||
eventType.startsWith("org.matrix.call.")
)
) {
return;
}
const content = event.getContent();
if (content.conf_id && content.conf_id !== groupCall.groupCallId) {
return;
}
setToDeviceEvents((prev) => [
...prev,
{ eventType, content, sender: event.getSender() },
]);
}
client.on("RoomState.events", onUpdateRoomState);
groupCall.on("calls_changed", onCallsChanged);
client.on("state", onCallsChanged);
client.on("hangup", onCallHangup);
client.on("toDeviceEvent", onToDeviceEvent);
onUpdateRoomState();
}, [client, groupCall]);
const toDeviceEventsByCall = useMemo(() => {
const result = {};
for (const event of toDeviceEvents) {
const callId = event.content.call_id;
const key = `${callId} (${event.sender})`;
result[key] = result[key] || [];
result[key].push(event);
}
return result;
}, [toDeviceEvents]);
useEffect(() => {
let timeout;
async function updateCallStats() {
const callIds = groupCall.calls.map(
(call) =>
`${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
);
const stats = await Promise.all(
groupCall.calls.map((call) =>
call.peerConn
? call.peerConn
.getStats(null)
.then((stats) =>
Object.fromEntries(
Array.from(stats).map(([_id, report], i) => [
report.type + i,
report,
])
)
)
: Promise.resolve(null)
)
);
const callStats = {};
for (let i = 0; i < groupCall.calls.length; i++) {
callStats[callIds[i]] = stats[i];
}
updateState({ callStats });
timeout = setTimeout(updateCallStats, 1000);
}
if (show) {
updateCallStats();
}
return () => {
clearTimeout(timeout);
};
}, [show]);
if (!show) {
return null;
}
return (
<Resizable enable={{ top: true }} defaultSize={{ height: 200 }}>
<ReactJson
theme="monokai"
src={{
...state,
roomStateEvents,
toDeviceEvents,
toDeviceEventsByCall,
}}
name={null}
indentWidth={2}
collapsed={1}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard={false}
style={{ height: "100%", overflowY: "scroll" }}
/>
</Resizable>
);
}

View File

@@ -6,6 +6,8 @@ import { ReactComponent as Logo } from "./icons/Logo.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
import { useButton } from "@react-aria/button";
import { Subtitle } from "./typography/Typography";
import { Avatar } from "./Avatar";
export function Header({ children, className, ...rest }) {
return (
@@ -15,10 +17,15 @@ export function Header({ children, className, ...rest }) {
);
}
export function LeftNav({ children, className, ...rest }) {
export function LeftNav({ children, className, hideMobile, ...rest }) {
return (
<div
className={classNames(styles.nav, styles.leftNav, className)}
className={classNames(
styles.nav,
styles.leftNav,
{ [styles.hideMobile]: hideMobile },
className
)}
{...rest}
>
{children}
@@ -26,10 +33,15 @@ export function LeftNav({ children, className, ...rest }) {
);
}
export function RightNav({ children, className, ...rest }) {
export function RightNav({ children, className, hideMobile, ...rest }) {
return (
<div
className={classNames(styles.nav, styles.rightNav, className)}
className={classNames(
styles.nav,
styles.rightNav,
{ [styles.hideMobile]: hideMobile },
className
)}
{...rest}
>
{children}
@@ -37,32 +49,38 @@ export function RightNav({ children, className, ...rest }) {
);
}
export function HeaderLogo() {
export function HeaderLogo({ className }) {
return (
<Link className={styles.logo} to="/">
<Link className={classNames(styles.headerLogo, className)} to="/">
<Logo />
</Link>
);
}
export function RoomHeaderInfo({ roomName }) {
export function RoomHeaderInfo({ roomName, avatarUrl }) {
return (
<>
<div className={styles.roomAvatar}>
<Avatar
size="md"
src={avatarUrl}
bgKey={roomName}
fallback={roomName.slice(0, 1).toUpperCase()}
/>
<VideoIcon width={16} height={16} />
</div>
<h3>{roomName}</h3>
<Subtitle fontWeight="semiBold">{roomName}</Subtitle>
</>
);
}
export function RoomSetupHeaderInfo({ roomName, ...rest }) {
export function RoomSetupHeaderInfo({ roomName, avatarUrl, ...rest }) {
const ref = useRef();
const { buttonProps } = useButton(rest, ref);
return (
<button className={styles.backButton} ref={ref} {...buttonProps}>
<ArrowLeftIcon width={16} height={16} />
<RoomHeaderInfo roomName={roomName} />
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</button>
);
}

View File

@@ -16,16 +16,24 @@
height: 64px;
}
.logo {
display: flex;
.headerLogo {
display: none;
align-items: center;
text-decoration: none;
}
.leftNav.hideMobile {
display: none;
}
.leftNav > * {
margin-right: 12px;
}
.leftNav h3 {
margin: 0;
}
.rightNav {
justify-content: flex-end;
}
@@ -34,13 +42,17 @@
margin-right: 24px;
}
.rightNav.hideMobile {
display: none;
}
.nav > :last-child {
margin-right: 0;
}
.roomAvatar {
position: relative;
display: flex;
display: none;
justify-content: center;
align-items: center;
width: 36px;
@@ -58,7 +70,7 @@
background: transparent;
border: none;
display: flex;
color: var(--textColor1);
color: var(--primary-content);
cursor: pointer;
align-items: center;
}
@@ -93,7 +105,18 @@
}
@media (min-width: 800px) {
.headerLogo,
.roomAvatar,
.leftNav.hideMobile,
.rightNav.hideMobile {
display: flex;
}
.leftNav h3 {
font-size: 18px;
}
.nav {
height: 98px;
height: 76px;
}
}

106
src/Header.stories.jsx Normal file
View File

@@ -0,0 +1,106 @@
import React from "react";
import { GridLayoutMenu } from "./room/GridLayoutMenu";
import {
Header,
HeaderLogo,
LeftNav,
RightNav,
RoomHeaderInfo,
} from "./Header";
import { UserMenu } from "./UserMenu";
export default {
title: "Header",
component: Header,
parameters: {
layout: "fullscreen",
},
};
export const HomeAnonymous = () => (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu />
</RightNav>
</Header>
);
export const HomeNamedGuest = () => (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
</RightNav>
</Header>
);
export const HomeLoggedIn = () => (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu isAuthenticated displayName="Yara" />
</RightNav>
</Header>
);
export const LobbyNamedGuest = () => (
<Header>
<LeftNav>
<RoomHeaderInfo roomName="Q4Roadmap" />
</LeftNav>
<RightNav>
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
</RightNav>
</Header>
);
export const LobbyLoggedIn = () => (
<Header>
<LeftNav>
<RoomHeaderInfo roomName="Q4Roadmap" />
</LeftNav>
<RightNav>
<UserMenu isAuthenticated displayName="Yara" />
</RightNav>
</Header>
);
export const InRoomNamedGuest = () => (
<Header>
<LeftNav>
<RoomHeaderInfo roomName="Q4Roadmap" />
</LeftNav>
<RightNav>
<GridLayoutMenu layout="freedom" />
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
</RightNav>
</Header>
);
export const InRoomLoggedIn = () => (
<Header>
<LeftNav>
<RoomHeaderInfo roomName="Q4Roadmap" />
</LeftNav>
<RightNav>
<GridLayoutMenu layout="freedom" />
<UserMenu isAuthenticated displayName="Yara" />
</RightNav>
</Header>
);
export const CreateAccount = () => (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav></RightNav>
</Header>
);

View File

@@ -1,352 +0,0 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react";
import { useHistory, Link } from "react-router-dom";
import {
useClient,
useGroupCallRooms,
usePublicRooms,
createRoom,
roomAliasFromRoomName,
} from "./ConferenceCallManagerHooks";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import styles from "./Home.module.css";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { UserMenu } from "./UserMenu";
import { Button } from "./button";
import { CallList } from "./CallList";
import classNames from "classnames";
import { ErrorView, LoadingView } from "./FullScreenView";
import { useModalTriggerState } from "./Modal";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
export function Home() {
const {
isAuthenticated,
isGuest,
isPasswordlessUser,
loading,
error,
client,
register,
} = useClient();
const history = useHistory();
const [creatingRoom, setCreatingRoom] = useState(false);
const [createRoomError, setCreateRoomError] = useState();
const { modalState, modalProps } = useModalTriggerState();
const [existingRoomId, setExistingRoomId] = useState();
const onCreateRoom = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("roomName");
const userName = data.get("userName");
async function onCreateRoom() {
let _client = client;
if (!_client || isGuest) {
_client = await register(userName, randomString(16), true);
}
const roomIdOrAlias = await createRoom(_client, roomName);
if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`);
}
}
setCreateRoomError(undefined);
setCreatingRoom(true);
return onCreateRoom().catch((error) => {
if (error.errcode === "M_ROOM_IN_USE") {
setExistingRoomId(roomAliasFromRoomName(roomName));
setCreateRoomError(undefined);
modalState.open();
} else {
setCreateRoomError(error);
}
setCreatingRoom(false);
});
},
[client, history, register, isGuest]
);
const onJoinRoom = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomId = data.get("roomId");
history.push(`/${roomId}`);
},
[history]
);
const onJoinExistingRoom = useCallback(() => {
history.push(`/${existingRoomId}`);
}, [history, existingRoomId]);
if (loading) {
return <LoadingView />;
} else if (error) {
return <ErrorView error={error} />;
} else {
return (
<>
{!isAuthenticated || isGuest ? (
<UnregisteredView
onCreateRoom={onCreateRoom}
createRoomError={createRoomError}
creatingRoom={creatingRoom}
onJoinRoom={onJoinRoom}
/>
) : (
<RegisteredView
client={client}
isPasswordlessUser={isPasswordlessUser}
isGuest={isGuest}
onCreateRoom={onCreateRoom}
createRoomError={createRoomError}
creatingRoom={creatingRoom}
onJoinRoom={onJoinRoom}
/>
)}
{modalState.isOpen && (
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
)}
</>
);
}
}
function UnregisteredView({
onCreateRoom,
createRoomError,
creatingRoom,
onJoinRoom,
}) {
return (
<div className={classNames(styles.home, styles.fullWidth)}>
<Header className={styles.header}>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu />
</RightNav>
</Header>
<div className={styles.splitContainer}>
<div className={styles.left}>
<div className={styles.content}>
<div className={styles.centered}>
<form onSubmit={onJoinRoom}>
<h1>Join a call</h1>
<FieldRow className={styles.fieldRow}>
<InputField
id="roomId"
name="roomId"
label="Call ID"
type="text"
required
autoComplete="off"
placeholder="Call ID"
/>
</FieldRow>
<FieldRow className={styles.fieldRow}>
<Button className={styles.button} type="submit">
Join call
</Button>
</FieldRow>
</form>
<hr />
<form onSubmit={onCreateRoom}>
<h1>Create a call</h1>
<FieldRow className={styles.fieldRow}>
<InputField
id="userName"
name="userName"
label="Username"
type="text"
required
autoComplete="off"
placeholder="Username"
/>
</FieldRow>
<FieldRow className={styles.fieldRow}>
<InputField
id="roomName"
name="roomName"
label="Room Name"
type="text"
required
autoComplete="off"
placeholder="Room Name"
/>
</FieldRow>
{createRoomError && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{createRoomError.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow className={styles.fieldRow}>
<Button
className={styles.button}
type="submit"
disabled={creatingRoom}
>
{creatingRoom ? "Creating call..." : "Create call"}
</Button>
</FieldRow>
</form>
<div className={styles.authLinks}>
<p>
Not registered yet?{" "}
<Link to="/register">Create an account</Link>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function RegisteredView({
client,
isPasswordlessUser,
isGuest,
onCreateRoom,
createRoomError,
creatingRoom,
onJoinRoom,
}) {
const publicRooms = usePublicRooms(
client,
import.meta.env.VITE_PUBLIC_SPACE_ROOM_ID
);
const recentRooms = useGroupCallRooms(client);
const hideCallList = publicRooms.length === 0 && recentRooms.length === 0;
return (
<div
className={classNames(styles.home, {
[styles.fullWidth]: hideCallList,
})}
>
<Header className={styles.header}>
<LeftNav className={styles.leftNav}>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu />
</RightNav>
</Header>
<div className={styles.splitContainer}>
<div className={styles.left}>
<div className={styles.content}>
<div className={styles.centered}>
<form onSubmit={onJoinRoom}>
<h1>Join a call</h1>
<FieldRow className={styles.fieldRow}>
<InputField
id="roomId"
name="roomId"
label="Call ID"
type="text"
required
autoComplete="off"
placeholder="Call ID"
/>
</FieldRow>
<FieldRow className={styles.fieldRow}>
<Button className={styles.button} type="submit">
Join call
</Button>
</FieldRow>
</form>
<hr />
<form onSubmit={onCreateRoom}>
<h1>Create a call</h1>
<FieldRow className={styles.fieldRow}>
<InputField
id="roomName"
name="roomName"
label="Room Name"
type="text"
required
autoComplete="off"
placeholder="Room Name"
/>
</FieldRow>
{createRoomError && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{createRoomError.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow className={styles.fieldRow}>
<Button
className={styles.button}
type="submit"
disabled={creatingRoom}
>
{creatingRoom ? "Creating call..." : "Create call"}
</Button>
</FieldRow>
</form>
{(isPasswordlessUser || isGuest) && (
<div className={styles.authLinks}>
<p>
Not registered yet?{" "}
<Link to="/register">Create an account</Link>
</p>
</div>
)}
</div>
</div>
</div>
{!hideCallList && (
<div className={styles.right}>
<div className={styles.content}>
{publicRooms.length > 0 && (
<CallList
title="Public Calls"
rooms={publicRooms}
client={client}
/>
)}
{recentRooms.length > 0 && (
<CallList
title="Recent Calls"
rooms={recentRooms}
client={client}
/>
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,139 +0,0 @@
.home {
display: flex;
flex: 1;
flex-direction: column;
min-height: 100%;
}
.splitContainer {
display: flex;
flex: 1;
flex-direction: column;
}
.left,
.right {
display: flex;
flex-direction: column;
flex: 1;
}
.fullWidth {
background-color: var(--bgColor1);
}
.fullWidth .header {
background-color: var(--bgColor1);
}
.centered {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
max-width: 512px;
min-width: 0;
}
.content {
flex: 1;
}
.left .content {
display: flex;
flex-direction: column;
align-items: center;
}
.left .content form > * {
margin-top: 0;
margin-bottom: 24px;
}
.left .content form > :last-child {
margin-bottom: 0;
}
.left .content hr:after {
background-color: var(--bgColor1);
content: "OR";
padding: 0 12px;
position: relative;
top: -12px;
}
.left .content form {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 92px;
}
.fieldRow {
width: 100%;
}
.button {
height: 40px;
width: 100%;
font-size: 15px;
font-weight: 600;
}
.left .content form:first-child {
padding-top: 0;
}
.left .content form:last-child {
padding-bottom: 40px;
}
.right .content {
padding: 0 40px 40px 40px;
}
.right .content h3:first-child {
margin-top: 0;
}
.authLinks {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
}
.authLinks {
margin-bottom: 100px;
font-size: 15px;
}
.authLinks a {
color: #0dbd8b;
font-weight: normal;
text-decoration: none;
}
@media (min-width: 800px) {
.left {
background-color: var(--bgColor2);
}
.home:not(.fullWidth) .left {
max-width: 50%;
}
.home:not(.fullWidth) .leftNav {
background-color: var(--bgColor2);
}
.splitContainer {
flex-direction: row;
}
.fullWidth .content hr:after,
.left .content hr:after,
.fullWidth .header {
background-color: var(--bgColor2);
}
}

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

@@ -1,21 +0,0 @@
import React from "react";
import { Modal, ModalContent } from "./Modal";
import { CopyButton } from "./button";
import { getRoomUrl } from "./ConferenceCallManagerHooks";
import styles from "./InviteModal.module.css";
export function InviteModal({ roomId, ...rest }) {
return (
<Modal
title="Invite People"
isDismissable
className={styles.inviteModal}
{...rest}
>
<ModalContent>
<p>Copy and share this meeting link</p>
<CopyButton className={styles.copyButton} value={getRoomUrl(roomId)} />
</ModalContent>
</Modal>
);
}

View File

@@ -1,19 +0,0 @@
import React from "react";
import { Modal, ModalContent } from "./Modal";
import { Button } from "./button";
import { FieldRow } from "./Input";
import styles from "./JoinExistingCallModal.module.css";
export function JoinExistingCallModal({ onJoin, ...rest }) {
return (
<Modal title="Join existing call?" isDismissable {...rest}>
<ModalContent>
<p>This call already exists, would you like to join?</p>
<FieldRow rightAlign className={styles.buttons}>
<Button onPress={rest.onClose}>No</Button>
<Button onPress={onJoin}>Yes, join call</Button>
</FieldRow>
</ModalContent>
</Modal>
);
}

View File

@@ -5,8 +5,8 @@
overflow-y: auto;
list-style: none;
background-color: transparent;
border: 1px solid var(--inputBorderColor);
background-color: var(--bgColor1);
border: 1px solid var(--quinary-content);
background-color: var(--background);
border-radius: 8px;
}
@@ -15,7 +15,7 @@
align-items: center;
justify-content: space-between;
background-color: transparent;
color: var(--textColor1);
color: var(--primary-content);
padding: 8px 16px;
outline: none;
cursor: pointer;
@@ -23,15 +23,11 @@
min-height: 32px;
}
.option.selected {
color: #0dbd8b;
}
.option.focused {
background-color: rgba(111, 120, 130, 0.2);
}
.option.disabled {
color: var(--textColor2);
color: var(--quaternary-content);
background-color: var(--bgColor3);
}

View File

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

View File

@@ -52,6 +52,12 @@
}
@media (max-width: 799px) {
.modalHeader {
display: flex;
justify-content: space-between;
padding: 24px 24px 0 24px;
}
.modal.mobileFullScreen {
position: fixed;
left: 0;

View File

@@ -1,77 +0,0 @@
import React, { useCallback } from "react";
import { Button } from "./button";
import { Menu } from "./Menu";
import { PopoverMenuTrigger } from "./PopoverMenu";
import { Item } from "@react-stately/collections";
import { ReactComponent as SettingsIcon } from "./icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "./icons/AddUser.svg";
import { ReactComponent as OverflowIcon } from "./icons/Overflow.svg";
import { useModalTriggerState } from "./Modal";
import { SettingsModal } from "./SettingsModal";
import { InviteModal } from "./InviteModal";
import { Tooltip, TooltipTrigger } from "./Tooltip";
export function OverflowMenu({
roomId,
setShowInspector,
showInspector,
client,
}) {
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
const { modalState: settingsModalState, modalProps: settingsModalProps } =
useModalTriggerState();
// TODO: On closing modal, focus should be restored to the trigger button
// https://github.com/adobe/react-spectrum/issues/2444
const onAction = useCallback((key) => {
switch (key) {
case "invite":
inviteModalState.open();
break;
case "settings":
settingsModalState.open();
break;
}
});
return (
<>
<PopoverMenuTrigger disableOnState>
<TooltipTrigger>
<Button variant="toolbar">
<OverflowIcon />
</Button>
{(props) => (
<Tooltip position="top" {...props}>
More
</Tooltip>
)}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="More menu" onAction={onAction}>
<Item key="invite" textValue="Invite people">
<AddUserIcon />
<span>Invite people</span>
</Item>
<Item key="settings" textValue="Settings">
<SettingsIcon />
<span>Settings</span>
</Item>
</Menu>
)}
</PopoverMenuTrigger>
{settingsModalState.isOpen && (
<SettingsModal
{...settingsModalProps}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
/>
)}
{inviteModalState.isOpen && (
<InviteModal roomId={roomId} {...inviteModalProps} />
)}
</>
);
}

View File

@@ -1,72 +0,0 @@
import React, { useRef } from "react";
import styles from "./PopoverMenu.module.css";
import { useMenuTriggerState } from "@react-stately/menu";
import { useMenuTrigger } from "@react-aria/menu";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
import classNames from "classnames";
import { Popover } from "./Popover";
export function PopoverMenuTrigger({
children,
placement,
className,
disableOnState,
...rest
}) {
const popoverMenuState = useMenuTriggerState(rest);
const buttonRef = useRef();
const { menuTriggerProps, menuProps } = useMenuTrigger(
{},
popoverMenuState,
buttonRef
);
const popoverRef = useRef();
const { overlayProps } = useOverlayPosition({
targetRef: buttonRef,
overlayRef: popoverRef,
placement: placement || "top",
offset: 5,
isOpen: popoverMenuState.isOpen,
});
if (
!Array.isArray(children) ||
children.length > 2 ||
typeof children[1] !== "function"
) {
throw new Error(
"PopoverMenu must have two props. The first being a button and the second being a render prop."
);
}
const [popoverTrigger, popoverMenu] = children;
return (
<div className={classNames(styles.popoverMenuTrigger, className)}>
<popoverTrigger.type
{...popoverTrigger.props}
{...menuTriggerProps}
on={!disableOnState && popoverMenuState.isOpen}
ref={buttonRef}
/>
{popoverMenuState.isOpen && (
<OverlayContainer>
<Popover
{...overlayProps}
isOpen={popoverMenuState.isOpen}
onClose={popoverMenuState.close}
ref={popoverRef}
>
{popoverMenu({
...menuProps,
autoFocus: popoverMenuState.focusStrategy,
onClose: popoverMenuState.close,
})}
</Popover>
</OverlayContainer>
)}
</div>
);
}

View File

@@ -1,96 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { Button } from "./button";
import { useProfile } from "./ConferenceCallManagerHooks";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Modal, ModalContent } from "./Modal";
export function ProfileModal({
client,
isAuthenticated,
isPasswordlessUser,
isGuest,
...rest
}) {
const { onClose } = rest;
const {
success,
error,
loading,
displayName: initialDisplayName,
saveProfile,
} = useProfile(client);
const [displayName, setDisplayName] = useState(initialDisplayName || "");
const onChangeDisplayName = useCallback(
(e) => {
setDisplayName(e.target.value);
},
[setDisplayName]
);
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const displayName = data.get("displayName");
const avatar = data.get("avatar");
saveProfile({
displayName,
avatar,
});
},
[saveProfile]
);
useEffect(() => {
if (success) {
onClose();
}
}, [success, onClose]);
return (
<Modal title="Profile" isDismissable {...rest}>
<ModalContent>
<form onSubmit={onSubmit}>
<FieldRow>
<InputField
id="displayName"
name="displayName"
label="Display Name"
type="text"
required
autoComplete="off"
placeholder="Display Name"
value={displayName}
onChange={onChangeDisplayName}
/>
</FieldRow>
{isAuthenticated && !isGuest && !isPasswordlessUser && (
<FieldRow>
<InputField
type="file"
id="avatar"
name="avatar"
label="Avatar"
/>
</FieldRow>
)}
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow rightAlign>
<Button type="button" variant="secondary" onPress={onClose}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</FieldRow>
</form>
</ModalContent>
</Modal>
);
}

View File

@@ -1,183 +0,0 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useHistory, useLocation, Link } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Button } from "./button";
import { useClient, defaultHomeserverHost } from "./ConferenceCallManagerHooks";
import styles from "./LoginPage.module.css";
import { ReactComponent as Logo } from "./icons/LogoLarge.svg";
import { LoadingView } from "./FullScreenView";
export function RegisterPage() {
const {
loading,
client,
register,
changePassword,
isAuthenticated,
isPasswordlessUser,
} = useClient();
const confirmPasswordRef = useRef();
const history = useHistory();
const location = useLocation();
const [registering, setRegistering] = useState(false);
const [error, setError] = useState();
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const onSubmitRegisterForm = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const userName = data.get("userName");
const password = data.get("password");
const passwordConfirmation = data.get("passwordConfirmation");
if (password !== passwordConfirmation) {
return;
}
setRegistering(true);
if (isPasswordlessUser) {
changePassword(password)
.then(() => {
if (location.state && location.state.from) {
history.push(location.state.from);
} else {
history.push("/");
}
})
.catch((error) => {
setError(error);
setRegistering(false);
});
} else {
register(userName, password)
.then(() => {
if (location.state && location.state.from) {
history.push(location.state.from);
} else {
history.push("/");
}
})
.catch((error) => {
setError(error);
setRegistering(false);
});
}
},
[register, changePassword, location, history, isPasswordlessUser]
);
useEffect(() => {
if (!confirmPasswordRef.current) {
return;
}
if (password && passwordConfirmation && password !== passwordConfirmation) {
confirmPasswordRef.current.setCustomValidity("Passwords must match");
} else {
confirmPasswordRef.current.setCustomValidity("");
}
}, [password, passwordConfirmation]);
useEffect(() => {
if (!loading && isAuthenticated && !isPasswordlessUser) {
history.push("/");
}
}, [history, isAuthenticated, isPasswordlessUser]);
if (loading) {
return <LoadingView />;
}
return (
<>
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.formContainer}>
<Logo width="auto" height="auto" className={styles.logo} />
<h2>Create your account</h2>
<form onSubmit={onSubmitRegisterForm}>
<FieldRow>
<InputField
type="text"
name="userName"
placeholder="Username"
label="Username"
autoCorrect="off"
autoCapitalize="none"
prefix="@"
suffix={`:${defaultHomeserverHost}`}
value={
isAuthenticated && isPasswordlessUser
? client.getUserIdLocalpart()
: undefined
}
disabled={isAuthenticated && isPasswordlessUser}
/>
</FieldRow>
<FieldRow>
<InputField
required
name="password"
type="password"
onChange={(e) => setPassword(e.target.value)}
value={password}
placeholder="Password"
label="Password"
/>
</FieldRow>
<FieldRow>
<InputField
required
type="password"
name="passwordConfirmation"
onChange={(e) => setPasswordConfirmation(e.target.value)}
value={passwordConfirmation}
placeholder="Confirm Password"
label="Confirm Password"
ref={confirmPasswordRef}
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={registering}>
{registering ? "Registering..." : "Register"}
</Button>
</FieldRow>
</form>
</div>
<div className={styles.authLinks}>
<p>Already have an account?</p>
<p>
<Link to="/login">Log in</Link>
{" Or "}
<Link to="/">Access as a guest</Link>
</p>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,574 +0,0 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
import styles from "./Room.module.css";
import { useLocation, useParams, useHistory, Link } from "react-router-dom";
import {
Button,
CopyButton,
HangupButton,
MicButton,
VideoButton,
ScreenshareButton,
LinkButton,
} from "./button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "./Header";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import VideoGrid, {
useVideoGridLayout,
} from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoGrid";
import SimpleVideoGrid from "matrix-react-sdk/src/components/views/voip/GroupCallView/SimpleVideoGrid";
import "matrix-react-sdk/res/css/views/voip/GroupCallView/_VideoGrid.scss";
import { useGroupCall } from "matrix-react-sdk/src/hooks/useGroupCall";
import { useCallFeed } from "matrix-react-sdk/src/hooks/useCallFeed";
import { useMediaStream } from "matrix-react-sdk/src/hooks/useMediaStream";
import {
getAvatarUrl,
getRoomUrl,
useClient,
useLoadGroupCall,
useProfile,
} from "./ConferenceCallManagerHooks";
import { ErrorView, LoadingView, FullScreenView } from "./FullScreenView";
import { GroupCallInspector } from "./GroupCallInspector";
import * as Sentry from "@sentry/react";
import { OverflowMenu } from "./OverflowMenu";
import { GridLayoutMenu } from "./GridLayoutMenu";
import { UserMenu } from "./UserMenu";
import classNames from "classnames";
import { Avatar } from "./Avatar";
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
// or with getUsermedia and getDisplaymedia being used within the same session.
// For now we can disable screensharing in Safari.
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
export function Room() {
const [registeringGuest, setRegisteringGuest] = useState(false);
const [registrationError, setRegistrationError] = useState();
const {
loading,
isAuthenticated,
error,
client,
registerGuest,
isGuest,
isPasswordlessUser,
} = useClient();
useEffect(() => {
if (!loading && !isAuthenticated) {
setRegisteringGuest(true);
registerGuest()
.then(() => {
setRegisteringGuest(false);
})
.catch((error) => {
setRegistrationError(error);
setRegisteringGuest(false);
});
}
}, [loading, isAuthenticated]);
if (loading || registeringGuest) {
return <LoadingView />;
}
if (registrationError || error) {
return <ErrorView error={registrationError || error} />;
}
return (
<GroupCall
client={client}
isGuest={isGuest}
isPasswordlessUser={isPasswordlessUser}
/>
);
}
export function GroupCall({ client, isGuest, isPasswordlessUser }) {
const { roomId: maybeRoomId } = useParams();
const { hash, search } = useLocation();
const [simpleGrid, viaServers] = useMemo(() => {
const params = new URLSearchParams(search);
return [params.has("simple"), params.getAll("via")];
}, [search]);
const roomId = maybeRoomId || hash;
const { loading, error, groupCall } = useLoadGroupCall(
client,
roomId,
viaServers
);
useEffect(() => {
window.groupCall = groupCall;
}, [groupCall]);
if (loading) {
return <LoadingRoomView />;
}
if (error) {
return <ErrorView error={error} />;
}
return (
<GroupCallView
isGuest={isGuest}
isPasswordlessUser={isPasswordlessUser}
client={client}
roomId={roomId}
groupCall={groupCall}
simpleGrid={simpleGrid}
/>
);
}
export function GroupCallView({
client,
isGuest,
isPasswordlessUser,
roomId,
groupCall,
simpleGrid,
}) {
const [showInspector, setShowInspector] = useState(false);
const {
state,
error,
activeSpeaker,
userMediaFeeds,
microphoneMuted,
localVideoMuted,
localCallFeed,
initLocalCallFeed,
enter,
leave,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
toggleScreensharing,
isScreensharing,
localScreenshareFeed,
screenshareFeeds,
hasLocalParticipant,
} = useGroupCall(groupCall);
useEffect(() => {
function onHangup(call) {
if (call.hangupReason === "ice_failed") {
Sentry.captureException(new Error("Call hangup due to ICE failure."));
}
}
function onError(error) {
Sentry.captureException(error);
}
if (groupCall) {
groupCall.on("hangup", onHangup);
groupCall.on("error", onError);
}
return () => {
if (groupCall) {
groupCall.removeListener("hangup", onHangup);
groupCall.removeListener("error", onError);
}
};
}, [groupCall]);
const [left, setLeft] = useState(false);
const history = useHistory();
const onLeave = useCallback(() => {
leave();
if (!isGuest && !isPasswordlessUser) {
history.push("/");
} else {
setLeft(true);
}
}, [leave, history, isGuest]);
if (error) {
return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered) {
return (
<InRoomView
groupCall={groupCall}
client={client}
isGuest={isGuest}
roomName={groupCall.room.name}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
userMediaFeeds={userMediaFeeds}
activeSpeaker={activeSpeaker}
onLeave={onLeave}
toggleScreensharing={toggleScreensharing}
isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
simpleGrid={simpleGrid}
setShowInspector={setShowInspector}
showInspector={showInspector}
roomId={roomId}
/>
);
} else if (state === GroupCallState.Entering) {
return <EnteringRoomView />;
} else if (left) {
if (isPasswordlessUser) {
return <PasswordlessUserCallEndedScreen client={client} />;
} else {
return <GuestCallEndedScreen />;
}
} else {
return (
<RoomSetupView
isGuest={isGuest}
client={client}
hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name}
state={state}
onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed}
onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
setShowInspector={setShowInspector}
showInspector={showInspector}
roomId={roomId}
/>
);
}
}
export function LoadingRoomView() {
return (
<FullScreenView>
<h1>Loading room...</h1>
</FullScreenView>
);
}
export function EnteringRoomView() {
return (
<FullScreenView>
<h1>Entering room...</h1>
</FullScreenView>
);
}
function RoomSetupView({
client,
roomName,
state,
onInitLocalCallFeed,
onEnter,
localCallFeed,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
setShowInspector,
showInspector,
roomId,
}) {
const { stream } = useCallFeed(localCallFeed);
const videoRef = useMediaStream(stream, true);
useEffect(() => {
onInitLocalCallFeed();
}, [onInitLocalCallFeed]);
return (
<div className={styles.room}>
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} />
</LeftNav>
<RightNav>
<UserMenu />
</RightNav>
</Header>
<div className={styles.joinRoom}>
<div className={styles.joinRoomContent}>
<div className={styles.preview}>
<video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedUninitialized && (
<p className={styles.webcamPermissions}>
Webcam/microphone permissions needed to join the call.
</p>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<p className={styles.webcamPermissions}>
Accept webcam/microphone permissions to join the call.
</p>
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<>
<Button
className={styles.joinCallButton}
disabled={state !== GroupCallState.LocalCallFeedInitialized}
onPress={onEnter}
>
Join call now
</Button>
<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>
<p>Or</p>
<CopyButton
value={getRoomUrl(roomId)}
className={styles.copyButton}
copiedMessage="Call link copied"
>
Copy call link and join later
</CopyButton>
</div>
<div className={styles.joinRoomFooter}>
<Link className={styles.homeLink} to="/">
Take me Home
</Link>
</div>
</div>
</div>
);
}
function InRoomView({
client,
isGuest,
groupCall,
roomName,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
userMediaFeeds,
activeSpeaker,
onLeave,
toggleScreensharing,
isScreensharing,
screenshareFeeds,
simpleGrid,
setShowInspector,
showInspector,
roomId,
}) {
const [layout, setLayout] = useVideoGridLayout();
const items = useMemo(() => {
const participants = [];
for (const callFeed of userMediaFeeds) {
participants.push({
id: callFeed.stream.id,
usermediaCallFeed: callFeed,
isActiveSpeaker:
screenshareFeeds.length === 0
? callFeed.userId === activeSpeaker
: false,
});
}
for (const callFeed of screenshareFeeds) {
const participant = participants.find(
(p) => p.usermediaCallFeed.userId === callFeed.userId
);
if (participant) {
participant.screenshareCallFeed = callFeed;
}
}
return participants;
}, [userMediaFeeds, activeSpeaker, screenshareFeeds]);
const onFocusTile = useCallback(
(tiles, focusedTile) => {
if (layout === "freedom") {
return tiles.map((tile) => {
if (tile === focusedTile) {
return { ...tile, presenter: !tile.presenter };
}
return tile;
});
} else {
setLayout("spotlight");
return tiles.map((tile) => {
if (tile === focusedTile) {
return { ...tile, presenter: true };
}
return { ...tile, presenter: false };
});
}
},
[layout, setLayout]
);
const renderAvatar = useCallback(
(roomMember, width, height) => {
const avatarUrl = roomMember.user?.avatarUrl;
const size = Math.round(Math.min(width, height) / 2);
return (
<Avatar
key={roomMember.userId}
style={{
width: size,
height: size,
borderRadius: size,
fontSize: Math.round(size / 2),
}}
src={avatarUrl && getAvatarUrl(client, avatarUrl, 96)}
fallback={roomMember.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
);
},
[client]
);
return (
<div className={classNames(styles.room, styles.inRoom)}>
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} />
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
{!isGuest && <UserMenu disableLogout />}
</RightNav>
</Header>
{items.length === 0 ? (
<div className={styles.centerMessage}>
<p>Waiting for other participants...</p>
</div>
) : simpleGrid ? (
<SimpleVideoGrid items={items} />
) : (
<VideoGrid
items={items}
layout={layout}
getAvatar={renderAvatar}
onFocusTile={onFocusTile}
disableAnimations={isSafari}
/>
)}
<div className={styles.footer}>
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
{canScreenshare && !isSafari && (
<ScreenshareButton
enabled={isScreensharing}
onPress={toggleScreensharing}
/>
)}
<OverflowMenu
roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
/>
<HangupButton onPress={onLeave} />
</div>
<GroupCallInspector
client={client}
groupCall={groupCall}
show={showInspector}
/>
</div>
);
}
export function GuestCallEndedScreen() {
return (
<FullScreenView className={styles.callEndedScreen}>
<h1>Your call is now ended</h1>
<div className={styles.callEndedContent}>
<p>Why not finish by creating an account?</p>
<p>You'll be able to:</p>
<ul>
<li>Easily access all your previous call links</li>
<li>Set a username and avatar</li>
</ul>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/register"
>
Create account
</LinkButton>
</div>
<Link to="/">Not now, return to home screen</Link>
</FullScreenView>
);
}
export function PasswordlessUserCallEndedScreen({ client }) {
const { displayName } = useProfile(client);
return (
<FullScreenView className={styles.callEndedScreen}>
<h1>{displayName}, your call is now ended</h1>
<div className={styles.callEndedContent}>
<p>Why not finish by setting up a password to keep your account?</p>
<p>
You'll be able to keep your name and set an avatar for use on future
calls
</p>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/register"
>
Create account
</LinkButton>
</div>
<Link to="/">Not now, return to home screen</Link>
</FullScreenView>
);
}

View File

@@ -1,217 +0,0 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.room {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 100%;
}
.inRoom {
position: fixed;
height: 100%;
width: 100%;
}
.joinRoom {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
overflow: hidden;
height: 100%;
}
.joinRoomContent {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.joinRoomContent h1 {
display: none;
margin: 0;
}
.joinRoomFooter {
margin: 20px 0;
}
.homeLink {
margin-top: 50px;
color: #0dbd8b;
text-decoration: none;
font-weight: normal;
font-size: 15px;
}
.preview {
position: relative;
min-height: 280px;
height: 50vh;
border-radius: 24px;
overflow: hidden;
background-color: var(--bgColor3);
margin: 40px 20px 20px 20px;
}
.preview video {
width: 100%;
height: 100%;
object-fit: contain;
background-color: black;
transform: scaleX(-1);
}
.webcamPermissions {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
font-size: 13px;
font-weight: 600;
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 {
position: absolute;
width: 100%;
max-width: 222px;
height: 40px;
bottom: 86px;
left: 50%;
font-weight: 600;
font-size: 15px;
transform: translateX(-50%);
}
.copyButton {
width: 320px !important;
}
.previewButtons > * {
margin-right: 30px;
}
.previewButtons > :last-child {
margin-right: 0px;
}
.centerMessage {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
flex-direction: column;
}
.centerMessage p {
display: block;
margin-bottom: 0;
}
.roomContainer {
overflow: hidden;
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-height: 0;
}
.footer {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 64px;
}
.footer > * {
margin-right: 30px;
}
.footer > :last-child {
margin-right: 0px;
}
.callEndedScreen h1 {
text-align: center;
margin-bottom: 60px;
}
.callEndedScreen h2 {
font-size: 24px;
font-weight: 600;
margin-bottom: 32px;
}
.callEndedScreen p {
margin: 0 0 16px 0;
}
.callEndedScreen ul {
padding: 0;
margin-bottom: 40px;
text-align: initial;
padding-left: 20px;
}
.callEndedButton {
width: 100%;
}
.callEndedContent {
text-align: center;
max-width: 360px;
}
.avatar {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@media (min-width: 800px) {
.roomContainer {
flex-direction: row;
}
.footer {
height: 118px;
}
.joinRoomContent h1 {
display: block;
}
}

View File

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

View File

@@ -1,95 +0,0 @@
import React from "react";
import { Modal } from "./Modal";
import styles from "./SettingsModal.module.css";
import { TabContainer, TabItem } from "./Tabs";
import { ReactComponent as AudioIcon } from "./icons/Audio.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { ReactComponent as DeveloperIcon } from "./icons/Developer.svg";
import { SelectInput } from "./SelectInput";
import { Item } from "@react-stately/collections";
import { useMediaHandler } from "./useMediaHandler";
import { FieldRow, InputField } from "./Input";
export function SettingsModal({
client,
setShowInspector,
showInspector,
...rest
}) {
const {
audioInput,
audioInputs,
setAudioInput,
videoInput,
videoInputs,
setVideoInput,
} = useMediaHandler(client);
return (
<Modal
title="Settings"
isDismissable
mobileFullScreen
className={styles.settingsModal}
{...rest}
>
<TabContainer className={styles.tabContainer}>
<TabItem
title={
<>
<AudioIcon width={16} height={16} />
<span>Audio</span>
</>
}
>
<SelectInput
label="Microphone"
selectedKey={audioInput}
onSelectionChange={setAudioInput}
>
{audioInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
))}
</SelectInput>
</TabItem>
<TabItem
title={
<>
<VideoIcon width={16} height={16} />
<span>Video</span>
</>
}
>
<SelectInput
label="Webcam"
selectedKey={videoInput}
onSelectionChange={setVideoInput}
>
{videoInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
))}
</SelectInput>
</TabItem>
<TabItem
title={
<>
<DeveloperIcon width={16} height={16} />
<span>Developer</span>
</>
}
>
<FieldRow>
<InputField
id="showInspector"
name="inspector"
label="Show Call Inspector"
type="checkbox"
checked={showInspector}
onChange={(e) => setShowInspector(e.target.checked)}
/>
</FieldRow>
</TabItem>
</TabContainer>
</Modal>
);
}

View File

@@ -1,33 +1,46 @@
import React, { forwardRef, useRef } from "react";
import { useTooltipTriggerState } from "@react-stately/tooltip";
import { FocusableProvider } from "@react-aria/focus";
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
import { mergeProps } from "@react-aria/utils";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import styles from "./Tooltip.module.css";
import classNames from "classnames";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
export function Tooltip({ position, state, ...props }) {
let { tooltipProps } = useTooltip(props, state);
export const Tooltip = forwardRef(
({ position, state, className, ...props }, ref) => {
let { tooltipProps } = useTooltip(props, state);
return (
<div
className={classNames(styles.tooltip, styles[position || "bottom"])}
{...mergeProps(props, tooltipProps)}
>
{props.children}
</div>
);
}
return (
<div
className={classNames(styles.tooltip, className)}
{...mergeProps(props, tooltipProps)}
ref={ref}
>
{props.children}
</div>
);
}
);
export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => {
const tooltipState = useTooltipTriggerState(rest);
const fallbackRef = useRef();
const triggerRef = ref || fallbackRef;
const triggerRef = useObjectRef(ref);
const overlayRef = useRef();
const { triggerProps, tooltipProps } = useTooltipTrigger(
rest,
tooltipState,
triggerRef
);
const { overlayProps } = useOverlayPosition({
placement: rest.placement || "top",
targetRef: triggerRef,
overlayRef,
isOpen: tooltipState.isOpen,
offset: 5,
});
if (
!Array.isArray(children) ||
children.length > 2 ||
@@ -41,13 +54,20 @@ export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => {
const [tooltipTrigger, tooltip] = children;
return (
<div className={styles.tooltipContainer}>
<tooltipTrigger.type
{...mergeProps(triggerProps, tooltipTrigger.props, rest)}
ref={triggerRef}
/>
{tooltipState.isOpen && tooltip({ state: tooltipState, ...tooltipProps })}
</div>
<FocusableProvider ref={triggerRef} {...triggerProps}>
{<tooltipTrigger.type {...mergeProps(tooltipTrigger.props, rest)} />}
{tooltipState.isOpen && (
<OverlayContainer>
<Tooltip
state={tooltipState}
{...mergeProps(tooltipProps, overlayProps)}
ref={overlayRef}
>
{tooltip()}
</Tooltip>
</OverlayContainer>
)}
</FocusableProvider>
);
});

View File

@@ -1,33 +1,12 @@
.tooltip {
background-color: var(--bgColor2);
position: absolute;
background-color: var(--system);
flex-direction: row;
justify-content: center;
align-items: center;
padding: 8px 10px;
color: var(--textColor1);
color: var(--primary-content);
border-radius: 8px;
max-width: 135px;
width: max-content;
z-index: 1;
left: 50%;
transform: translateX(-50%);
text-align: center;
}
.tooltip.top {
bottom: calc(100% + 6px);
}
.tooltip.bottom {
top: calc(100% + 6px);
}
.tooltip.bottomLeft {
top: calc(100% + 6px);
left: -25%;
}
.tooltipContainer {
position: relative;
}

View File

@@ -1,61 +1,38 @@
import React, { useCallback, useMemo } from "react";
import React, { useMemo } from "react";
import { Item } from "@react-stately/collections";
import { Button, LinkButton } from "./button";
import { PopoverMenuTrigger } from "./PopoverMenu";
import { PopoverMenuTrigger } from "./popover/PopoverMenu";
import { Menu } from "./Menu";
import { Tooltip, TooltipTrigger } from "./Tooltip";
import { Avatar } from "./Avatar";
import { ReactComponent as UserIcon } from "./icons/User.svg";
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
import styles from "./UserMenu.module.css";
import { Item } from "@react-stately/collections";
import { Menu } from "./Menu";
import { useHistory, useLocation } from "react-router-dom";
import { useClient, useProfile } from "./ConferenceCallManagerHooks";
import { useModalTriggerState } from "./Modal";
import { ProfileModal } from "./ProfileModal";
import { Tooltip, TooltipTrigger } from "./Tooltip";
import { Avatar } from "./Avatar";
import { useLocation } from "react-router-dom";
import { Body } from "./typography/Typography";
export function UserMenu({ disableLogout }) {
export function UserMenu({
preventNavigation,
isAuthenticated,
isPasswordlessUser,
displayName,
avatarUrl,
onAction,
}) {
const location = useLocation();
const history = useHistory();
const {
isAuthenticated,
isGuest,
isPasswordlessUser,
logout,
userName,
client,
} = useClient();
const { displayName, avatarUrl } = useProfile(client);
const { modalState, modalProps } = useModalTriggerState();
const onAction = useCallback(
(value) => {
switch (value) {
case "user":
modalState.open();
break;
case "logout":
logout();
break;
case "login":
history.push("/login", { state: { from: location } });
break;
}
},
[history, location, logout, modalState]
);
const items = useMemo(() => {
const arr = [];
if (isAuthenticated && !isGuest) {
if (isAuthenticated) {
arr.push({
key: "user",
icon: UserIcon,
label: displayName || userName,
label: displayName,
});
if (isPasswordlessUser) {
if (isPasswordlessUser && !preventNavigation) {
arr.push({
key: "login",
label: "Sign In",
@@ -63,7 +40,7 @@ export function UserMenu({ disableLogout }) {
});
}
if (!isPasswordlessUser && !disableLogout) {
if (!isPasswordlessUser && !preventNavigation) {
arr.push({
key: "logout",
label: "Sign Out",
@@ -73,9 +50,9 @@ export function UserMenu({ disableLogout }) {
}
return arr;
}, [isAuthenticated, isGuest, userName, displayName]);
}, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation]);
if (isGuest || !isAuthenticated) {
if (!isAuthenticated) {
return (
<LinkButton to={{ pathname: "/login", state: { from: location } }}>
Log in
@@ -84,46 +61,32 @@ export function UserMenu({ disableLogout }) {
}
return (
<>
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger>
<Button variant="icon" className={styles.userButton}>
{isAuthenticated && !isGuest && !isPasswordlessUser ? (
<Avatar
size="sm"
src={avatarUrl}
fallback={(displayName || userName).slice(0, 1).toUpperCase()}
/>
) : (
<UserIcon />
)}
</Button>
{(props) => (
<Tooltip position="bottomLeft" {...props}>
Profile
</Tooltip>
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger placement="bottom left">
<Button variant="icon" className={styles.userButton}>
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
<Avatar
size="sm"
className={styles.avatar}
src={avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
) : (
<UserIcon />
)}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="User menu" onAction={onAction}>
{items.map(({ key, icon: Icon, label }) => (
<Item key={key} textValue={label}>
<Icon />
<span>{label}</span>
</Item>
))}
</Menu>
)}
</PopoverMenuTrigger>
{modalState.isOpen && (
<ProfileModal
client={client}
isAuthenticated={isAuthenticated}
isGuest={isGuest}
isPasswordlessUser={isPasswordlessUser}
{...modalProps}
/>
</Button>
{() => "Profile"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="User menu" onAction={onAction}>
{items.map(({ key, icon: Icon, label }) => (
<Item key={key} textValue={label} className={styles.menuItem}>
<Icon width={24} height={24} className={styles.menuIcon} />
<Body overflowEllipsis>{label}</Body>
</Item>
))}
</Menu>
)}
</>
</PopoverMenuTrigger>
);
}

View File

@@ -1,3 +1,22 @@
.userButton svg * {
fill: var(--textColor1);
.menuIcon {
width: 24px;
height: 24px;
}
.userButton svg * {
fill: var(--primary-content);
}
.avatar {
width: 24px;
height: 24px;
font-size: 12px;
}
@media (min-width: 800px) {
.avatar {
width: 32px;
height: 32px;
font-size: 15px;
}
}

49
src/UserMenuContainer.jsx Normal file
View File

@@ -0,0 +1,49 @@
import React, { useCallback } from "react";
import { useHistory, useLocation } from "react-router-dom";
import { useClient } from "./ClientContext";
import { useProfile } from "./profile/useProfile";
import { useModalTriggerState } from "./Modal";
import { ProfileModal } from "./profile/ProfileModal";
import { UserMenu } from "./UserMenu";
export function UserMenuContainer({ preventNavigation }) {
const location = useLocation();
const history = useHistory();
const { isAuthenticated, isPasswordlessUser, logout, userName, client } =
useClient();
const { displayName, avatarUrl } = useProfile(client);
const { modalState, modalProps } = useModalTriggerState();
const onAction = useCallback(
(value) => {
switch (value) {
case "user":
modalState.open();
break;
case "logout":
logout();
break;
case "login":
history.push("/login", { state: { from: location } });
break;
}
},
[history, location, logout, modalState]
);
return (
<>
<UserMenu
preventNavigation={preventNavigation}
isAuthenticated={isAuthenticated}
isPasswordlessUser={isPasswordlessUser}
avatarUrl={avatarUrl}
onAction={onAction}
displayName={
displayName || (userName ? userName.replace("@", "") : undefined)
}
/>
{modalState.isOpen && <ProfileModal client={client} {...modalProps} />}
</>
);
}

View File

@@ -1,6 +1,7 @@
.logo {
max-width: 300px;
margin: 80px 0;
height: auto;
}
.container {
@@ -64,7 +65,7 @@
}
.authLinks a {
color: #0dbd8b;
color: var(--accent);
text-decoration: none;
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");
you may not use this file except in compliance with the License.
@@ -14,37 +14,49 @@ See the License for the specific language governing permissions and
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 { ReactComponent as Logo } from "./icons/LogoLarge.svg";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Button } from "./button";
import {
useClient,
defaultHomeserver,
defaultHomeserverHost,
} from "./ConferenceCallManagerHooks";
import styles from "./LoginPage.module.css";
export function LoginPage() {
const { login } = useClient();
const [homeserver, setHomeServer] = useState(defaultHomeserver);
const usernameRef = useRef();
const passwordRef = useRef();
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { useClient } from "../ClientContext";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils";
import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle";
export const LoginPage: FC = () => {
usePageTitle("Login");
const { setClient } = useClient();
const login = useInteractiveLogin();
const homeserver = defaultHomeserver; // TODO: Make this configurable
const usernameRef = useRef<HTMLInputElement>();
const passwordRef = useRef<HTMLInputElement>();
const history = useHistory();
const location = useLocation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [error, setError] = useState<Error>();
// TODO: Handle hitting login page with authenticated client
const onSubmitLoginForm = useCallback(
(e) => {
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
login(homeserver, usernameRef.current.value, passwordRef.current.value)
.then(() => {
.then(([client, session]) => {
setClient(client, session);
if (location.state && location.state.from) {
history.push(location.state.from);
} else {
@@ -56,13 +68,13 @@ export function LoginPage() {
setLoading(false);
});
},
[login, location, history, homeserver]
[login, location, history, homeserver, setClient]
);
const homeserverHost = useMemo(() => {
try {
return new URL(homeserver).host;
} catch (_error) {
} catch (error) {
return defaultHomeserverHost;
}
}, [homeserver]);
@@ -121,4 +133,4 @@ export function LoginPage() {
</div>
</>
);
}
};

226
src/auth/RegisterPage.tsx Normal file
View File

@@ -0,0 +1,226 @@
/*
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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,
FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
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 { Button } from "../button";
import { useClient } from "../ClientContext";
import { defaultHomeserverHost } from "../matrix-utils";
import { useInteractiveRegistration } from "./useInteractiveRegistration";
import styles from "./LoginPage.module.css";
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { LoadingView } from "../FullScreenView";
import { useRecaptcha } from "./useRecaptcha";
import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle";
export const RegisterPage: FC = () => {
usePageTitle("Register");
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
useClient();
const confirmPasswordRef = useRef<HTMLInputElement>();
const history = useHistory();
const location = useLocation();
const [registering, setRegistering] = useState(false);
const [error, setError] = useState<Error>();
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const userName = data.get("userName") as string;
const password = data.get("password") as string;
const passwordConfirmation = data.get("passwordConfirmation") as string;
if (password !== passwordConfirmation) return;
const submit = async () => {
setRegistering(true);
const recaptchaResponse = await execute();
const [newClient, session] = await register(
userName,
password,
userName,
recaptchaResponse
);
if (client && isPasswordlessUser) {
// Migrate the user's rooms
for (const groupCall of client.groupCallEventHandler.groupCalls.values()) {
const roomId = groupCall.room.roomId;
try {
await newClient.joinRoom(roomId);
} catch (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()
.then(() => {
if (location.state?.from) {
history.push(location.state.from);
} else {
history.push("/");
}
})
.catch((error) => {
setError(error);
setRegistering(false);
reset();
});
},
[
register,
location,
history,
isPasswordlessUser,
reset,
execute,
client,
setClient,
]
);
useEffect(() => {
if (password && passwordConfirmation && password !== passwordConfirmation) {
confirmPasswordRef.current?.setCustomValidity("Passwords must match");
} else {
confirmPasswordRef.current?.setCustomValidity("");
}
}, [password, passwordConfirmation]);
useEffect(() => {
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
history.push("/");
}
}, [loading, history, isAuthenticated, isPasswordlessUser, registering]);
if (loading) {
return <LoadingView />;
}
return (
<>
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.formContainer}>
<Logo width="auto" height="auto" className={styles.logo} />
<h2>Create your account</h2>
<form onSubmit={onSubmitRegisterForm}>
<FieldRow>
<InputField
type="text"
name="userName"
placeholder="Username"
label="Username"
autoCorrect="off"
autoCapitalize="none"
prefix="@"
suffix={`:${defaultHomeserverHost}`}
/>
</FieldRow>
<FieldRow>
<InputField
required
name="password"
type="password"
onChange={(e) => setPassword(e.target.value)}
value={password}
placeholder="Password"
label="Password"
/>
</FieldRow>
<FieldRow>
<InputField
required
type="password"
name="passwordConfirmation"
onChange={(e) => setPasswordConfirmation(e.target.value)}
value={passwordConfirmation}
placeholder="Confirm Password"
label="Confirm Password"
ref={confirmPasswordRef}
/>
</FieldRow>
<Caption>
This site is protected by ReCAPTCHA and the Google{" "}
<Link href="https://www.google.com/policies/privacy/">
Privacy Policy
</Link>{" "}
and{" "}
<Link href="https://policies.google.com/terms">
Terms of Service
</Link>{" "}
apply.
<br />
By clicking "Register", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Caption>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={registering}>
{registering ? "Registering..." : "Register"}
</Button>
</FieldRow>
<div id={recaptchaId} />
</form>
</div>
<div className={styles.authLinks}>
<p>Already have an account?</p>
<p>
<Link to="/login">Log in</Link>
{" Or "}
<Link to="/">Access as a guest</Link>
</p>
</div>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,154 @@
/*
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 {
uniqueNamesGenerator,
adjectives,
colors,
animals,
Config,
} from "unique-names-generator";
const elements = [
"hydrogen",
"helium",
"lithium",
"beryllium",
"boron",
"carbon",
"nitrogen",
"oxygen",
"fluorine",
"neon",
"sodium",
"magnesium",
"aluminum",
"silicon",
"phosphorus",
"sulfur",
"chlorine",
"argon",
"potassium",
"calcium",
"scandium",
"titanium",
"vanadium",
"chromium",
"manganese",
"iron",
"cobalt",
"nickel",
"copper",
"zinc",
"gallium",
"germanium",
"arsenic",
"selenium",
"bromine",
"krypton",
"rubidium",
"strontium",
"yttrium",
"zirconium",
"niobium",
"molybdenum",
"technetium",
"ruthenium",
"rhodium",
"palladium",
"silver",
"cadmium",
"indium",
"tin",
"antimony",
"tellurium",
"iodine",
"xenon",
"cesium",
"barium",
"lanthanum",
"cerium",
"praseodymium",
"neodymium",
"promethium",
"samarium",
"europium",
"gadolinium",
"terbium",
"dysprosium",
"holmium",
"erbium",
"thulium",
"ytterbium",
"lutetium",
"hafnium",
"tantalum",
"wolfram",
"rhenium",
"osmium",
"iridium",
"platinum",
"gold",
"mercury",
"thallium",
"lead",
"bismuth",
"polonium",
"astatine",
"radon",
"francium",
"radium",
"actinium",
"thorium",
"protactinium",
"uranium",
"neptunium",
"plutonium",
"americium",
"curium",
"berkelium",
"californium",
"einsteinium",
"fermium",
"mendelevium",
"nobelium",
"lawrencium",
"rutherfordium",
"dubnium",
"seaborgium",
"bohrium",
"hassium",
"meitnerium",
"darmstadtium",
"roentgenium",
"copernicium",
"nihonium",
"flerovium",
"moscovium",
"livermorium",
"tennessine",
"oganesson",
];
export function generateRandomName(config: Config): string {
return uniqueNamesGenerator({
dictionaries: [colors, adjectives, animals, elements],
style: "lowerCase",
length: 3,
separator: "-",
...config,
});
}

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

@@ -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];
};

118
src/auth/useRecaptcha.ts Normal file
View File

@@ -0,0 +1,118 @@
/*
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 { randomString } from "matrix-js-sdk/src/randomstring";
declare global {
interface Window {
mxOnRecaptchaLoaded: () => void;
}
}
const RECAPTCHA_SCRIPT_URL =
"https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit";
interface RecaptchaPromiseRef {
resolve: (response: string) => void;
reject: (error: Error) => void;
}
export const useRecaptcha = (sitekey: string) => {
const [recaptchaId] = useState(() => randomString(16));
const promiseRef = useRef<RecaptchaPromiseRef>();
useEffect(() => {
if (!sitekey) return;
const onRecaptchaLoaded = () => {
if (!document.getElementById(recaptchaId)) return;
window.grecaptcha.render(recaptchaId, {
sitekey,
size: "invisible",
callback: (response: string) => promiseRef.current?.resolve(response),
// eslint-disable-next-line @typescript-eslint/naming-convention
"error-callback": () => promiseRef.current?.reject(new Error()),
});
};
if (typeof window.grecaptcha?.render === "function") {
onRecaptchaLoaded();
} else {
window.mxOnRecaptchaLoaded = onRecaptchaLoaded;
if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) {
const scriptTag = document.createElement("script") as HTMLScriptElement;
scriptTag.src = RECAPTCHA_SCRIPT_URL;
scriptTag.async = true;
document.body.appendChild(scriptTag);
}
}
}, [recaptchaId, sitekey]);
const execute = useCallback(() => {
if (!sitekey) {
return Promise.resolve(null);
}
if (!window.grecaptcha) {
console.log("Recaptcha not loaded");
return Promise.reject(new Error("Recaptcha not loaded"));
}
return new Promise((resolve, reject) => {
const observer = new MutationObserver((mutationsList) => {
for (const item of mutationsList) {
if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
reject(new Error("Recaptcha dismissed"));
observer.disconnect();
return;
}
}
});
promiseRef.current = {
resolve: (value) => {
resolve(value);
observer.disconnect();
},
reject: (error) => {
reject(error);
observer.disconnect();
},
};
window.grecaptcha.execute();
const iframe = document.querySelector<HTMLIFrameElement>(
'iframe[src*="recaptcha/api2/bframe"]'
);
if (iframe?.parentNode?.parentNode) {
observer.observe(iframe?.parentNode?.parentNode, {
attributes: true,
});
}
});
}, [sitekey]);
const reset = useCallback(() => {
window.grecaptcha?.reset();
}, []);
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 classNames from "classnames";
import styles from "./Button.module.css";
@@ -7,17 +23,24 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { Tooltip, TooltipTrigger } from "../Tooltip";
import { TooltipTrigger } from "../Tooltip";
export const variantToClassName = {
default: [styles.button],
toolbar: [styles.toolbarButton],
toolbarSecondary: [styles.toolbarButtonSecondary],
icon: [styles.iconButton],
secondary: [styles.secondary],
copy: [styles.copyButton],
secondaryCopy: [styles.secondaryCopy, styles.copyButton],
iconCopy: [styles.iconCopyButton],
secondaryHangup: [styles.secondaryHangup],
dropdown: [styles.dropdownButton],
};
export const sizeToClassName = {
@@ -34,12 +57,17 @@ export const Button = forwardRef(
iconStyle,
className,
children,
onPress,
onPressStart,
...rest
},
ref
) => {
const buttonRef = useObjectRef(ref);
const { buttonProps } = useButton(rest, buttonRef);
const { buttonProps } = useButton(
{ onPress, onPressStart, ...rest },
buttonRef
);
// TODO: react-aria's useButton hook prevents form submission via keyboard
// Remove the hack below after this is merged https://github.com/adobe/react-spectrum/pull/904
@@ -62,34 +90,23 @@ export const Button = forwardRef(
[styles.off]: off,
}
)}
{...filteredButtonProps}
{...mergeProps(rest, filteredButtonProps)}
ref={buttonRef}
>
{children}
{variant === "dropdown" && <ArrowDownIcon />}
</button>
);
}
);
export function ButtonTooltip({ className, children }) {
return (
<div className={classNames(styles.buttonTooltip, className)}>
{children}
</div>
);
}
export function MicButton({ muted, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <MuteMicIcon /> : <MicIcon />}
</Button>
{(props) => (
<Tooltip position="top" {...props}>
{muted ? "Unmute microphone" : "Mute microphone"}
</Tooltip>
)}
{() => (muted ? "Unmute microphone" : "Mute microphone")}
</TooltipTrigger>
);
}
@@ -100,11 +117,7 @@ export function VideoButton({ muted, ...rest }) {
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <DisableVideoIcon /> : <VideoIcon />}
</Button>
{(props) => (
<Tooltip position="top" {...props}>
{muted ? "Turn on camera" : "Turn off camera"}
</Tooltip>
)}
{() => (muted ? "Turn on camera" : "Turn off camera")}
</TooltipTrigger>
);
}
@@ -112,14 +125,10 @@ export function VideoButton({ muted, ...rest }) {
export function ScreenshareButton({ enabled, className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} on={enabled}>
<Button variant="toolbarSecondary" {...rest} on={enabled}>
<ScreenshareIcon />
</Button>
{(props) => (
<Tooltip position="top" {...props}>
{enabled ? "Stop sharing screen" : "Share screen"}
</Tooltip>
)}
{() => (enabled ? "Stop sharing screen" : "Share screen")}
</TooltipTrigger>
);
}
@@ -134,11 +143,29 @@ export function HangupButton({ className, ...rest }) {
>
<HangupIcon />
</Button>
{(props) => (
<Tooltip position="top" {...props}>
Leave
</Tooltip>
)}
{() => "Leave"}
</TooltipTrigger>
);
}
export function SettingsButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
<SettingsIcon />
</Button>
{() => "Settings"}
</TooltipTrigger>
);
}
export function InviteButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
<AddUserIcon />
</Button>
{() => "Invite"}
</TooltipTrigger>
);
}

View File

@@ -16,10 +16,13 @@ limitations under the License.
.button,
.toolbarButton,
.toolbarButtonSecondary,
.iconButton,
.iconCopyButton,
.secondary,
.copyButton {
.secondaryHangup,
.copyButton,
.dropdownButton {
position: relative;
display: flex;
justify-content: center;
@@ -33,6 +36,7 @@ limitations under the License.
}
.secondary,
.secondaryHangup,
.button,
.copyButton {
padding: 7px 15px;
@@ -42,91 +46,94 @@ limitations under the License.
}
.button {
color: #fff;
background-color: var(--primaryColor);
color: var(--primary-content);
background-color: var(--accent);
}
.toolbarButton {
.button:focus,
.toolbarButton:focus,
.toolbarButtonSecondary:focus,
.iconButton:focus,
.iconCopyButton:focus,
.secondary:focus,
.secondaryHangup:focus,
.copyButton:focus {
outline: auto;
}
.toolbarButton,
.toolbarButtonSecondary {
width: 50px;
height: 50px;
border-radius: 50px;
background-color: var(--bgColor2);
background-color: var(--system);
}
.toolbarButton:hover {
background-color: var(--bgColor4);
.toolbarButton:hover,
.toolbarButtonSecondary:hover {
background-color: var(--quinary-content);
}
.toolbarButton.on,
.toolbarButton.off {
background-color: #ffffff;
background-color: var(--primary-content);
}
.toolbarButtonSecondary.on {
background-color: var(--accent);
}
.iconButton:not(.stroke) svg * {
fill: #ffffff;
fill: var(--primary-content);
}
.iconButton:not(.stroke):hover svg * {
fill: #0dbd8b;
fill: var(--accent);
}
.iconButton.on:not(.stroke) svg * {
fill: #0dbd8b;
fill: var(--accent);
}
.iconButton.on.stroke svg * {
stroke: #0dbd8b;
stroke: var(--accent);
}
.hangupButton,
.hangupButton:hover {
background-color: #ff5b55;
background-color: var(--alert);
}
.toolbarButton.on svg * {
fill: #0dbd8b;
fill: var(--accent);
}
.toolbarButton.off svg * {
fill: #21262c;
}
.buttonTooltip {
display: none;
background-color: var(--bgColor2);
position: absolute;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 8px 10px;
color: var(--textColor1);
border-radius: 8px;
max-width: 135px;
width: max-content;
z-index: 1;
}
.buttonTooltip.bottomRight {
right: 0;
}
.toolbarButton:hover .buttonTooltip {
display: flex;
bottom: calc(100% + 6px);
}
.iconButton:hover .buttonTooltip {
display: flex;
top: calc(100% + 6px);
.toolbarButtonSecondary.on svg * {
fill: var(--primary-content);
}
.secondary,
.copyButton {
color: #0dbd8b;
border: 2px solid #0dbd8b;
color: var(--accent);
border: 2px solid var(--accent);
background-color: transparent;
}
.secondaryHangup {
color: var(--alert);
border: 2px solid var(--alert);
background-color: transparent;
}
.copyButton.secondaryCopy {
color: var(--primary-content);
border-color: var(--primary-content);
}
.copyButton {
width: 100%;
height: 40px;
@@ -147,12 +154,12 @@ limitations under the License.
}
.copyButton:not(.on) svg * {
fill: #0dbd8b;
fill: var(--accent);
}
.copyButton.on {
border-color: transparent;
background-color: #0dbd8b;
background-color: var(--accent);
color: white;
}
@@ -160,18 +167,41 @@ limitations under the License.
stroke: white;
}
.copyButton.secondaryCopy:not(.on) svg * {
fill: var(--primary-content);
}
.iconCopyButton svg * {
fill: var(--textColor3);
fill: var(--tertiary-content);
}
.iconCopyButton:hover svg * {
fill: #0dbd8b;
fill: var(--accent);
}
.iconCopyButton.on svg *,
.iconCopyButton.on:hover svg * {
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 {

View File

@@ -1,4 +1,20 @@
import React, { useCallback } 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 from "react";
import useClipboard from "react-use-clipboard";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import { ReactComponent as CopyIcon } from "../icons/Copy.svg";
@@ -17,7 +33,7 @@ export function CopyButton({
return (
<Button
{...rest}
variant={variant === "icon" ? "iconCopy" : "copy"}
variant={variant === "icon" ? "iconCopy" : variant || "copy"}
on={isCopied}
className={className}
onPress={setCopied}

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 { Link } from "react-router-dom";
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 "./CopyButton";
export * from "./LinkButton";

27
src/form/Form.jsx Normal file
View File

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

4
src/form/Form.module.css Normal file
View File

@@ -0,0 +1,4 @@
.form {
display: flex;
flex-direction: column;
}

92
src/home/CallList.jsx Normal file
View File

@@ -0,0 +1,92 @@
/*
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 { Link } from "react-router-dom";
import { CopyButton } from "../button";
import { Facepile } from "../Facepile";
import { Avatar } from "../Avatar";
import styles from "./CallList.module.css";
import { getRoomUrl } from "../matrix-utils";
import { Body, Caption } from "../typography/Typography";
export function CallList({ rooms, client, disableFacepile }) {
return (
<>
<div className={styles.callList}>
{rooms.map(({ roomId, roomName, avatarUrl, participants }) => (
<CallTile
key={roomId}
client={client}
name={roomName}
avatarUrl={avatarUrl}
roomId={roomId}
participants={participants}
disableFacepile={disableFacepile}
/>
))}
{rooms.length > 3 && (
<>
<div className={styles.callTileSpacer} />
<div className={styles.callTileSpacer} />
</>
)}
</div>
</>
);
}
function CallTile({
name,
avatarUrl,
roomId,
participants,
client,
disableFacepile,
}) {
return (
<div className={styles.callTile}>
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
<Avatar
size="lg"
bgKey={name}
src={avatarUrl}
fallback={name.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
<div className={styles.callInfo}>
<Body overflowEllipsis fontWeight="semiBold">
{name}
</Body>
<Caption overflowEllipsis>{getRoomUrl(roomId)}</Caption>
{participants && !disableFacepile && (
<Facepile
className={styles.facePile}
client={client}
participants={participants}
/>
)}
</div>
<div className={styles.copyButtonSpacer} />
</Link>
<CopyButton
className={styles.copyButton}
variant="icon"
value={getRoomUrl(roomId)}
/>
</div>
);
}

View File

@@ -1,8 +1,16 @@
.callTileSpacer,
.callTile {
min-width: 240px;
height: 94px;
width: 329px;
}
.callTileSpacer {
height: 0;
}
.callTile {
height: 95px;
padding: 12px;
background-color: var(--bgColor2);
background-color: var(--system);
border-radius: 8px;
overflow: hidden;
box-sizing: border-box;
@@ -14,6 +22,8 @@
display: flex;
text-decoration: none;
width: 100%;
height: 100%;
align-items: center;
}
.avatar,
@@ -26,33 +36,16 @@
flex-direction: column;
flex: 1;
padding: 0 16px;
color: var(--textColor1);
color: var(--primary-content);
min-width: 0;
}
.callInfo > * {
margin-top: 0;
margin-bottom: 8px;
}
.callInfo > :last-child {
margin-bottom: 0;
}
.callInfo h5 {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.callInfo p {
font-weight: 400;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.facePile {
margin-top: 8px;
}
.copyButtonSpacer,
@@ -68,7 +61,12 @@
}
.callList {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
display: flex;
flex-wrap: wrap;
max-width: calc((329px + 24px) * 3);
width: calc(100% - 48px);
gap: 24px;
padding: 0 24px;
justify-content: center;
margin-bottom: 24px;
}

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>
);
};

41
src/home/HomePage.jsx Normal file
View File

@@ -0,0 +1,41 @@
/*
Copyright 2021 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { useClient } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView";
import { RegisteredView } from "./RegisteredView";
import { usePageTitle } from "../usePageTitle";
export function HomePage() {
usePageTitle("Home");
const { isAuthenticated, isPasswordlessUser, loading, error, client } =
useClient();
if (loading) {
return <LoadingView />;
} else if (error) {
return <ErrorView error={error} />;
} else {
return isAuthenticated ? (
<RegisteredView isPasswordlessUser={isPasswordlessUser} client={client} />
) : (
<UnauthenticatedView />
);
}
}

View File

@@ -0,0 +1,35 @@
/*
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 { Modal, ModalContent } from "../Modal";
import { Button } from "../button";
import { FieldRow } from "../input/Input";
import styles from "./JoinExistingCallModal.module.css";
export function JoinExistingCallModal({ onJoin, ...rest }) {
return (
<Modal title="Join existing call?" isDismissable {...rest}>
<ModalContent>
<p>This call already exists, would you like to join?</p>
<FieldRow rightAlign className={styles.buttons}>
<Button onPress={rest.onClose}>No</Button>
<Button onPress={onJoin}>Yes, join call</Button>
</FieldRow>
</ModalContent>
</Modal>
);
}

141
src/home/RegisteredView.jsx Normal file
View File

@@ -0,0 +1,141 @@
/*
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 { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useGroupCallRooms } from "./useGroupCallRooms";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import commonStyles from "./common.module.css";
import styles from "./RegisteredView.module.css";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { CallList } from "./CallList";
import { UserMenuContainer } from "../UserMenuContainer";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useHistory } from "react-router-dom";
import { Title } from "../typography/Typography";
import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
export function RegisteredView({ client }) {
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const history = useHistory();
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("callName");
const ptt = callType === CallType.Radio;
async function submit() {
setError(undefined);
setLoading(true);
const roomIdOrAlias = await createRoom(client, roomName, ptt);
if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`);
}
}
submit().catch((error) => {
if (error.errcode === "M_ROOM_IN_USE") {
setExistingRoomId(roomAliasLocalpartFromRoomName(roomName));
setLoading(false);
setError(undefined);
modalState.open();
} else {
console.error(error);
setLoading(false);
setError(error);
reset();
}
});
},
[client, callType]
);
const recentRooms = useGroupCallRooms(client);
const { modalState, modalProps } = useModalTriggerState();
const [existingRoomId, setExistingRoomId] = useState();
const onJoinExistingRoom = useCallback(() => {
history.push(`/${existingRoomId}`);
}, [history, existingRoomId]);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
return (
<>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenuContainer />
</RightNav>
</Header>
<div className={commonStyles.container}>
<main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} />
<CallTypeDropdown callType={callType} setCallType={setCallType} />
<Form className={styles.form} onSubmit={onSubmit}>
<FieldRow className={styles.fieldRow}>
<InputField
id="callName"
name="callName"
label={callNameLabel}
placeholder={callNameLabel}
type="text"
required
autoComplete="off"
/>
<Button
type="submit"
size="lg"
className={styles.button}
disabled={loading}
>
{loading ? "Loading..." : "Go"}
</Button>
</FieldRow>
{error && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
</Form>
{recentRooms.length > 0 && (
<>
<Title className={styles.recentCallsTitle}>
Your recent Calls
</Title>
<CallList rooms={recentRooms} client={client} disableFacepile />
</>
)}
</main>
</div>
{modalState.isOpen && (
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
)}
</>
);
}

View File

@@ -0,0 +1,23 @@
.form {
padding: 0 24px;
justify-content: center;
max-width: 409px;
width: calc(100% - 48px);
margin-bottom: 72px;
}
.fieldRow {
margin-bottom: 24px;
}
.fieldRow:last-child {
margin-bottom: 0;
}
.button {
padding: 0 24px;
}
.recentCallsTitle {
margin-bottom: 32px;
}

View File

@@ -0,0 +1,180 @@
/*
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 { useClient } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { UserMenuContainer } from "../UserMenuContainer";
import { useHistory } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useRecaptcha } from "../auth/useRecaptcha";
import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName";
export function UnauthenticatedView() {
const { setClient } = useClient();
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const { modalState, modalProps } = useModalTriggerState();
const [onFinished, setOnFinished] = useState();
const history = useHistory();
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("callName");
const displayName = data.get("displayName");
const ptt = callType === CallType.Radio;
async function submit() {
setError(undefined);
setLoading(true);
const recaptchaResponse = await execute();
const userName = generateRandomName();
const [client, session] = await register(
userName,
randomString(16),
displayName,
recaptchaResponse,
true
);
let 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) => {
console.error(error);
setLoading(false);
setError(error);
reset();
});
},
[register, reset, execute, history, callType]
);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
return (
<>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav hideMobile>
<UserMenuContainer />
</RightNav>
</Header>
<div className={commonStyles.container}>
<main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} />
<CallTypeDropdown callType={callType} setCallType={setCallType} />
<Form className={styles.form} onSubmit={onSubmit}>
<FieldRow>
<InputField
id="callName"
name="callName"
label={callNameLabel}
placeholder={callNameLabel}
type="text"
required
autoComplete="off"
/>
</FieldRow>
<FieldRow>
<InputField
id="displayName"
name="displayName"
label="Display Name"
placeholder="Display Name"
type="text"
required
autoComplete="off"
/>
</FieldRow>
<Caption>
By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Caption>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<Button type="submit" size="lg" disabled={loading}>
{loading ? "Loading..." : "Go"}
</Button>
<div id={recaptchaId} />
</Form>
</main>
<footer className={styles.footer}>
<Body className={styles.mobileLoginLink}>
<Link color="primary" to="/login">
Login to your account
</Link>
</Body>
<Body>
Not registered yet?{" "}
<Link color="primary" to="/register">
Create an account
</Link>
</Body>
</footer>
</div>
{modalState.isOpen && (
<JoinExistingCallModal onJoin={onFinished} {...modalProps} />
)}
</>
);
}

View File

@@ -0,0 +1,31 @@
.footer {
display: flex;
flex-direction: column;
align-items: center;
padding: 28px;
}
.footer p {
margin-bottom: 0;
}
.footer .mobileLoginLink {
display: flex;
margin-bottom: 24px;
}
.form {
padding: 0 24px;
justify-content: center;
max-width: 360px;
}
.form > * + * {
margin-bottom: 24px;
}
@media (min-width: 800px) {
.mobileLoginLink {
display: none;
}
}

View File

@@ -0,0 +1,33 @@
.container {
display: flex;
min-height: calc(100% - 64px);
flex-direction: column;
justify-content: space-between;
}
.main {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
display: flex;
margin-bottom: 54px;
}
.headline {
margin-bottom: 40px;
}
@media (min-width: 800px) {
.logo {
display: none;
}
.container {
min-height: calc(100% - 76px);
}
}

View File

@@ -0,0 +1,103 @@
/*
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";
const tsCache = {};
function getLastTs(client, r) {
if (tsCache[r.roomId]) {
return tsCache[r.roomId];
}
if (!r || !r.timeline) {
const ts = Number.MAX_SAFE_INTEGER;
tsCache[r.roomId] = ts;
return ts;
}
const myUserId = client.getUserId();
if (r.getMyMembership() !== "join") {
const membershipEvent = r.currentState.getStateEvents(
"m.room.member",
myUserId
);
if (membershipEvent && !Array.isArray(membershipEvent)) {
const ts = membershipEvent.getTs();
tsCache[r.roomId] = ts;
return ts;
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i];
const ts = ev.getTs();
if (ts) {
tsCache[r.roomId] = ts;
return ts;
}
}
const ts = Number.MAX_SAFE_INTEGER;
tsCache[r.roomId] = ts;
return ts;
}
function sortRooms(client, rooms) {
return rooms.sort((a, b) => {
return getLastTs(client, b) - getLastTs(client, a);
});
}
export function useGroupCallRooms(client) {
const [rooms, setRooms] = useState([]);
useEffect(() => {
function updateRooms() {
const groupCalls = client.groupCallEventHandler.groupCalls.values();
const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room);
const sortedRooms = sortRooms(client, rooms);
const items = sortedRooms.map((room) => {
const groupCall = client.getGroupCallForRoom(room.roomId);
return {
roomId: room.getCanonicalAlias() || room.roomId,
roomName: room.name,
avatarUrl: room.getMxcAvatarUrl(),
room,
groupCall,
participants: [...groupCall.participants],
};
});
setRooms(items);
}
updateRooms();
client.on("GroupCall.incoming", updateRooms);
client.on("GroupCall.participants", updateRooms);
return () => {
client.removeListener("GroupCall.incoming", updateRooms);
client.removeListener("GroupCall.participants", updateRooms);
};
}, [client]);
return rooms;
}

View File

@@ -1,3 +1,3 @@
<svg width="21" height="24" viewBox="0 0 21 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.03125 3C4.3744 3 3.03125 4.34315 3.03125 6V13C3.03125 14.6569 4.3744 16 6.03125 16H7.07407V18C7.07407 19.6569 8.41722 21 10.0741 21H14.9683C16.6251 21 17.9683 19.6569 17.9683 18V11C17.9683 9.34315 16.6251 8 14.9683 8H13.9255V6C13.9255 4.34315 12.5823 3 10.9255 3H6.03125ZM11.9255 8V6C11.9255 5.44772 11.4777 5 10.9255 5H6.03125C5.47897 5 5.03125 5.44772 5.03125 6V13C5.03125 13.5523 5.47897 14 6.03125 14H7.07407V11C7.07407 9.34315 8.41722 8 10.0741 8H11.9255ZM9.07407 11C9.07407 10.4477 9.52179 10 10.0741 10H14.9683C15.5206 10 15.9683 10.4477 15.9683 11V18C15.9683 18.5523 15.5206 19 14.9683 19H10.0741C9.52179 19 9.07407 18.5523 9.07407 18V11Z" fill="#0DBD8B"/>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 2C2.89543 2 2 2.89543 2 4V8.66667C2 9.77124 2.89543 10.6667 4 10.6667H5.33333V12C5.33333 13.1046 6.22877 14 7.33333 14H12C13.1046 14 14 13.1046 14 12V7.33333C14 6.22876 13.1046 5.33333 12 5.33333H10.6667V4C10.6667 2.89543 9.77123 2 8.66667 2H4ZM9.33333 5.33333V4C9.33333 3.63181 9.03486 3.33333 8.66667 3.33333H4C3.63181 3.33333 3.33333 3.63181 3.33333 4V8.66667C3.33333 9.03486 3.63181 9.33333 4 9.33333H5.33333V7.33333C5.33333 6.22877 6.22876 5.33333 7.33333 5.33333H9.33333ZM6.66667 7.33333C6.66667 6.96514 6.96514 6.66667 7.33333 6.66667H12C12.3682 6.66667 12.6667 6.96514 12.6667 7.33333V12C12.6667 12.3682 12.3682 12.6667 12 12.6667H7.33333C6.96514 12.6667 6.66667 12.3682 6.66667 12V7.33333Z" fill="#8E99A4"/>
</svg>

Before

Width:  |  Height:  |  Size: 820 B

After

Width:  |  Height:  |  Size: 872 B

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

@@ -0,0 +1,4 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.64856 7.35501C2.65473 7.31601 2.67231 7.27972 2.69908 7.25069L8.40377 1.06442C8.47865 0.983217 8.60518 0.978093 8.68638 1.05297L9.8626 2.13763C9.9438 2.21251 9.94893 2.33904 9.87405 2.42024L4.16936 8.60651C4.1426 8.63554 4.10783 8.656 4.06946 8.6653L2.66781 9.00511C2.52911 9.03873 2.40084 8.92044 2.42315 8.77948L2.64856 7.35501Z" fill="white"/>
<path d="M1.75 9.44346C1.33579 9.44346 1 9.77925 1 10.1935C1 10.6077 1.33579 10.9435 1.75 10.9435L10.75 10.9435C11.1642 10.9435 11.5 10.6077 11.5 10.1935C11.5 9.77925 11.1642 9.44346 10.75 9.44346L1.75 9.44346Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 689 B

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

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.283 21.4401C17.6495 21.4401 21.9999 17.0881 21.9999 11.7196C21.9999 6.3511 17.6495 1.99908 12.283 1.99908C6.91643 1.99908 2.566 6.3511 2.566 11.7196C2.566 13.2234 2.90739 14.6476 3.51687 15.9186L2.04468 20.7049C1.80806 21.4742 2.5308 22.1936 3.29898 21.9535L8.04564 20.4696C9.32625 21.0914 10.7639 21.4401 12.283 21.4401Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -1,11 +1,17 @@
<svg width="199" height="30" viewBox="0 0 199 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="260" height="30" viewBox="0 0 260 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="15" cy="15" r="13" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 30C23.2843 30 30 23.2843 30 15C30 6.71573 23.2843 0 15 0C6.71573 0 0 6.71573 0 15C0 23.2843 6.71573 30 15 30ZM12.2579 6.98923C12.2579 6.38376 12.7497 5.89292 13.3565 5.89292C17.4687 5.89292 20.8024 9.21967 20.8024 13.3234C20.8024 13.9289 20.3106 14.4197 19.7038 14.4197C19.0971 14.4197 18.6052 13.9289 18.6052 13.3234C18.6052 10.4306 16.2553 8.08554 13.3565 8.08554C12.7497 8.08554 12.2579 7.59471 12.2579 6.98923ZM24.1066 13.3235C24.1066 12.7181 23.6148 12.2272 23.008 12.2272C22.4013 12.2272 21.9094 12.7181 21.9094 13.3235C21.9094 16.2163 19.5595 18.5614 16.6607 18.5614C16.0539 18.5614 15.5621 19.0523 15.5621 19.6577C15.5621 20.2632 16.0539 20.754 16.6607 20.754C20.7729 20.754 24.1066 17.4273 24.1066 13.3235ZM17.7601 23.011C17.7601 23.6164 17.2682 24.1073 16.6615 24.1073C12.5492 24.1073 9.21553 20.7805 9.21553 16.6768C9.21553 16.0713 9.70739 15.5805 10.3141 15.5805C10.9209 15.5805 11.4127 16.0713 11.4127 16.6768C11.4127 19.5696 13.7627 21.9146 16.6615 21.9146C17.2682 21.9146 17.7601 22.4055 17.7601 23.011ZM5.89281 16.6769C5.89281 17.2824 6.38467 17.7732 6.9914 17.7732C7.59813 17.7732 8.08999 17.2824 8.08999 16.6769C8.08999 13.7841 10.4399 11.439 13.3388 11.439C13.9455 11.439 14.4373 10.9482 14.4373 10.3427C14.4373 9.73722 13.9455 9.24639 13.3388 9.24639C9.22647 9.24639 5.89281 12.5731 5.89281 16.6769Z" fill="#0DBD8B"/>
<path d="M53.5406 17.2581H42.8052C42.9321 18.3815 43.3398 19.2783 44.0283 19.9487C44.7168 20.601 45.6227 20.9272 46.7461 20.9272C47.4889 20.9272 48.1593 20.746 48.7573 20.3836C49.3552 20.0212 49.781 19.532 50.0346 18.916H53.296C52.8611 20.3474 52.0458 21.507 50.85 22.3948C49.6722 23.2645 48.2771 23.6993 46.6645 23.6993C44.5628 23.6993 42.8596 23.0017 41.5551 21.6066C40.2686 20.2115 39.6254 18.4449 39.6254 16.3069C39.6254 14.2232 40.2777 12.4748 41.5822 11.0615C42.8868 9.64824 44.5718 8.94161 46.6374 8.94161C48.7029 8.94161 50.3698 9.63918 51.6381 11.0343C52.9246 12.4113 53.5678 14.1507 53.5678 16.2525L53.5406 17.2581ZM46.6374 11.5779C45.6227 11.5779 44.7802 11.8768 44.1098 12.4748C43.4394 13.0727 43.0227 13.8699 42.8596 14.8664H50.3608C50.2158 13.8699 49.8172 13.0727 49.1649 12.4748C48.5127 11.8768 47.6701 11.5779 46.6374 11.5779Z" fill="white"/>
<path d="M55.7934 19.1606V2.9896H59.0276V19.2149C59.0276 19.9397 59.4262 20.3021 60.2234 20.3021L60.7942 20.2749V23.346C60.4862 23.4004 60.16 23.4275 59.8158 23.4275C58.4206 23.4275 57.3969 23.0742 56.7446 22.3676C56.1105 21.661 55.7934 20.592 55.7934 19.1606Z" fill="white"/>
<path d="M75.8564 17.2581H65.121C65.2478 18.3815 65.6555 19.2783 66.344 19.9487C67.0325 20.601 67.9385 20.9272 69.0618 20.9272C69.8047 20.9272 70.4751 20.746 71.073 20.3836C71.6709 20.0212 72.0967 19.532 72.3504 18.916H75.6118C75.1769 20.3474 74.3616 21.507 73.1657 22.3948C71.988 23.2645 70.5929 23.6993 68.9803 23.6993C66.8785 23.6993 65.1754 23.0017 63.8708 21.6066C62.5844 20.2115 61.9412 18.4449 61.9412 16.3069C61.9412 14.2232 62.5935 12.4748 63.898 11.0615C65.2026 9.64824 66.8876 8.94161 68.9531 8.94161C71.0187 8.94161 72.6856 9.63918 73.9539 11.0343C75.2403 12.4113 75.8835 14.1507 75.8835 16.2525L75.8564 17.2581ZM68.9531 11.5779C67.9385 11.5779 67.096 11.8768 66.4256 12.4748C65.7552 13.0727 65.3384 13.8699 65.1754 14.8664H72.6765C72.5316 13.8699 72.133 13.0727 71.4807 12.4748C70.8284 11.8768 69.9859 11.5779 68.9531 11.5779Z" fill="white"/>
<path d="M90.448 15.2741V23.3732H87.2138V14.9208C87.2138 12.7828 86.326 11.7138 84.5504 11.7138C83.5901 11.7138 82.82 12.0218 82.2402 12.6378C81.6786 13.2539 81.3977 14.0964 81.3977 15.1654V23.3732H78.1635V9.26775H81.1531V11.143C81.4974 10.5089 82.0228 9.98344 82.7295 9.56671C83.4361 9.14998 84.3148 8.94161 85.3657 8.94161C87.3226 8.94161 88.7358 9.68448 89.6055 11.1702C90.8013 9.68448 92.3958 8.94161 94.3889 8.94161C96.0377 8.94161 97.306 9.45799 98.1938 10.4908C99.0816 11.5054 99.5255 12.8462 99.5255 14.5131V23.3732H96.2913V14.9208C96.2913 12.7828 95.4035 11.7138 93.6279 11.7138C92.6495 11.7138 91.8704 12.0309 91.2906 12.665C90.7289 13.281 90.448 14.1507 90.448 15.2741Z" fill="white"/>
<path d="M115.61 17.2581H104.874C105.001 18.3815 105.409 19.2783 106.097 19.9487C106.786 20.601 107.692 20.9272 108.815 20.9272C109.558 20.9272 110.228 20.746 110.826 20.3836C111.424 20.0212 111.85 19.532 112.104 18.916H115.365C114.93 20.3474 114.115 21.507 112.919 22.3948C111.741 23.2645 110.346 23.6993 108.734 23.6993C106.632 23.6993 104.929 23.0017 103.624 21.6066C102.338 20.2115 101.694 18.4449 101.694 16.3069C101.694 14.2232 102.347 12.4748 103.651 11.0615C104.956 9.64824 106.641 8.94161 108.706 8.94161C110.772 8.94161 112.439 9.63918 113.707 11.0343C114.994 12.4113 115.637 14.1507 115.637 16.2525L115.61 17.2581ZM108.706 11.5779C107.692 11.5779 106.849 11.8768 106.179 12.4748C105.508 13.0727 105.092 13.8699 104.929 14.8664H112.43C112.285 13.8699 111.886 13.0727 111.234 12.4748C110.582 11.8768 109.739 11.5779 108.706 11.5779Z" fill="white"/>
<path d="M120.906 9.26775V11.143C121.233 10.527 121.767 10.0106 122.51 9.59388C123.271 9.15903 124.186 8.94161 125.255 8.94161C126.922 8.94161 128.208 9.44893 129.114 10.4636C130.038 11.4782 130.5 12.8281 130.5 14.5131V23.3732H127.266V14.9208C127.266 13.9243 127.031 13.1452 126.559 12.5835C126.106 12.0037 125.409 11.7138 124.467 11.7138C123.434 11.7138 122.619 12.0218 122.021 12.6378C121.441 13.2539 121.151 14.1054 121.151 15.1926V23.3732H117.917V9.26775H120.906Z" fill="white"/>
<path d="M139.946 20.4923V23.2916C139.547 23.4004 138.985 23.4547 138.261 23.4547C135.507 23.4547 134.13 22.0686 134.13 19.2965V11.8497H131.983V9.26775H134.13V5.5987H137.364V9.26775H140V11.8497H137.364V18.9703C137.364 20.0756 137.889 20.6282 138.94 20.6282L139.946 20.4923Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 30C23.2843 30 30 23.2843 30 15C30 6.71573 23.2843 0 15 0C6.71573 0 0 6.71573 0 15C0 23.2843 6.71573 30 15 30ZM12.2579 6.98923C12.2579 6.38376 12.7497 5.89292 13.3565 5.89292C17.4687 5.89292 20.8024 9.21967 20.8024 13.3234C20.8024 13.9289 20.3106 14.4197 19.7038 14.4197C19.0971 14.4197 18.6052 13.9289 18.6052 13.3234C18.6052 10.4306 16.2553 8.08554 13.3565 8.08554C12.7497 8.08554 12.2579 7.59471 12.2579 6.98923ZM24.1066 13.3235C24.1066 12.7181 23.6148 12.2272 23.008 12.2272C22.4013 12.2272 21.9094 12.7181 21.9094 13.3235C21.9094 16.2163 19.5595 18.5614 16.6607 18.5614C16.0539 18.5614 15.5621 19.0523 15.5621 19.6577C15.5621 20.2632 16.0539 20.754 16.6607 20.754C20.7729 20.754 24.1066 17.4273 24.1066 13.3235ZM17.7601 23.011C17.7601 23.6164 17.2682 24.1073 16.6615 24.1073C12.5492 24.1073 9.21553 20.7805 9.21553 16.6768C9.21553 16.0713 9.70739 15.5805 10.3141 15.5805C10.9209 15.5805 11.4127 16.0713 11.4127 16.6768C11.4127 19.5696 13.7627 21.9146 16.6615 21.9146C17.2682 21.9146 17.7601 22.4055 17.7601 23.011ZM5.89281 16.6769C5.89281 17.2824 6.38466 17.7732 6.9914 17.7732C7.59813 17.7732 8.08999 17.2824 8.08999 16.6769C8.08999 13.7841 10.4399 11.439 13.3388 11.439C13.9455 11.439 14.4373 10.9482 14.4373 10.3427C14.4373 9.73722 13.9455 9.24639 13.3388 9.24639C9.22647 9.24639 5.89281 12.5731 5.89281 16.6769Z" fill="#0DBD8B"/>
<path d="M53.5406 17.258H42.8052C42.932 18.3814 43.3397 19.2782 44.0282 19.9486C44.7167 20.6009 45.6227 20.927 46.746 20.927C47.4889 20.927 48.1593 20.7459 48.7572 20.3835C49.3551 20.0211 49.7809 19.5319 50.0346 18.9159H53.296C52.8611 20.3472 52.0458 21.5068 50.8499 22.3947C49.6722 23.2644 48.2771 23.6992 46.6645 23.6992C44.5627 23.6992 42.8596 23.0016 41.555 21.6065C40.2686 20.2114 39.6254 18.4448 39.6254 16.3068C39.6254 14.2231 40.2776 12.4747 41.5822 11.0614C42.8867 9.64814 44.5718 8.94151 46.6373 8.94151C48.7029 8.94151 50.3698 9.63908 51.6381 11.0342C52.9245 12.4112 53.5677 14.1506 53.5677 16.2524L53.5406 17.258ZM46.6373 11.5778C45.6227 11.5778 44.7801 11.8767 44.1098 12.4747C43.4394 13.0726 43.0226 13.8698 42.8596 14.8663H50.3607C50.2158 13.8698 49.8172 13.0726 49.1649 12.4747C48.5126 11.8767 47.6701 11.5778 46.6373 11.5778Z" fill="white"/>
<path d="M55.7934 19.1605V2.9895H59.0276V19.2148C59.0276 19.9396 59.4262 20.302 60.2234 20.302L60.7941 20.2748V23.3459C60.4861 23.4003 60.16 23.4274 59.8157 23.4274C58.4206 23.4274 57.3969 23.0741 56.7446 22.3675C56.1104 21.6609 55.7934 20.5919 55.7934 19.1605Z" fill="white"/>
<path d="M75.8563 17.258H65.121C65.2478 18.3814 65.6555 19.2782 66.344 19.9486C67.0325 20.6009 67.9384 20.927 69.0618 20.927C69.8047 20.927 70.4751 20.7459 71.073 20.3835C71.6709 20.0211 72.0967 19.5319 72.3503 18.9159H75.6117C75.1769 20.3472 74.3615 21.5068 73.1657 22.3947C71.988 23.2644 70.5928 23.6992 68.9803 23.6992C66.8785 23.6992 65.1753 23.0016 63.8708 21.6065C62.5843 20.2114 61.9411 18.4448 61.9411 16.3068C61.9411 14.2231 62.5934 12.4747 63.898 11.0614C65.2025 9.64814 66.8875 8.94151 68.9531 8.94151C71.0186 8.94151 72.6855 9.63908 73.9539 11.0342C75.2403 12.4112 75.8835 14.1506 75.8835 16.2524L75.8563 17.258ZM68.9531 11.5778C67.9384 11.5778 67.0959 11.8767 66.4255 12.4747C65.7551 13.0726 65.3384 13.8698 65.1753 14.8663H72.6765C72.5315 13.8698 72.1329 13.0726 71.4806 12.4747C70.8284 11.8767 69.9859 11.5778 68.9531 11.5778Z" fill="white"/>
<path d="M90.448 15.274V23.3731H87.2138V14.9207C87.2138 12.7827 86.326 11.7137 84.5503 11.7137C83.59 11.7137 82.82 12.0217 82.2402 12.6377C81.6785 13.2538 81.3977 14.0963 81.3977 15.1653V23.3731H78.1635V9.26764H81.1531V11.1429C81.4973 10.5088 82.0228 9.98333 82.7294 9.5666C83.436 9.14987 84.3148 8.94151 85.3657 8.94151C87.3225 8.94151 88.7358 9.68438 89.6055 11.1701C90.8013 9.68438 92.3958 8.94151 94.3888 8.94151C96.0376 8.94151 97.3059 9.45789 98.1937 10.4907C99.0816 11.5053 99.5255 12.8461 99.5255 14.513V23.3731H96.2913V14.9207C96.2913 12.7827 95.4035 11.7137 93.6278 11.7137C92.6494 11.7137 91.8703 12.0308 91.2905 12.6649C90.7288 13.2809 90.448 14.1506 90.448 15.274Z" fill="white"/>
<path d="M115.61 17.258H104.874C105.001 18.3814 105.409 19.2782 106.097 19.9486C106.786 20.6009 107.692 20.927 108.815 20.927C109.558 20.927 110.228 20.7459 110.826 20.3835C111.424 20.0211 111.85 19.5319 112.104 18.9159H115.365C114.93 20.3472 114.115 21.5068 112.919 22.3947C111.741 23.2644 110.346 23.6992 108.734 23.6992C106.632 23.6992 104.929 23.0016 103.624 21.6065C102.338 20.2114 101.694 18.4448 101.694 16.3068C101.694 14.2231 102.347 12.4747 103.651 11.0614C104.956 9.64814 106.641 8.94151 108.706 8.94151C110.772 8.94151 112.439 9.63908 113.707 11.0342C114.994 12.4112 115.637 14.1506 115.637 16.2524L115.61 17.258ZM108.706 11.5778C107.692 11.5778 106.849 11.8767 106.179 12.4747C105.508 13.0726 105.092 13.8698 104.929 14.8663H112.43C112.285 13.8698 111.886 13.0726 111.234 12.4747C110.582 11.8767 109.739 11.5778 108.706 11.5778Z" fill="white"/>
<path d="M120.906 9.26764V11.1429C121.232 10.5269 121.767 10.0105 122.51 9.59378C123.271 9.15893 124.186 8.94151 125.255 8.94151C126.922 8.94151 128.208 9.44883 129.114 10.4635C130.038 11.4781 130.5 12.828 130.5 14.513V23.3731H127.266V14.9207C127.266 13.9242 127.03 13.1451 126.559 12.5834C126.106 12.0036 125.409 11.7137 124.467 11.7137C123.434 11.7137 122.619 12.0217 122.021 12.6377C121.441 13.2538 121.151 14.1053 121.151 15.1925V23.3731H117.917V9.26764H120.906Z" fill="white"/>
<path d="M139.946 20.4922V23.2915C139.547 23.4003 138.985 23.4546 138.261 23.4546C135.507 23.4546 134.13 22.0685 134.13 19.2964V11.8496H131.982V9.26764H134.13V5.5986H137.364V9.26764H140V11.8496H137.364V18.9702C137.364 20.0755 137.889 20.6281 138.94 20.6281L139.946 20.4922Z" fill="white"/>
<path d="M148.304 20.864C146.768 19.184 146 17.056 146 14.48C146 11.904 146.768 9.784 148.304 8.12C149.856 6.44 151.896 5.6 154.424 5.6C156.504 5.6 158.264 6.176 159.704 7.328C161.144 8.48 162.064 10.024 162.464 11.96H160.616C160.28 10.52 159.552 9.376 158.432 8.528C157.312 7.68 155.976 7.256 154.424 7.256C152.44 7.256 150.84 7.92 149.624 9.248C148.424 10.576 147.824 12.32 147.824 14.48C147.824 16.64 148.424 18.384 149.624 19.712C150.84 21.04 152.44 21.704 154.424 21.704C155.976 21.704 157.312 21.28 158.432 20.432C159.552 19.584 160.28 18.44 160.616 17H162.464C162.064 18.936 161.144 20.48 159.704 21.632C158.264 22.784 156.504 23.36 154.424 23.36C151.896 23.36 149.856 22.528 148.304 20.864Z" fill="white"/>
<path d="M173.63 17.192C171.438 17.192 169.942 17.24 169.142 17.336C168.358 17.416 167.782 17.552 167.414 17.744C166.758 18.112 166.43 18.704 166.43 19.52C166.43 21.088 167.358 21.872 169.214 21.872C170.638 21.872 171.726 21.552 172.478 20.912C173.246 20.272 173.63 19.416 173.63 18.344V17.192ZM169.022 23.288C167.63 23.288 166.566 22.952 165.83 22.28C165.11 21.592 164.75 20.688 164.75 19.568C164.75 18.832 164.942 18.176 165.326 17.6C165.726 17.024 166.27 16.6 166.958 16.328C167.534 16.104 168.278 15.96 169.19 15.896C170.102 15.816 171.582 15.776 173.63 15.776V14.984C173.63 12.968 172.478 11.96 170.174 11.96C168.19 11.96 167.038 12.768 166.718 14.384H165.062C165.238 13.2 165.742 12.256 166.574 11.552C167.422 10.848 168.646 10.496 170.246 10.496C171.958 10.496 173.23 10.896 174.062 11.696C174.91 12.496 175.334 13.6 175.334 15.008V23H173.702V21.224C172.854 22.6 171.294 23.288 169.022 23.288Z" fill="white"/>
<path d="M179.418 20.312V5H181.122V20.12C181.122 20.616 181.202 20.96 181.362 21.152C181.538 21.344 181.85 21.44 182.298 21.44L182.778 21.392V22.952C182.506 23 182.21 23.024 181.89 23.024C180.242 23.024 179.418 22.12 179.418 20.312Z" fill="white"/>
<path d="M185.582 20.312V5H187.286V20.12C187.286 20.616 187.366 20.96 187.526 21.152C187.702 21.344 188.014 21.44 188.462 21.44L188.942 21.392V22.952C188.67 23 188.374 23.024 188.054 23.024C186.406 23.024 185.582 22.12 185.582 20.312Z" fill="white"/>
<path d="M201 10C201 5.58172 204.582 2 209 2H252C256.418 2 260 5.58172 260 10V20C260 24.4183 256.418 28 252 28H209C204.582 28 201 24.4183 201 20V10Z" fill="#368BD6"/>
<path d="M212.076 20H216.492C218.99 20 220.215 18.7269 220.215 17.0277C220.215 15.3764 219.043 14.407 217.882 14.3484V14.2418C218.947 13.9915 219.789 13.2457 219.789 11.9194C219.789 10.2947 218.617 9.09091 216.252 9.09091H212.076V20ZM214.052 18.3487V15.1527H216.231C217.451 15.1527 218.207 15.8984 218.207 16.8732C218.207 17.7415 217.61 18.3487 216.178 18.3487H214.052ZM214.052 13.7305V10.7209H216.05C217.211 10.7209 217.813 11.3335 217.813 12.1751C217.813 13.1339 217.035 13.7305 216.007 13.7305H214.052ZM221.934 20H229.072V18.3434H223.911V15.3658H228.662V13.7092H223.911V10.7475H229.03V9.09091H221.934V20ZM230.566 10.7475H233.938V20H235.898V10.7475H239.27V9.09091H230.566V10.7475ZM241.031 20L241.931 17.31H246.032L246.938 20H249.047L245.201 9.09091H242.762L238.921 20H241.031ZM242.464 15.7227L243.939 11.3281H244.024L245.5 15.7227H242.464Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

5
src/icons/MicMuted.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.9206 1.0544C1.68141 0.815201 1.29359 0.815201 1.0544 1.0544C0.815201 1.29359 0.815201 1.68141 1.0544 1.9206L4.55 5.41621V7C4.55 8.3531 5.6469 9.45 7 9.45C7.45436 9.45 7.87983 9.32632 8.24458 9.11079L9.12938 9.99558C8.52863 10.4234 7.7937 10.675 7 10.675C4.97035 10.675 3.325 9.02965 3.325 7C3.325 6.66173 3.05077 6.3875 2.7125 6.3875C2.37423 6.3875 2.1 6.66173 2.1 7C2.1 9.49877 3.97038 11.5607 6.3875 11.8621V12.5125C6.3875 12.8508 6.66173 13.125 7 13.125C7.33827 13.125 7.6125 12.8508 7.6125 12.5125V11.8621C8.50718 11.7505 9.32696 11.3978 10.0047 10.8709L12.0794 12.9456C12.3186 13.1848 12.7064 13.1848 12.9456 12.9456C13.1848 12.7064 13.1848 12.3186 12.9456 12.0794L1.9206 1.0544Z" fill="white"/>
<path d="M10.5474 7.96338L11.5073 8.92525C11.7601 8.33424 11.9 7.68346 11.9 7C11.9 6.66173 11.6258 6.3875 11.2875 6.3875C10.9492 6.3875 10.675 6.66173 10.675 7C10.675 7.33336 10.6306 7.65634 10.5474 7.96338Z" fill="white"/>
<path d="M4.81385 2.21784L9.45 6.86366V3.325C9.45 1.9719 8.3531 0.875 7 0.875C6.04532 0.875 5.21818 1.42104 4.81385 2.21784Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,6 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="18" y="10" width="5" height="5" rx="1" fill="white"/>
<rect x="18" y="16" width="5" height="5" rx="1" fill="white"/>
<rect x="18" y="4" width="5" height="5" rx="1" fill="white"/>
<rect x="1" y="4" width="16" height="17" rx="1" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 17.8689V2.51551C4 2.06669 4.53728 1.83452 4.86986 2.13591C8.18767 5.14263 10.9111 7.48209 13.102 9.36399L13.102 9.36403C18.3295 13.8544 20.5243 15.7398 20.5243 17.8689C20.5243 19.4181 19.6538 20.0153 18.1044 20.79C16.5549 21.5648 14.4534 22 12.2621 22C10.0709 22 7.96938 21.5648 6.41992 20.79C4.87047 20.0153 4 18.9646 4 17.8689ZM12.2621 20.9673C16.2548 20.9673 19.4915 19.5801 19.4915 17.869C19.4915 16.1578 16.2548 14.7707 12.2621 14.7707C8.26947 14.7707 5.03277 16.1578 5.03277 17.869C5.03277 19.5801 8.26947 20.9673 12.2621 20.9673ZM16.2618 8.67876C16.1718 8.64549 16.1718 8.51831 16.2618 8.48504L17.84 7.90103C17.8683 7.89057 17.8906 7.86828 17.901 7.84001L18.4851 6.26174C18.5183 6.17182 18.6455 6.17182 18.6788 6.26174L19.2628 7.84001C19.2733 7.86828 19.2955 7.89057 19.3238 7.90103L20.9021 8.48504C20.992 8.51831 20.992 8.64549 20.9021 8.67876L19.3238 9.26277C19.2955 9.27323 19.2733 9.29552 19.2628 9.32379L18.6788 10.9021C18.6455 10.992 18.5183 10.992 18.4851 10.9021L17.901 9.32379C17.8906 9.29552 17.8683 9.27323 17.84 9.26277L16.2618 8.67876ZM13.2618 5.45232C13.1718 5.48559 13.1718 5.61276 13.2618 5.64604L14.0862 5.95111C14.1145 5.96157 14.1368 5.98386 14.1472 6.01213L14.4523 6.83657C14.4856 6.92649 14.6127 6.92649 14.646 6.83657L14.9511 6.01213C14.9615 5.98386 14.9838 5.96157 15.0121 5.95111L15.8365 5.64603C15.9265 5.61276 15.9265 5.48559 15.8365 5.45232L15.0121 5.14725C14.9838 5.13679 14.9615 5.1145 14.9511 5.08623L14.646 4.26178C14.6127 4.17187 14.4856 4.17187 14.4523 4.26178L14.1472 5.08623C14.1368 5.1145 14.1145 5.13679 14.0862 5.14725L13.2618 5.45232Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 354 B

After

Width:  |  Height:  |  Size: 1.7 KiB

6
src/icons/VideoMuted.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.20333 0.963373C0.474437 0.690007 0.913989 0.690007 1.1851 0.963373L11.5983 11.4633C11.8694 11.7367 11.8694 12.1799 11.5983 12.4533C11.3272 12.7267 10.8876 12.7267 10.6165 12.4533L0.20333 1.95332C-0.0677768 1.67995 -0.0677768 1.23674 0.20333 0.963373Z" fill="white"/>
<path d="M0.418261 3.63429C0.226267 3.95219 0.115674 4.32557 0.115674 4.725V9.85832C0.115674 11.0181 1.0481 11.9583 2.19831 11.9583H8.65411L0.447396 3.66596C0.437225 3.65568 0.427513 3.64511 0.418261 3.63429Z" fill="white"/>
<path d="M9.95036 4.725V8.33212L4.30219 2.625H7.86772C9.01793 2.625 9.95036 3.5652 9.95036 4.725Z" fill="white"/>
<path d="M12.8721 4.11817L11.1074 5.54167V9.04166L12.8721 10.4652C13.3266 10.8318 14 10.5055 14 9.91855V4.66478C14 4.07782 13.3266 3.7515 12.8721 4.11817Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 892 B

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021 New Vector Ltd
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,21 +20,24 @@ limitations under the License.
Therefore we define a unicode-range to load which excludes the glyphs
(to avoid having to maintain a fork of Inter). */
@import "normalize.css/normalize.css";
:root {
--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;
--primaryColor: #0dbd8b;
--bgColor1: #15191e;
--bgColor2: #21262c;
--bgColor3: #444;
--bgColor4: #394049;
--bgColor5: #8d97a5;
--textColor1: #fff;
--textColor2: #6f7882;
--textColor3: #8e99a4;
--textColor4: #a9b2bc;
--inputBorderColor: #394049;
--inputBorderColorFocused: #0086e6;
--accent: #0dbd8b;
--accent-20: #0dbd8b33;
--alert: #ff5b55;
--alert-20: #ff5b5533;
--links: #0086e6;
--primary-content: #ffffff;
--secondary-content: #a9b2bc;
--tertiary-content: #8e99a4;
--quaternary-content: #6f7882;
--quinary-content: #394049;
--system: #21262c;
--background: #15191e;
--bgColor3: #444; /* This isn't found anywhere in the designs or Compound */
}
@font-face {
@@ -118,8 +121,9 @@ limitations under the License.
}
body {
background-color: var(--bgColor1);
color: var(--textColor1);
background-color: var(--background);
color: var(--primary-content);
color-scheme: dark;
margin: 0;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
@@ -139,8 +143,46 @@ body,
flex-direction: column;
}
h1,
h2,
h3,
h4,
h5,
h6,
p,
a {
color: var(--primaryColor);
margin-top: 0;
}
/* Headline Semi Bold */
h1 {
font-weight: 600;
font-size: 32px;
line-height: 39px;
}
/* Title */
h2 {
font-weight: 600;
font-size: 24px;
line-height: 29px;
}
/* Subtitle */
h3 {
font-weight: 400;
font-size: 18px;
line-height: 22px;
}
/* Body */
p {
font-size: 15px;
line-height: 24px;
}
a {
color: var(--accent);
text-decoration: none;
}
@@ -152,8 +194,8 @@ a:active {
hr {
width: calc(100% - 24px);
border: none;
border-top: 1px solid var(--bgColor4);
color: var(--textColor2);
border-top: 1px solid var(--quinary-content);
color: var(--quaternary-content);
overflow: visible;
text-align: center;
height: 5px;

View File

@@ -0,0 +1,96 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useObjectRef } from "@react-aria/utils";
import React, { useEffect } from "react";
import { useCallback } from "react";
import { useState } from "react";
import { forwardRef } from "react";
import { Avatar } from "../Avatar";
import { Button } from "../button";
import classNames from "classnames";
import { ReactComponent as EditIcon } from "../icons/Edit.svg";
import styles from "./AvatarInputField.module.css";
export const AvatarInputField = forwardRef(
(
{ id, label, className, avatarUrl, displayName, onRemoveAvatar, ...rest },
ref
) => {
const [removed, setRemoved] = useState(false);
const [objUrl, setObjUrl] = useState(null);
const fileInputRef = useObjectRef(ref);
useEffect(() => {
const onChange = (e) => {
if (e.target.files.length > 0) {
setObjUrl(URL.createObjectURL(e.target.files[0]));
setRemoved(false);
} else {
setObjUrl(null);
}
};
fileInputRef.current.addEventListener("change", onChange);
return () => {
if (fileInputRef.current) {
fileInputRef.current.removeEventListener("change", onChange);
}
};
});
const onPressRemoveAvatar = useCallback(() => {
setRemoved(true);
onRemoveAvatar();
}, [onRemoveAvatar]);
return (
<div className={classNames(styles.avatarInputField, className)}>
<div className={styles.avatarContainer}>
<Avatar
size="xl"
src={removed ? null : objUrl || avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
<input
id={id}
accept="image/png, image/jpeg"
ref={fileInputRef}
type="file"
className={styles.fileInput}
role="button"
aria-label={label}
{...rest}
/>
<label htmlFor={id} className={styles.fileInputButton}>
<EditIcon />
</label>
</div>
{(avatarUrl || objUrl) && !removed && (
<Button
className={styles.removeButton}
variant="icon"
onPress={onPressRemoveAvatar}
>
Remove
</Button>
)}
</div>
);
}
);

View File

@@ -0,0 +1,41 @@
.avatarInputField {
display: flex;
flex-direction: column;
justify-content: center;
}
.avatarContainer {
position: relative;
margin-bottom: 8px;
}
.fileInput {
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: -1;
}
.fileInput:focus + .fileInputButton {
outline: auto;
}
.fileInputButton {
position: absolute;
bottom: 11px;
right: -4px;
background-color: var(--quinary-content);
width: 20px;
height: 20px;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.removeButton {
color: var(--accent);
}

View File

@@ -1,7 +1,23 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef } from "react";
import classNames from "classnames";
import styles from "./Input.module.css";
import { ReactComponent as CheckIcon } from "./icons/Check.svg";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
export function FieldRow({ children, rightAlign, className, ...rest }) {
return (
@@ -23,7 +39,18 @@ export function Field({ children, className, ...rest }) {
export const InputField = forwardRef(
(
{ id, label, className, type, checked, prefix, suffix, disabled, ...rest },
{
id,
label,
className,
type,
checked,
prefix,
suffix,
description,
disabled,
...rest
},
ref
) => {
return (
@@ -38,14 +65,25 @@ export const InputField = forwardRef(
)}
>
{prefix && <span>{prefix}</span>}
<input
id={id}
{...rest}
ref={ref}
type={type}
checked={checked}
disabled={disabled}
/>
{type === "textarea" ? (
<textarea
id={id}
{...rest}
ref={ref}
type={type}
disabled={disabled}
/>
) : (
<input
id={id}
{...rest}
ref={ref}
type={type}
checked={checked}
disabled={disabled}
/>
)}
<label htmlFor={id}>
{type === "checkbox" && (
<div className={styles.checkbox}>
@@ -55,6 +93,7 @@ export const InputField = forwardRef(
{label}
</label>
{suffix && <span>{suffix}</span>}
{description && <p className={styles.description}>{description}</p>}
</Field>
);
}

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