Compare commits

..

123 Commits

Author SHA1 Message Date
David Baker
810cdeeab4 Merge pull request #482 from vector-im/dbkr/fix_screenshare_crash
Fix crash on screen share
2022-07-21 11:48:55 +01:00
David Baker
075049abc4 Merge pull request #479 from vector-im/dbkr/wait_for_room
Fix 'cannot find room' error
2022-07-21 11:48:23 +01:00
David Baker
56afbe6eb1 Fix crash on screen share
Don't try to wire up audio nodes if the stream has no audio track,
'cos it'll crash.

Fixes https://github.com/vector-im/element-call/issues/421
2022-07-20 20:49:07 +01:00
David Baker
32b37ed8f0 Fix 'cannot find room' error
We weren't waiting for rooms to arrive down the sync stream after
joining them but before trying to use them.

More regression details in linked issue.

Fixes https://github.com/vector-im/element-call/issues/477
2022-07-20 16:01:29 +01:00
Šimon Brandner
6d7f52d2d6 Merge pull request #472 from vector-im/SimonBrandner/task/vs-code 2022-07-16 17:49:58 +02:00
Šimon Brandner
d77d953f84 Be more explicit in .vscode/settings.json
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-16 08:28:08 +02:00
Robin
6456a6b0c0 Merge pull request #471 from robintown/await-room-creation
Make room setup more reliable
2022-07-15 16:17:55 -04:00
Robin Townsend
996c5f86c1 Refactor to use fewer else's 2022-07-15 16:08:26 -04:00
Robin Townsend
daeecc9b68 Add a missing type 2022-07-15 13:07:19 -04:00
Robin Townsend
982398b32f Remove unnecessary complexity from createRoom
With fae4c504c9, the changes from
b4a56f6dd7 are no longer necessary.
2022-07-15 13:05:06 -04:00
Robin Townsend
fae4c504c9 Consolidate all group call creation into useLoadGroupCall
This enables us to automatically create a group call in rooms that
exist, but contain no calls.
2022-07-15 12:59:54 -04:00
Robin Townsend
b4a56f6dd7 Wait for the created room to come down sync before placing a group call 2022-07-15 11:31:52 -04:00
Šimon Brandner
034552a063 Merge pull request #469 from vector-im/SimonBrandner/task/env 2022-07-15 15:59:40 +02:00
Šimon Brandner
bb505273f4 Add .env instruction
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:32:07 +02:00
Šimon Brandner
f876df6acc Remove .env
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:30:52 +02:00
Šimon Brandner
d097223d41 Add .env to .gitignore
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:29:25 +02:00
Šimon Brandner
d01f7be58a Add .env.example
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:28:16 +02:00
David Baker
cc7a44dc17 Merge pull request #466 from vector-im/dbkr/check_indexeddb
Don't restore session unless crypto data is found
2022-07-14 13:43:21 +01:00
David Baker
873e68e1e1 Add notes from thinking through the need for storing what crypto db we use 2022-07-14 13:24:22 +01:00
David Baker
4f44a68198 Merge pull request #465 from vector-im/dbkr/display_name_url_param
Auto-register if displayName URL param is given
2022-07-14 13:20:36 +01:00
David Baker
1eab957d85 Fix typescript syntax 2022-07-14 13:11:47 +01:00
David Baker
4c145af7a3 Don't restore session unless crypto data is found
Add a check to ensure that we find crypto data in the crypto store
when we're restoring a session and otherwise abort the session restore.

This will prevent us from restoring a session and generating new keys
when there was a previous session with different keys.

***This will force a logout for all users***

See the linked issue (and the comment in code) for more detail.

Fixes https://github.com/vector-im/element-call/issues/464
2022-07-14 13:07:30 +01:00
David Baker
c1e45c4a30 Missed a file 2022-07-13 16:02:17 +01:00
David Baker
5784a005dc Auto-register if displayName URL param is given
Fixes https://github.com/vector-im/element-call/issues/442
2022-07-13 14:34:15 +01:00
David Baker
a3e4d6998f Merge pull request #463 from vector-im/dbkr/create_room_ptt
Add ptt URL param to control what mode rooms are created in
2022-07-11 14:34:42 +01:00
David Baker
32907764b3 Add ptt URL param to control what mode rooms are created in 2022-07-11 13:23:03 +01:00
David Baker
cb34b1634d Merge pull request #462 from vector-im/dbkr/bypass_lobby_in_embed_mode
Bypass lobby in embedded mode
2022-07-11 12:42:15 +01:00
David Baker
5199fd2566 Prettier 2022-07-08 21:17:27 +01:00
David Baker
b31c6c6780 Bypass lobby in embedded mode 2022-07-08 20:55:18 +01:00
David Baker
aeec2c076e Merge pull request #458 from vector-im/dbkr/fix_network_waiting_after_timeout
Fix 'waiting for network' after reaching time limit
2022-07-08 19:03:55 +01:00
David Baker
8bbce188ef Merge pull request #457 from vector-im/dbkr/yarn_upgrade_20220708
yarn upgrade
2022-07-08 19:03:34 +01:00
David Baker
dbdc010764 Updgrade postcss-preset-env
as it was complaining that it didn't work with our version of postcss
2022-07-08 17:19:13 +01:00
David Baker
a81c48cc22 Fix 'waiting for network' after reaching time limit
If you spoke for the maximum amount of time and got cut off, the
next time you tried to speak you'd just get the 'waiting for network'
state. Key repeats would cause more delayed state timeouts to queue
up.
2022-07-08 15:52:32 +01:00
David Baker
6eb77b7c2f Fix types 2022-07-08 14:56:00 +01:00
David Baker
92a50fe51d yarn upgrade
Fixes https://github.com/vector-im/element-call/issues/456 as
the various react libraries had got out of sync (also we were well
overdue a dependency update).
2022-07-08 14:26:08 +01:00
David Baker
572caf6826 Merge pull request #453 from vector-im/dbkr/fix_facepile_display
Fix facepile display issues
2022-07-08 09:59:11 +01:00
David Baker
b0c8ceb302 Merge pull request #455 from vector-im/dbkr/fix_talkover
Fix talking collision not colliding properly
2022-07-08 09:51:26 +01:00
David Baker
c9ae6532a0 Bump js-sdk 2022-07-08 09:48:40 +01:00
Timo
e5cfcb601b Merge pull request #397 from toger5/ts_button 2022-07-07 22:03:28 +02:00
David Baker
2b92bf3694 Fix talking collision not colliding properly
The code was only entering the blocked state if the user was speaking,
which often won't be the case when another person starts speaking because
we'll have pressed the button but not got the ack back from the server
yet. Add the transmitblocked flag instead so we don't enter that state
again if we've already decided we've been blocked.

We were also starting with blocked = false and so resetting it when it
shouldn't have been reset.

Also requires https://github.com/matrix-org/matrix-js-sdk/pull/2502
2022-07-07 19:42:15 +01:00
David Baker
cd42d09ea9 Fix facepile display issues
Fixes https://github.com/vector-im/element-call/issues/434 and a
separate bug where the facepile would just disappear off to the left
(because we kept increasing the size even though we capped the number
of circles at 8 plus the overflow one).
2022-07-07 14:30:28 +01:00
David Baker
c56b1c8a86 Merge pull request #452 from Johennes/feature/no-empty-labels-pt2
Prevent empty device labels in audio preview
2022-07-07 14:30:12 +01:00
Johannes Marbach
e8d99e15f7 Prevent empty device labels in audio preview
Fixes: #324
Signed-off-by: Johannes Marbach <johannesm@element.io>
2022-07-07 13:32:23 +02:00
David Baker
4dcec504ca Merge pull request #449 from Johennes/feature/no-empty-labels
Prevent empty device labels
2022-07-07 12:16:18 +01:00
Johannes Marbach
1308e52e42 Enumerate devices 2022-07-07 12:10:08 +02:00
Johannes Marbach
f6d356c5ce Prettify the thing 2022-07-07 10:31:44 +02:00
Johannes Marbach
eb2de869b8 Prevent empty device labels
Fixes: #324
Signed-off-by: Johannes Marbach <johannesm@element.io>
2022-07-07 10:21:38 +02:00
David Baker
c6030d33ca Merge pull request #448 from vector-im/dbkr/country_roads
Remove the 'Take Me Home' link in embed mode
2022-07-06 22:01:54 +01:00
David Baker
655058a7e6 Remove the 'Take Me Home' link in embed mode 2022-07-06 18:27:30 +01:00
David Baker
16d4ffbe3a Merge pull request #446 from vector-im/dbkr/fix_talking_view
Fix view when another person is talking
2022-07-06 18:08:32 +01:00
David Baker
775125c8a7 Fix view when another person is talking
Fixes https://github.com/vector-im/element-call/issues/445
2022-07-06 13:44:17 +01:00
Robin
631e63a0b5 Merge pull request #444 from robintown/wt-small
Adapt walkie-talkie layout to hide controls at small sizes
2022-07-05 16:07:55 -04:00
Robin Townsend
4cb2306de0 Make button be constrained primarily by width rather than height 2022-07-05 15:49:48 -04:00
Robin Townsend
f15ee439a9 Fix page layout 2022-07-05 15:41:57 -04:00
Robin Townsend
b9a2473d19 Adapt walkie-talkie layout to hide controls at small sizes 2022-07-05 13:47:53 -04:00
Timo K
5b58223f9d fix refs 2022-07-05 17:44:09 +02:00
Timo K
f34fd0bd00 update @react-aria/button 2022-07-05 17:42:03 +02:00
David Baker
984b02700e Merge pull request #438 from vector-im/dbkr/e2e_config_param
Add config param to disable e2e for signalling
2022-07-05 13:21:28 +01:00
David Baker
e310392800 Bump js-sdk 2022-07-05 13:19:32 +01:00
David Baker
2cc291dccd Merge pull request #441 from vector-im/dbkr/fix_ptt_button_mobile
Fix the PTT button on mobile
2022-07-05 13:16:56 +01:00
David Baker
2dcf043787 Fix the PTT button on mobile
We were using createRef() instead of useRef() in the hook, which
meant we were always creating a new ref object and never actually
getting the ref. This must have been working before the useEventTarget
stuff due to some quirk of React / hooks...
2022-07-05 11:06:32 +01:00
David Baker
6b03ae0dc3 Use the traditional syntax for not-equals
Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-04 20:32:20 +01:00
David Baker
5dd5668389 Add config param to disable e2e for signalling 2022-07-04 20:10:13 +01:00
Robin
8380894692 Merge pull request #436 from robintown/update-js-sdk
Update matrix-js-sdk
2022-07-03 16:56:00 -04:00
Robin
94f16b986a Merge pull request #435 from robintown/insecure-context
Produce a more informative error when running in an insecure context
2022-07-03 16:55:43 -04:00
Robin Townsend
2928df8b8c Update matrix-js-sdk 2022-07-03 10:52:49 -04:00
Robin Townsend
71a819fcf0 Produce a more informative error when running in an insecure context 2022-07-03 10:38:03 -04:00
Timo K
713136672a make className an optional param 2022-07-02 21:45:31 +02:00
Timo K
f1bd47be8c Merge branch 'main' into ts_button 2022-07-02 21:42:15 +02:00
Timo K
2e82960ae6 ButtonVariant ButtonSize 2022-07-02 21:20:53 +02:00
Robin
a31fcd7346 Merge pull request #433 from robintown/update-js-sdk
Update matrix-js-sdk
2022-07-01 13:55:46 -04:00
Robin Townsend
4a1a53d3ab Run prettier 2022-07-01 12:34:57 -04:00
Robin Townsend
be173a838d Update matrix-js-sdk 2022-07-01 12:08:15 -04:00
David Baker
623bd52e1f Merge pull request #420 from vector-im/dbkr/embed_mode
Add embed mode
2022-07-01 13:12:19 +01:00
David Baker
5ebdf3e878 Use has on set
Co-authored-by: Robin <robin@robin.town>
2022-07-01 13:10:51 +01:00
David Baker
761eee2cdc Remove button props / style too 2022-07-01 13:10:08 +01:00
Robin
831e49919b Merge pull request #427 from robintown/issue-templates
Add some issue templates
2022-06-29 13:03:15 -04:00
Robin Townsend
6d90586aee Add some issue templates 2022-06-29 11:10:52 -04:00
David Baker
a7f0ade83a Merge pull request #422 from vector-im/dbkr/fix_imports
Fix js-sdk imports to be from src
2022-06-29 13:47:31 +01:00
David Baker
c49e300247 Fix js-sdk imports to be from src
(For a curious definition of 'fix')
2022-06-29 10:31:17 +01:00
Timo
6d8e34762e Merge pull request #395 from toger5/ts_profile
typescript `src/profile`
2022-06-28 18:47:54 +02:00
David Baker
33461f5ac2 Merge pull request #418 from vector-im/dbkr/catch_exceptions
Catch an exception & add log line on setsinkid
2022-06-28 15:15:25 +01:00
David Baker
4e3345482f Move setsinkid inside if statement 2022-06-28 15:12:59 +01:00
David Baker
7dc6fb27ea Add embed mode
2db23e4110
from postmessage_ptt branch done in a slightly nicer way
2022-06-28 15:08:14 +01:00
Robin
5ced94755b Merge pull request #413 from robintown/update-js-sdk
Update matrix-js-sdk
2022-06-28 08:56:38 -04:00
David Baker
0ffd860fdb catch a couple of exceptions 2022-06-28 13:24:07 +01:00
Timo
05e786e3d6 Merge pull request #387 from toger5/ts_settings
typescript `src/settings`
2022-06-28 12:08:05 +02:00
Robin Townsend
122ffeeab5 Update matrix-js-sdk 2022-06-21 11:32:07 -04:00
Robin
1448eac7c1 Merge pull request #399 from robintown/chrome-spatial-aec
Make AEC work with spatial audio on Chrome
2022-06-16 10:45:23 -04:00
Robin Townsend
f2dbd5ff96 Move MediaElement interface to the global types file 2022-06-16 10:01:52 -04:00
Robin Townsend
dcae5ad5f2 Merge branch 'main' into chrome-spatial-aec 2022-06-16 09:56:27 -04:00
Robin
9bd3ade93d Merge pull request #404 from robintown/waiting-spacebar
Make the 'waiting for network' state work with spacebar
2022-06-16 09:41:49 -04:00
Robin Townsend
22dcb883b3 Fix waiting state not disappearing after the 20 second timeout 2022-06-14 23:38:40 -04:00
Robin Townsend
2e945780de Make the 'waiting for network' state work with spacebar 2022-06-14 16:53:56 -04:00
Robin
9033b688ab Merge pull request #403 from robintown/preload
Preload PTT sounds correctly
2022-06-14 14:19:47 -04:00
Robin Townsend
1d4ed6609d Preload PTT sounds correctly 2022-06-14 14:15:52 -04:00
Robin
b0269e310f Merge pull request #401 from robintown/network-waiting
Add a 'waiting for network' state to walkie-talkie mode
2022-06-14 12:14:23 -04:00
Robin Townsend
74ccf7d820 Clean up useDelayedState 2022-06-14 12:13:59 -04:00
Robin Townsend
2eae6243bb Add a comment 2022-06-14 12:10:17 -04:00
Robin Townsend
276532e2e1 Add a 'waiting for network' state to walkie-talkie mode 2022-06-14 12:00:26 -04:00
Robin Townsend
fc07dd2af9 Convert useMediaStream to TypeScript 2022-06-13 17:24:25 -04:00
Robin Townsend
989712c2d5 Fix lints 2022-06-13 13:34:45 -04:00
Robin Townsend
ee43fcc91f Make AEC work with spatial audio on Chrome 2022-06-13 13:31:44 -04:00
Timo K
18ca92cec4 js->ts 2022-06-11 23:21:20 +02:00
Timo K
dc11814695 rename files js->ts 2022-06-11 15:23:33 +02:00
Timo K
17a31e0904 typing profile folder 2022-06-11 15:14:00 +02:00
Timo K
f990530031 Merge branch 'main' into ts_profile 2022-06-11 14:36:54 +02:00
Timo K
46f1f0f8e9 remove explicit any 2022-06-11 14:32:25 +02:00
Timo K
885e933948 fixes in useMediaHandler 2022-06-11 14:29:26 +02:00
Timo K
9b2e99c559 use React.ChangeEvent in SettingsModal 2022-06-11 14:28:54 +02:00
Timo K
60ed54d6d3 change rageshake.ts
to be more similar to the matrix-js version
2022-06-11 14:28:30 +02:00
David Baker
939398b277 Merge pull request #394 from vector-im/dbkr/bump_js_sdk_chrome_audio_hang
Update js-sdk for chrome audio renderer hang fix
2022-06-10 21:06:41 +01:00
David Baker
d2c820f080 Update js-sdk for chrome audio renderer hang fix
Fixes https://github.com/vector-im/element-call/issues/267
2022-06-10 21:03:52 +01:00
David Baker
375578177b Merge pull request #391 from vector-im/dbkr/version_warning
Add warning if incompatible versions are being used
2022-06-10 15:15:09 +01:00
David Baker
eb9f2ccbaa Merge remote-tracking branch 'origin/main' into dbkr/version_warning 2022-06-10 12:11:19 +01:00
David Baker
d4b211e678 Update js-sdk version 2022-06-10 12:07:14 +01:00
David Baker
9fc4fbc3e7 Icon / styling fixes + typo
* Use icon from compound
 * Use warning colour
 * Fix capitalisation
2022-06-10 12:06:06 +01:00
David Baker
1f5ac411f6 Add warning if incompatible versionsd are being used
This will probably be overly sensitive until we start timing out
member events (ie. https://github.com/matrix-org/matrix-js-sdk/pull/2446
lands) because lots of calls might have old member events from people
who've joined previously.
2022-06-09 21:56:58 +01:00
Timo K
0aa29f775c linter 2022-06-08 17:22:46 +02:00
Timo K
a4a6105bc9 Merge branch 'main' into ts_settings 2022-06-08 16:40:51 +02:00
Timo K
23098131b8 couple of cleanups
ModalProps fixes
LogEntry interface
missing return promise
2022-06-08 16:36:22 +02:00
Timo K
190c57e853 typescript src/settings 2022-06-06 22:42:48 +02:00
Timo K
785eca7289 typescript src/profile 2022-06-06 22:33:13 +02:00
58 changed files with 5026 additions and 4347 deletions

View File

@@ -22,6 +22,7 @@
# VITE_THEME_PRIMARY_CONTENT=#ffffff
# VITE_THEME_SECONDARY_CONTENT=#a9b2bc
# VITE_THEME_TERTIARY_CONTENT=#8e99a4
# VITE_THEME_TERTIARY_CONTENT_20=#8e99a433
# VITE_THEME_QUATERNARY_CONTENT=#6f7882
# VITE_THEME_QUINARY_CONTENT=#394049
# VITE_THEME_SYSTEM=#21262c

67
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Bug report
description: Create a report to help us improve
labels: [T-Defect]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Please report security issues by email to security@matrix.org
- type: textarea
id: reproduction-steps
attributes:
label: Steps to reproduce
description: Please attach screenshots, videos or logs if you can.
placeholder: Tell us what you see!
value: |
1. Where are you starting? What can you see?
2. What do you click?
3. More steps…
validations:
required: true
- type: textarea
id: result
attributes:
label: Outcome
placeholder: Tell us what went wrong
value: |
#### What did you expect?
#### What happened instead?
validations:
required: true
- type: input
id: os
attributes:
label: Operating system
placeholder: Windows, macOS, Ubuntu, Android…
validations:
required: false
- type: input
id: browser
attributes:
label: Browser information
description: Which browser are you using? Which version?
placeholder: e.g. Chromium Version 92.0.4515.131
validations:
required: false
- type: input
id: webapp-url
attributes:
label: URL for webapp
description: Which URL are you using to access the webapp? If a private server, tell us what version of Element Call you are using.
placeholder: e.g. call.element.io
validations:
required: false
- type: dropdown
id: rageshake
attributes:
label: Will you send logs?
description: |
To send them, press the 'Submit Feedback' button and check 'Include Debug Logs'. Please link to this issue in the description field.
options:
- 'Yes'
- 'No'
validations:
required: true

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions & support
url: https://matrix.to/#/#webrtc:matrix.org
about: Please ask and answer questions here.

36
.github/ISSUE_TEMPLATE/enhancement.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Enhancement request
description: Do you have a suggestion or feature request?
labels: [T-Enhancement]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to propose a new feature or make a suggestion.
- type: textarea
id: usecase
attributes:
label: Your use case
description: What would you like to be able to do? Please feel welcome to include screenshots or mock ups.
placeholder: Tell us what you would like to do!
value: |
#### What would you like to do?
#### Why would you like to do it?
#### How would you like to achieve it?
validations:
required: true
- type: textarea
id: alternative
attributes:
label: Have you considered any alternatives?
placeholder: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
placeholder: Is there anything else you'd like to add?
validations:
required: false

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules
.DS_Store
.env
dist
dist-ssr
*.local

18
.vscode/settings.json vendored
View File

@@ -2,5 +2,21 @@
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.insertSpaces": true,
"editor.tabSize": 2
"editor.tabSize": 2,
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[javascriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[javascript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
}
}

View File

@@ -27,6 +27,7 @@ git clone https://github.com/vector-im/element-call.git
cd element-call
yarn
yarn link matrix-js-sdk
cp .env.example .env
yarn dev
```

View File

@@ -33,15 +33,16 @@
"@sentry/react": "^6.13.3",
"@sentry/tracing": "^6.13.3",
"@types/grecaptcha": "^3.0.4",
"@types/sdp-transform": "^2.4.5",
"@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#5e766978b8cf80d943f796df1067722a6a5918a7",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#984dd26a138411ef73903ff4e635f2752e0829f2",
"mermaid": "^8.13.8",
"normalize.css": "^8.0.1",
"pako": "^2.0.4",
"postcss-preset-env": "^6.7.0",
"postcss-preset-env": "^7",
"re-resizable": "^6.9.0",
"react": "^17.0.0",
"react-dom": "^17.0.0",
@@ -50,6 +51,7 @@
"react-router-dom": "^5.2.0",
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"sdp-transform": "^2.14.1",
"unique-names-generator": "^4.6.0"
},
"devDependencies": {

View File

@@ -21,4 +21,10 @@ declare global {
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
OLM_OPTIONS: Record<string, string>;
}
// TypeScript doesn't know about the experimental setSinkId method, so we
// declare it ourselves
interface MediaElement extends HTMLMediaElement {
setSinkId: (id: string) => void;
}
}

View File

@@ -71,7 +71,11 @@ type ClientProviderState = Omit<
"changePassword" | "logout" | "setClient"
> & { error?: Error };
export const ClientProvider: FC = ({ children }) => {
interface Props {
children: JSX.Element;
}
export const ClientProvider: FC<Props> = ({ children }) => {
const history = useHistory();
const [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
@@ -97,12 +101,15 @@ export const ClientProvider: FC = ({ children }) => {
const { user_id, device_id, access_token, passwordlessUser } =
session;
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
true
);
/* eslint-enable camelcase */
return { client, isPasswordlessUser: passwordlessUser };

View File

@@ -24,7 +24,11 @@ export function Facepile({
<div
className={classNames(styles.facepile, styles[size], className)}
title={participants.map((member) => member.name).join(", ")}
style={{ width: participants.length * (_size - _overlap) + _overlap }}
style={{
width:
Math.min(participants.length, max + 1) * (_size - _overlap) +
_overlap,
}}
{...rest}
>
{participants.slice(0, max).map((member, i) => {

View File

@@ -1,5 +1,5 @@
import classNames from "classnames";
import React, { useRef } from "react";
import React, { useCallback, useRef } from "react";
import { Link } from "react-router-dom";
import styles from "./Header.module.css";
import { ReactComponent as Logo } from "./icons/Logo.svg";
@@ -8,6 +8,9 @@ import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
import { useButton } from "@react-aria/button";
import { Subtitle } from "./typography/Typography";
import { Avatar } from "./Avatar";
import { IncompatibleVersionModal } from "./IncompatibleVersionModal";
import { useModalTriggerState } from "./Modal";
import { Button } from "./button";
export function Header({ children, className, ...rest }) {
return (
@@ -74,9 +77,23 @@ export function RoomHeaderInfo({ roomName, avatarUrl }) {
);
}
export function RoomSetupHeaderInfo({ roomName, avatarUrl, ...rest }) {
export function RoomSetupHeaderInfo({
roomName,
avatarUrl,
isEmbedded,
...rest
}) {
const ref = useRef();
const { buttonProps } = useButton(rest, ref);
if (isEmbedded) {
return (
<div ref={ref}>
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</div>
);
}
return (
<button className={styles.backButton} ref={ref} {...buttonProps}>
<ArrowLeftIcon width={16} height={16} />
@@ -84,3 +101,25 @@ export function RoomSetupHeaderInfo({ roomName, avatarUrl, ...rest }) {
</button>
);
}
export function VersionMismatchWarning({ users, room }) {
const { modalState, modalProps } = useModalTriggerState();
const onDetailsClick = useCallback(() => {
modalState.open();
}, [modalState]);
if (users.size === 0) return null;
return (
<span className={styles.versionMismatchWarning}>
Incomaptible versions!
<Button variant="link" onClick={onDetailsClick}>
Details
</Button>
{modalState.isOpen && (
<IncompatibleVersionModal userIds={users} room={room} {...modalProps} />
)}
</span>
);
}

View File

@@ -104,6 +104,24 @@
flex-shrink: 0;
}
.versionMismatchWarning {
padding-left: 15px;
}
.versionMismatchWarning::before {
content: "";
display: inline-block;
position: relative;
top: 1px;
width: 16px;
height: 16px;
mask-image: url("./icons/AlertTriangleFilled.svg");
mask-repeat: no-repeat;
mask-size: contain;
background-color: var(--alert);
padding-right: 5px;
}
@media (min-width: 800px) {
.headerLogo,
.roomAvatar,

View File

@@ -0,0 +1,48 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from "matrix-js-sdk";
import React from "react";
import { Modal, ModalContent } from "./Modal";
import { Body } from "./typography/Typography";
interface Props {
userIds: Set<string>;
room: Room;
}
export const IncompatibleVersionModal: React.FC<Props> = ({
userIds,
room,
...rest
}) => {
const userLis = Array.from(userIds).map((u) => (
<li>{room.getMember(u).name}</li>
));
return (
<Modal title="Incompatible Versions" isDismissable {...rest}>
<ModalContent>
<Body>
Other users are trying to join this call from incompatible versions.
These users should ensure that they have refreshed their browsers:
<ul>{userLis}</ul>
</Body>
</ModalContent>
</Modal>
);
};

View File

@@ -19,7 +19,6 @@ import {
adjectives,
colors,
animals,
Config,
} from "unique-names-generator";
const elements = [
@@ -143,12 +142,11 @@ const elements = [
"oganesson",
];
export function generateRandomName(config: Config): string {
export function generateRandomName(): string {
return uniqueNamesGenerator({
dictionaries: [colors, adjectives, animals, elements],
style: "lowerCase",
length: 3,
separator: "-",
...config,
});
}

View File

@@ -57,12 +57,15 @@ export const useInteractiveLogin = () =>
passwordlessUser: false,
};
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false
);
/* eslint-enable camelcase */
return [client, session];

View File

@@ -90,12 +90,15 @@ export const useInteractiveRegistration = (): [
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,
});
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false
);
await client.setDisplayName(displayName);

View File

@@ -0,0 +1,59 @@
/*
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 { randomString } from "matrix-js-sdk/src/randomstring";
import { useClient } from "../ClientContext";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { generateRandomName } from "../auth/generateRandomName";
import { useRecaptcha } from "../auth/useRecaptcha";
export interface UseRegisterPasswordlessUserType {
privacyPolicyUrl: string;
registerPasswordlessUser: (displayName: string) => Promise<void>;
recaptchaId: string;
}
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
const { setClient } = useClient();
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const registerPasswordlessUser = useCallback(
async (displayName: string) => {
try {
const recaptchaResponse = await execute();
const userName = generateRandomName();
const [client, session] = await register(
userName,
randomString(16),
displayName,
recaptchaResponse,
true
);
setClient(client, session);
} catch (e) {
reset();
throw e;
}
},
[execute, reset, register, setClient]
);
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
}

View File

@@ -207,3 +207,10 @@ limitations under the License.
.lg {
height: 40px;
}
.linkButton {
background-color: transparent;
border: none;
color: var(--accent);
cursor: pointer;
}

View File

@@ -13,9 +13,12 @@ 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 { PressEvent } from "@react-types/shared";
import classNames from "classnames";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import styles from "./Button.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as MuteMicIcon } from "../icons/MuteMic.svg";
@@ -26,10 +29,21 @@ 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 { TooltipTrigger } from "../Tooltip";
export type ButtonVariant =
| "default"
| "toolbar"
| "toolbarSecondary"
| "icon"
| "secondary"
| "copy"
| "secondaryCopy"
| "iconCopy"
| "secondaryHangup"
| "dropdown"
| "link";
export const variantToClassName = {
default: [styles.button],
toolbar: [styles.toolbarButton],
@@ -41,13 +55,27 @@ export const variantToClassName = {
iconCopy: [styles.iconCopyButton],
secondaryHangup: [styles.secondaryHangup],
dropdown: [styles.dropdownButton],
link: [styles.linkButton],
};
export const sizeToClassName = {
export type ButtonSize = "lg";
export const sizeToClassName: { lg: string[] } = {
lg: [styles.lg],
};
export const Button = forwardRef(
interface Props {
variant: ButtonVariant;
size: ButtonSize;
on: () => void;
off: () => void;
iconStyle: string;
className: string;
children: Element[];
onPress: (e: PressEvent) => void;
onPressStart: (e: PressEvent) => void;
[index: string]: unknown;
}
export const Button = forwardRef<HTMLButtonElement, Props>(
(
{
variant = "default",
@@ -63,7 +91,7 @@ export const Button = forwardRef(
},
ref
) => {
const buttonRef = useObjectRef(ref);
const buttonRef = useObjectRef<HTMLButtonElement>(ref);
const { buttonProps } = useButton(
{ onPress, onPressStart, ...rest },
buttonRef
@@ -74,7 +102,7 @@ export const Button = forwardRef(
let filteredButtonProps = buttonProps;
if (rest.type === "submit" && !rest.onPress) {
const { onKeyDown, onKeyUp, ...filtered } = buttonProps;
const { ...filtered } = buttonProps;
filteredButtonProps = filtered;
}
@@ -93,14 +121,22 @@ export const Button = forwardRef(
{...mergeProps(rest, filteredButtonProps)}
ref={buttonRef}
>
{children}
{variant === "dropdown" && <ArrowDownIcon />}
<>
{children}
{variant === "dropdown" && <ArrowDownIcon />}
</>
</button>
);
}
);
export function MicButton({ muted, ...rest }) {
export function MicButton({
muted,
...rest
}: {
muted: boolean;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
@@ -111,7 +147,13 @@ export function MicButton({ muted, ...rest }) {
);
}
export function VideoButton({ muted, ...rest }) {
export function VideoButton({
muted,
...rest
}: {
muted: boolean;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
@@ -122,7 +164,15 @@ export function VideoButton({ muted, ...rest }) {
);
}
export function ScreenshareButton({ enabled, className, ...rest }) {
export function ScreenshareButton({
enabled,
className,
...rest
}: {
enabled: boolean;
className?: string;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button variant="toolbarSecondary" {...rest} on={enabled}>
@@ -133,7 +183,13 @@ export function ScreenshareButton({ enabled, className, ...rest }) {
);
}
export function HangupButton({ className, ...rest }) {
export function HangupButton({
className,
...rest
}: {
className?: string;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button
@@ -148,7 +204,13 @@ export function HangupButton({ className, ...rest }) {
);
}
export function SettingsButton({ className, ...rest }) {
export function SettingsButton({
className,
...rest
}: {
className?: string;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
@@ -159,7 +221,13 @@ export function SettingsButton({ className, ...rest }) {
);
}
export function InviteButton({ className, ...rest }) {
export function InviteButton({
className,
...rest
}: {
className?: string;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>

View File

@@ -16,10 +16,18 @@ 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";
import { Button } from "./Button";
import { Button, ButtonVariant } from "./Button";
interface Props {
value: string;
children: JSX.Element;
className: string;
variant: ButtonVariant;
copiedMessage: string;
}
export function CopyButton({
value,
children,
@@ -27,7 +35,7 @@ export function CopyButton({
variant,
copiedMessage,
...rest
}) {
}: Props) {
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
return (

View File

@@ -17,9 +17,28 @@ limitations under the License.
import React from "react";
import { Link } from "react-router-dom";
import classNames from "classnames";
import { variantToClassName, sizeToClassName } from "./Button";
export function LinkButton({ className, variant, size, children, ...rest }) {
import {
variantToClassName,
sizeToClassName,
ButtonVariant,
ButtonSize,
} from "./Button";
interface Props {
className: string;
variant: ButtonVariant;
size: ButtonSize;
children: JSX.Element;
[index: string]: unknown;
}
export function LinkButton({
className,
variant,
size,
children,
...rest
}: Props) {
return (
<Link
className={classNames(

View File

@@ -47,7 +47,7 @@ export function RegisteredView({ client }) {
setError(undefined);
setLoading(true);
const roomIdOrAlias = await createRoom(client, roomName, ptt);
const [roomIdOrAlias] = await createRoom(client, roomName, ptt);
if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`);

View File

@@ -70,7 +70,7 @@ export function UnauthenticatedView() {
let roomIdOrAlias;
try {
roomIdOrAlias = await createRoom(client, roomName, ptt);
[roomIdOrAlias] = await createRoom(client, roomName, ptt);
} catch (error) {
if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => () => {

View File

@@ -0,0 +1,3 @@
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.47012 18H17.5301C19.0701 18 20.0301 16.33 19.2601 15L11.7301 1.98999C10.9601 0.659993 9.04012 0.659993 8.27012 1.98999L0.740121 15C-0.0298788 16.33 0.930121 18 2.47012 18ZM10.0001 11C9.45012 11 9.00012 10.55 9.00012 9.99999V7.99999C9.00012 7.44999 9.45012 6.99999 10.0001 6.99999C10.5501 6.99999 11.0001 7.44999 11.0001 7.99999V9.99999C11.0001 10.55 10.5501 11 10.0001 11ZM11.0001 15H9.00012V13H11.0001V15Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 540 B

View File

@@ -33,6 +33,7 @@ limitations under the License.
--primary-content: #ffffff;
--secondary-content: #a9b2bc;
--tertiary-content: #8e99a4;
--tertiary-content-20: #8e99a433;
--quaternary-content: #6f7882;
--quinary-content: #394049;
--system: #21262c;

View File

@@ -36,6 +36,14 @@ initRageshake();
console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`);
if (!window.isSecureContext) {
throw new Error(
"This app cannot run in an insecure context. To fix this, access the app " +
"via a local loopback address, or serve it over HTTPS.\n" +
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts"
);
}
if (import.meta.env.VITE_CUSTOM_THEME) {
const style = document.documentElement.style;
style.setProperty("--accent", import.meta.env.VITE_THEME_ACCENT as string);
@@ -61,6 +69,10 @@ if (import.meta.env.VITE_CUSTOM_THEME) {
"--tertiary-content",
import.meta.env.VITE_THEME_TERTIARY_CONTENT as string
);
style.setProperty(
"--tertiary-content-20",
import.meta.env.VITE_THEME_TERTIARY_CONTENT_20 as string
);
style.setProperty(
"--quaternary-content",
import.meta.env.VITE_THEME_QUATERNARY_CONTENT as string

View File

@@ -1,18 +1,16 @@
import Olm from "@matrix-org/olm";
import olmWasmPath from "@matrix-org/olm/olm.wasm?url";
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
import { WebStorageSessionStore } from "matrix-js-sdk/src/store/session/webstorage";
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import { ICreateClientOpts } from "matrix-js-sdk/src/matrix";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { logger } from "matrix-js-sdk/src/logger";
import IndexedDBWorker from "./IndexedDBWorker?worker";
@@ -22,6 +20,19 @@ export const defaultHomeserver =
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
export class CryptoStoreIntegrityError extends Error {
constructor() {
super("Crypto store data was expected, but none was found");
}
}
const SYNC_STORE_NAME = "element-call-sync";
// Note that the crypto store name has changed from previous versions
// deliberately in order to force a logout for all users due to
// https://github.com/vector-im/element-call/issues/464
// (It's a good opportunity to make the database names consistent.)
const CRYPTO_STORE_NAME = "element-call-crypto";
function waitForSync(client: MatrixClient) {
return new Promise<void>((resolve, reject) => {
const onSync = (
@@ -41,8 +52,18 @@ function waitForSync(client: MatrixClient) {
});
}
/**
* Initialises and returns a new Matrix Client
* If true is passed for the 'restore' parameter, a check will be made
* to ensure that corresponding crypto data is stored and recovered.
* If the check fails, CryptoStoreIntegrityError will be thrown.
* @param clientOptions Object of options passed through to the client
* @param restore Whether the session is being restored from storage
* @returns The MatrixClient instance
*/
export async function initClient(
clientOptions: ICreateClientOpts
clientOptions: ICreateClientOpts,
restore: boolean
): Promise<MatrixClient> {
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
window.OLM_OPTIONS = {};
@@ -59,21 +80,64 @@ export async function initClient(
if (indexedDB && localStorage && !import.meta.env.DEV) {
storeOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage: window.localStorage,
dbName: "element-call-sync",
localStorage,
dbName: SYNC_STORE_NAME,
workerFactory: () => new IndexedDBWorker(),
});
} else if (localStorage) {
storeOpts.store = new MemoryStore({ localStorage });
}
if (localStorage) {
storeOpts.sessionStore = new WebStorageSessionStore(localStorage);
// Check whether we have crypto data store. If we are restoring a session
// from storage then we will have started the crypto store and therefore
// have generated keys for that device, so if we can't recover those keys,
// we must not continue or we'll generate new keys and anyone who saw our
// previous keys will not accept our new key.
// It's worth mentioning here that if support for indexeddb or localstorage
// appears or disappears between sessions (it happens) then the failure mode
// here will be that we'll try a different store, not find crypto data and
// fail to restore the session. An alternative would be to continue using
// whatever we were using before, but that could be confusing since you could
// enable indexeddb and but the app would still not be using it.
if (restore) {
if (indexedDB) {
const cryptoStoreExists = await IndexedDBCryptoStore.exists(
indexedDB,
CRYPTO_STORE_NAME
);
if (!cryptoStoreExists) throw new CryptoStoreIntegrityError();
} else if (localStorage) {
if (!LocalStorageCryptoStore.exists(localStorage))
throw new CryptoStoreIntegrityError();
} else {
// if we get here then we're using the memory store, which cannot
// possibly have remembered a session, so it's an error.
throw new CryptoStoreIntegrityError();
}
}
if (indexedDB) {
storeOpts.cryptoStore = new IndexedDBCryptoStore(
indexedDB,
"matrix-js-sdk:crypto"
CRYPTO_STORE_NAME
);
} else if (localStorage) {
storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
} else {
storeOpts.cryptoStore = new MemoryCryptoStore();
}
// XXX: we read from the URL search params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const params = new URLSearchParams(window.location.search);
// disable e2e only if enableE2e=false is given
const enableE2e = params.get("enableE2e") !== "false";
if (!enableE2e) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
const client = createClient({
@@ -83,6 +147,7 @@ export async function initClient(
// Use a relatively low timeout for API calls: this is a realtime application
// so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000,
useE2eForGroupCall: enableE2e,
});
try {
@@ -154,10 +219,9 @@ export function isLocalRoomId(roomId: string): boolean {
export async function createRoom(
client: MatrixClient,
name: string,
isPtt = false
): Promise<string> {
const createRoomResult = await client.createRoom({
name: string
): Promise<[string, string]> {
const result = await client.createRoom({
visibility: Visibility.Private,
preset: Preset.PublicChat,
name,
@@ -187,16 +251,7 @@ export async function createRoom(
},
});
console.log(`Creating ${isPtt ? "PTT" : "video"} group call room`);
await client.createGroupCall(
createRoomResult.room_id,
isPtt ? GroupCallType.Voice : GroupCallType.Video,
isPtt,
GroupCallIntent.Prompt
);
return fullAliasFromRoomName(name, client);
return [fullAliasFromRoomName(name, client), result.room_id];
}
export function getRoomUrl(roomId: string): string {

View File

@@ -15,6 +15,8 @@ limitations under the License.
*/
import React, { useCallback, useEffect, useState } from "react";
import { MatrixClient } from "matrix-js-sdk";
import { Button } from "../button";
import { useProfile } from "./useProfile";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
@@ -22,7 +24,12 @@ import { Modal, ModalContent } from "../Modal";
import { AvatarInputField } from "../input/AvatarInputField";
import styles from "./ProfileModal.module.css";
export function ProfileModal({ client, ...rest }) {
interface Props {
client: MatrixClient;
onClose: () => {};
[rest: string]: unknown;
}
export function ProfileModal({ client, ...rest }: Props) {
const { onClose } = rest;
const {
success,
@@ -50,13 +57,20 @@ export function ProfileModal({ client, ...rest }) {
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const displayName = data.get("displayName");
const avatar = data.get("avatar");
const displayNameDataEntry = data.get("displayName");
const avatar: File | string = data.get("avatar");
const avatarSize =
typeof avatar == "string" ? avatar.length : avatar.size;
const displayName =
typeof displayNameDataEntry == "string"
? displayNameDataEntry
: displayNameDataEntry.name;
saveProfile({
displayName,
avatar: avatar && avatar.size > 0 ? avatar : undefined,
removeAvatar: removeAvatar && (!avatar || avatar.size === 0),
avatar: avatar && avatarSize > 0 ? avatar : undefined,
removeAvatar: removeAvatar && (!avatar || avatarSize === 0),
});
},
[saveProfile, removeAvatar]

View File

@@ -14,11 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { User, UserEvent } from "matrix-js-sdk/src/models/user";
import { FileType } from "matrix-js-sdk/src/http-api";
import { useState, useCallback, useEffect } from "react";
export function useProfile(client) {
interface ProfileLoadState {
success?: boolean;
loading?: boolean;
displayName: string;
avatarUrl: string;
error?: Error;
}
type ProfileSaveCallback = ({
displayName,
avatar,
removeAvatar,
}: {
displayName: string;
avatar: FileType;
removeAvatar: boolean;
}) => Promise<void>;
export function useProfile(client: MatrixClient) {
const [{ loading, displayName, avatarUrl, error, success }, setState] =
useState(() => {
useState<ProfileLoadState>(() => {
const user = client?.getUser(client.getUserId());
return {
@@ -31,7 +53,10 @@ export function useProfile(client) {
});
useEffect(() => {
const onChangeUser = (_event, { displayName, avatarUrl }) => {
const onChangeUser = (
_event: MatrixEvent,
{ displayName, avatarUrl }: User
) => {
setState({
success: false,
loading: false,
@@ -41,24 +66,24 @@ export function useProfile(client) {
});
};
let user;
let user: User;
if (client) {
const userId = client.getUserId();
user = client.getUser(userId);
user.on("User.displayName", onChangeUser);
user.on("User.avatarUrl", onChangeUser);
user.on(UserEvent.DisplayName, onChangeUser);
user.on(UserEvent.AvatarUrl, onChangeUser);
}
return () => {
if (user) {
user.removeListener("User.displayName", onChangeUser);
user.removeListener("User.avatarUrl", onChangeUser);
user.removeListener(UserEvent.DisplayName, onChangeUser);
user.removeListener(UserEvent.AvatarUrl, onChangeUser);
}
};
}, [client]);
const saveProfile = useCallback(
const saveProfile = useCallback<ProfileSaveCallback>(
async ({ displayName, avatar, removeAvatar }) => {
if (client) {
setState((prev) => ({
@@ -71,7 +96,7 @@ export function useProfile(client) {
try {
await client.setDisplayName(displayName);
let mxcAvatarUrl;
let mxcAvatarUrl: string;
if (removeAvatar) {
await client.setAvatarUrl("");
@@ -87,11 +112,11 @@ export function useProfile(client) {
loading: false,
success: true,
}));
} catch (error) {
} catch (error: unknown) {
setState((prev) => ({
...prev,
loading: false,
error,
error: error instanceof Error ? error : Error(error as string),
success: false,
}));
}
@@ -102,5 +127,12 @@ export function useProfile(client) {
[client]
);
return { loading, error, displayName, avatarUrl, saveProfile, success };
return {
loading,
error,
displayName,
avatarUrl,
saveProfile,
success,
};
}

View File

@@ -53,8 +53,12 @@ export function AudioPreview({
onSelectionChange={setAudioInput}
className={styles.inputField}
>
{audioInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{audioInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Microphone ${index + 1}`}
</Item>
))}
</SelectInput>
{audioOutputs.length > 0 && (
@@ -64,8 +68,12 @@ export function AudioPreview({
onSelectionChange={setAudioOutput}
className={styles.inputField}
>
{audioOutputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{audioOutputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Speaker ${index + 1}`}
</Item>
))}
</SelectInput>
)}

View File

@@ -19,12 +19,18 @@ import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { usePageTitle } from "../usePageTitle";
export function GroupCallLoader({ client, roomId, viaServers, children }) {
export function GroupCallLoader({
client,
roomId,
viaServers,
createPtt,
children,
}) {
const { loading, error, groupCall } = useLoadGroupCall(
client,
roomId,
viaServers,
true
createPtt
);
usePageTitle(groupCall ? groupCall.room.name : "Loading...");

View File

@@ -30,6 +30,7 @@ import { useLocationNavigation } from "../useLocationNavigation";
export function GroupCallView({
client,
isPasswordlessUser,
isEmbedded,
roomId,
groupCall,
}) {
@@ -53,13 +54,17 @@ export function GroupCallView({
screenshareFeeds,
hasLocalParticipant,
participants,
unencryptedEventsFromUsers,
} = useGroupCall(groupCall);
const avatarUrl = useRoomAvatar(groupCall.room);
useEffect(() => {
window.groupCall = groupCall;
}, [groupCall]);
// In embedded mode, bypass the lobby and just enter the call straight away
if (isEmbedded) groupCall.enter();
}, [groupCall, isEmbedded]);
useSentryGroupCallHandler(groupCall);
@@ -91,6 +96,7 @@ export function GroupCallView({
participants={participants}
userMediaFeeds={userMediaFeeds}
onLeave={onLeave}
isEmbedded={isEmbedded}
/>
);
} else {
@@ -112,6 +118,7 @@ export function GroupCallView({
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
roomId={roomId}
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
/>
);
}
@@ -124,23 +131,32 @@ export function GroupCallView({
} else if (left) {
return <CallEndedView client={client} />;
} else {
return (
<LobbyView
client={client}
groupCall={groupCall}
hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
state={state}
onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed}
onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
roomId={roomId}
/>
);
if (isEmbedded) {
return (
<FullScreenView>
<h1>Loading room...</h1>
</FullScreenView>
);
} else {
return (
<LobbyView
client={client}
groupCall={groupCall}
hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
state={state}
onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed}
onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
roomId={roomId}
isEmbedded={isEmbedded}
/>
);
}
}
}

View File

@@ -22,7 +22,13 @@ import {
VideoButton,
ScreenshareButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import {
Header,
LeftNav,
RightNav,
RoomHeaderInfo,
VersionMismatchWarning,
} from "../Header";
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
import { GroupCallInspector } from "./GroupCallInspector";
@@ -36,8 +42,9 @@ import { usePreventScroll } from "@react-aria/overlays";
import { useMediaHandler } from "../settings/useMediaHandler";
import { useShowInspector } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { useAudioContext } from "../video-grid/useMediaStream";
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
// or with getUsermedia and getDisplaymedia being used within the same session.
// For now we can disable screensharing in Safari.
@@ -59,16 +66,15 @@ export function InCallView({
isScreensharing,
screenshareFeeds,
roomId,
unencryptedEventsFromUsers,
}) {
usePreventScroll();
const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0);
const [audioContext, audioDestination, audioRef] = useAudioContext();
const { audioOutput } = useMediaHandler();
const [showInspector] = useShowInspector();
const audioContext = useRef();
if (!audioContext.current) audioContext.current = new AudioContext();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
@@ -132,9 +138,14 @@ export function InCallView({
return (
<div className={styles.inRoom}>
<audio ref={audioRef} />
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
<VersionMismatchWarning
users={unencryptedEventsFromUsers}
room={groupCall.room}
/>
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
@@ -154,7 +165,8 @@ export function InCallView({
getAvatar={renderAvatar}
showName={items.length > 2 || item.focused}
audioOutputDevice={audioOutput}
audioContext={audioContext.current}
audioContext={audioContext}
audioDestination={audioDestination}
disableSpeakingIndicator={items.length < 3}
{...rest}
/>

View File

@@ -42,6 +42,7 @@ export function LobbyView({
toggleLocalVideoMuted,
toggleMicrophoneMuted,
roomId,
isEmbedded,
}) {
const { stream } = useCallFeed(localCallFeed);
const {
@@ -122,11 +123,13 @@ export function LobbyView({
Copy call link and join later
</CopyButton>
</div>
<Body className={styles.joinRoomFooter}>
<Link color="primary" to="/">
Take me Home
</Link>
</Body>
{!isEmbedded && (
<Body className={styles.joinRoomFooter}>
<Link color="primary" to="/">
Take me Home
</Link>
</Body>
)}
</div>
</div>
);

View File

@@ -1,22 +1,39 @@
.pttButton {
width: 100vw;
height: 100vh;
max-height: 232px;
max-width: 232px;
aspect-ratio: 1;
max-height: min(232px, calc(100vh - 16px));
max-width: min(232px, calc(100vw - 16px));
border-radius: 116px;
color: var(--primary-content);
border: 6px solid var(--accent);
background-color: #21262c;
position: relative;
padding: 0;
margin: 4px;
cursor: pointer;
}
.micIcon {
max-height: 50%;
}
.avatar {
/* Remove explicit size to allow avatar to scale with the button */
width: 100% !important;
height: 100% !important;
}
.talking {
background-color: var(--accent);
cursor: unset;
}
.networkWaiting {
background-color: var(--tertiary-content);
border-color: var(--tertiary-content);
cursor: unset;
}
.error {
background-color: var(--alert);
border-color: var(--alert);

View File

@@ -14,15 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState, createRef } from "react";
import React, { useCallback, useState, useRef } from "react";
import classNames from "classnames";
import { useSpring, animated } from "@react-spring/web";
import styles from "./PTTButton.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { useEventTarget } from "../useEvents";
import { Avatar } from "../Avatar";
interface Props {
enabled: boolean;
showTalkOverError: boolean;
activeSpeakerUserId: string;
activeSpeakerDisplayName: string;
@@ -32,15 +34,13 @@ interface Props {
size: number;
startTalking: () => void;
stopTalking: () => void;
}
interface State {
isHeld: boolean;
// If the button is being pressed by touch, the ID of that touch
activeTouchID: number | null;
networkWaiting: boolean;
enqueueNetworkWaiting: (value: boolean, delay: number) => void;
setNetworkWaiting: (value: boolean) => void;
}
export const PTTButton: React.FC<Props> = ({
enabled,
showTalkOverError,
activeSpeakerUserId,
activeSpeakerDisplayName,
@@ -50,89 +50,114 @@ export const PTTButton: React.FC<Props> = ({
size,
startTalking,
stopTalking,
networkWaiting,
enqueueNetworkWaiting,
setNetworkWaiting,
}) => {
const buttonRef = createRef<HTMLButtonElement>();
const buttonRef = useRef<HTMLButtonElement>();
const [{ isHeld, activeTouchID }, setState] = useState<State>({
isHeld: false,
activeTouchID: null,
});
const onWindowMouseUp = useCallback(
(e) => {
if (isHeld) stopTalking();
setState({ isHeld: false, activeTouchID: null });
},
[isHeld, setState, stopTalking]
);
const [activeTouchId, setActiveTouchId] = useState<number | null>(null);
const [buttonHeld, setButtonHeld] = useState(false);
const onWindowTouchEnd = useCallback(
(e: TouchEvent) => {
// ignore any ended touches that weren't the one pressing the
// button (bafflingly the TouchList isn't an iterable so we
// have to do this a really old-school way).
let touchFound = false;
for (let i = 0; i < e.changedTouches.length; ++i) {
if (e.changedTouches.item(i).identifier === activeTouchID) {
touchFound = true;
break;
}
}
if (!touchFound) return;
e.preventDefault();
if (isHeld) stopTalking();
setState({ isHeld: false, activeTouchID: null });
},
[isHeld, activeTouchID, setState, stopTalking]
);
const hold = useCallback(() => {
// This update is delayed so the user only sees it if latency is significant
if (buttonHeld) return;
setButtonHeld(true);
enqueueNetworkWaiting(true, 100);
startTalking();
}, [enqueueNetworkWaiting, startTalking, buttonHeld]);
const unhold = useCallback(() => {
setButtonHeld(false);
setNetworkWaiting(false);
stopTalking();
}, [setNetworkWaiting, stopTalking]);
const onButtonMouseDown = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setState({ isHeld: true, activeTouchID: null });
startTalking();
hold();
},
[setState, startTalking]
[hold]
);
const onButtonTouchStart = useCallback(
(e: TouchEvent) => {
e.preventDefault();
// These listeners go on the window so even if the user's cursor / finger
// leaves the button while holding it, the button stays pushed until
// they stop clicking / tapping.
useEventTarget(window, "mouseup", unhold);
useEventTarget(
window,
"touchend",
useCallback(
(e: TouchEvent) => {
// ignore any ended touches that weren't the one pressing the
// button (bafflingly the TouchList isn't an iterable so we
// have to do this a really old-school way).
let touchFound = false;
for (let i = 0; i < e.changedTouches.length; ++i) {
if (e.changedTouches.item(i).identifier === activeTouchId) {
touchFound = true;
break;
}
}
if (!touchFound) return;
if (isHeld) return;
setState({
isHeld: true,
activeTouchID: e.changedTouches.item(0).identifier,
});
startTalking();
},
[isHeld, setState, startTalking]
e.preventDefault();
unhold();
setActiveTouchId(null);
},
[unhold, activeTouchId, setActiveTouchId]
)
);
useEffect(() => {
const currentButtonElement = buttonRef.current;
// This is a native DOM listener too because we want to preventDefault in it
// to stop also getting a click event, so we need it to be non-passive.
useEventTarget(
buttonRef.current,
"touchstart",
useCallback(
(e: TouchEvent) => {
e.preventDefault();
// These listeners go on the window so even if the user's cursor / finger
// leaves the button while holding it, the button stays pushed until
// they stop clicking / tapping.
window.addEventListener("mouseup", onWindowMouseUp);
window.addEventListener("touchend", onWindowTouchEnd);
// This is a native DOM listener too because we want to preventDefault in it
// to stop also getting a click event, so we need it to be non-passive.
currentButtonElement.addEventListener("touchstart", onButtonTouchStart, {
passive: false,
});
hold();
setActiveTouchId(e.changedTouches.item(0).identifier);
},
[hold, setActiveTouchId]
),
{ passive: false }
);
return () => {
window.removeEventListener("mouseup", onWindowMouseUp);
window.removeEventListener("touchend", onWindowTouchEnd);
currentButtonElement.removeEventListener(
"touchstart",
onButtonTouchStart
);
};
}, [onWindowMouseUp, onWindowTouchEnd, onButtonTouchStart, buttonRef]);
useEventTarget(
window,
"keydown",
useCallback(
(e: KeyboardEvent) => {
if (e.code === "Space") {
if (!enabled) return;
e.preventDefault();
hold();
}
},
[enabled, hold]
)
);
useEventTarget(
window,
"keyup",
useCallback(
(e: KeyboardEvent) => {
if (e.code === "Space") {
e.preventDefault();
unhold();
}
},
[unhold]
)
);
// TODO: We will need to disable this for a global PTT hotkey to work
useEventTarget(window, "blur", unhold);
const { shadow } = useSpring({
shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.6,
@@ -143,12 +168,15 @@ export const PTTButton: React.FC<Props> = ({
});
const shadowColor = showTalkOverError
? "var(--alert-20)"
: networkWaiting
? "var(--tertiary-content-20)"
: "var(--accent-20)";
return (
<animated.button
className={classNames(styles.pttButton, {
[styles.talking]: activeSpeakerUserId,
[styles.networkWaiting]: networkWaiting,
[styles.error]: showTalkOverError,
})}
style={{

View File

@@ -28,6 +28,7 @@
display: flex;
flex-direction: column;
margin: 20px;
text-align: center;
}
.participants > p {
@@ -41,6 +42,7 @@
.talkingInfo {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: center;
margin-bottom: 20px;

View File

@@ -14,12 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { useEffect } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCall, MatrixClient, RoomMember } from "matrix-js-sdk";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useDelayedState } from "../useDelayedState";
import { useModalTriggerState } from "../Modal";
import { InviteModal } from "./InviteModal";
import { HangupButton, InviteButton } from "../button";
@@ -39,6 +40,7 @@ import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
function getPromptText(
networkWaiting: boolean,
showTalkOverError: boolean,
pttButtonHeld: boolean,
activeSpeakerIsLocalUser: boolean,
@@ -47,10 +49,14 @@ function getPromptText(
activeSpeakerDisplayName: string,
connected: boolean
): string {
if (!connected) return "Connection Lost";
if (!connected) return "Connection lost";
const isTouchScreen = Boolean(window.ontouchstart !== undefined);
if (networkWaiting) {
return "Waiting for network";
}
if (showTalkOverError) {
return "You can't talk at the same time";
}
@@ -87,6 +93,7 @@ interface Props {
participants: RoomMember[];
userMediaFeeds: CallFeed[];
onLeave: () => void;
isEmbedded: boolean;
}
export const PTTCallView: React.FC<Props> = ({
@@ -98,6 +105,7 @@ export const PTTCallView: React.FC<Props> = ({
participants,
userMediaFeeds,
onLeave,
isEmbedded,
}) => {
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
@@ -105,6 +113,7 @@ export const PTTCallView: React.FC<Props> = ({
useModalTriggerState();
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
const facepileSize = bounds.width < 800 ? "sm" : "md";
const showControls = bounds.height > 500;
const pttButtonSize = 232;
const { audioOutput } = useMediaHandler();
@@ -128,18 +137,15 @@ export const PTTCallView: React.FC<Props> = ({
stopTalking,
transmitBlocked,
connected,
} = usePTT(
client,
groupCall,
userMediaFeeds,
playClip,
!feedbackModalState.isOpen
);
} = usePTT(client, groupCall, userMediaFeeds, playClip);
const [talkingExpected, enqueueTalkingExpected, setTalkingExpected] =
useDelayedState(false);
const showTalkOverError = pttButtonHeld && transmitBlocked;
const networkWaiting =
talkingExpected && !activeSpeakerUserId && !showTalkOverError;
const activeSpeakerIsLocalUser =
activeSpeakerUserId && client.getUserId() === activeSpeakerUserId;
const activeSpeakerIsLocalUser = activeSpeakerUserId === client.getUserId();
const activeSpeakerUser = activeSpeakerUserId
? client.getUser(activeSpeakerUserId)
: null;
@@ -148,6 +154,10 @@ export const PTTCallView: React.FC<Props> = ({
? activeSpeakerUser.displayName
: "";
useEffect(() => {
setTalkingExpected(activeSpeakerIsLocalUser);
}, [activeSpeakerIsLocalUser, setTalkingExpected]);
return (
<div className={styles.pttCallView} ref={containerRef}>
<PTTClips
@@ -163,60 +173,69 @@ export const PTTCallView: React.FC<Props> = ({
// https://github.com/vector-im/element-call/issues/328
show={false}
/>
<Header className={styles.header}>
<LeftNav>
<RoomSetupHeaderInfo
roomName={roomName}
avatarUrl={avatarUrl}
onPress={onLeave}
/>
</LeftNav>
<RightNav />
</Header>
{showControls && (
<Header className={styles.header}>
<LeftNav>
<RoomSetupHeaderInfo
roomName={roomName}
avatarUrl={avatarUrl}
onPress={onLeave}
isEmbedded={isEmbedded}
/>
</LeftNav>
<RightNav />
</Header>
)}
<div className={styles.center}>
<div className={styles.participants}>
<p>{`${participants.length} ${
participants.length > 1 ? "people" : "person"
} connected`}</p>
<Facepile
size={facepileSize}
max={8}
className={styles.facepile}
client={client}
participants={participants}
/>
</div>
<div className={styles.footer}>
<OverflowMenu
inCall
roomId={roomId}
client={client}
groupCall={groupCall}
showInvite={false}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
<HangupButton onPress={onLeave} />
<InviteButton onPress={() => inviteModalState.open()} />
</div>
{showControls && (
<>
<div className={styles.participants}>
<p>{`${participants.length} ${
participants.length > 1 ? "people" : "person"
} connected`}</p>
<Facepile
size={facepileSize}
max={8}
className={styles.facepile}
client={client}
participants={participants}
/>
</div>
<div className={styles.footer}>
<OverflowMenu
inCall
roomId={roomId}
client={client}
groupCall={groupCall}
showInvite={false}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
{!isEmbedded && <HangupButton onPress={onLeave} />}
<InviteButton onPress={() => inviteModalState.open()} />
</div>
</>
)}
<div className={styles.pttButtonContainer}>
{activeSpeakerUserId ? (
<div className={styles.talkingInfo}>
<h2>
{!activeSpeakerIsLocalUser && (
<AudioIcon className={styles.speakerIcon} />
)}
{activeSpeakerIsLocalUser
? "Talking..."
: `${activeSpeakerDisplayName} is talking...`}
</h2>
<Timer value={activeSpeakerUserId} />
</div>
) : (
<div className={styles.talkingInfo} />
)}
{showControls &&
(activeSpeakerUserId ? (
<div className={styles.talkingInfo}>
<h2>
{!activeSpeakerIsLocalUser && (
<AudioIcon className={styles.speakerIcon} />
)}
{activeSpeakerIsLocalUser
? "Talking..."
: `${activeSpeakerDisplayName} is talking...`}
</h2>
<Timer value={activeSpeakerUserId} />
</div>
) : (
<div className={styles.talkingInfo} />
))}
<PTTButton
enabled={!feedbackModalState.isOpen}
showTalkOverError={showTalkOverError}
activeSpeakerUserId={activeSpeakerUserId}
activeSpeakerDisplayName={activeSpeakerDisplayName}
@@ -226,18 +245,24 @@ export const PTTCallView: React.FC<Props> = ({
size={pttButtonSize}
startTalking={startTalking}
stopTalking={stopTalking}
networkWaiting={networkWaiting}
enqueueNetworkWaiting={enqueueTalkingExpected}
setNetworkWaiting={setTalkingExpected}
/>
<p className={styles.actionTip}>
{getPromptText(
showTalkOverError,
pttButtonHeld,
activeSpeakerIsLocalUser,
talkOverEnabled,
activeSpeakerUserId,
activeSpeakerDisplayName,
connected
)}
</p>
{showControls && (
<p className={styles.actionTip}>
{getPromptText(
networkWaiting,
showTalkOverError,
pttButtonHeld,
activeSpeakerIsLocalUser,
talkOverEnabled,
activeSpeakerUserId,
activeSpeakerDisplayName,
connected
)}
</p>
)}
{userMediaFeeds.map((callFeed) => (
<PTTFeed
key={callFeed.userId}
@@ -245,7 +270,7 @@ export const PTTCallView: React.FC<Props> = ({
audioOutputDevice={audioOutput}
/>
))}
{isAdmin && (
{isAdmin && showControls && (
<Toggle
isSelected={talkOverEnabled}
onChange={setTalkOverEnabled}
@@ -256,7 +281,7 @@ export const PTTCallView: React.FC<Props> = ({
</div>
</div>
{inviteModalState.isOpen && (
{inviteModalState.isOpen && showControls && (
<InviteModal roomId={roomId} {...inviteModalProps} />
)}
</div>

View File

@@ -16,26 +16,21 @@ limitations under the License.
import React, { useCallback, useState } from "react";
import styles from "./RoomAuthView.module.css";
import { useClient } from "../ClientContext";
import { Button } from "../button";
import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { useLocation } from "react-router-dom";
import { useRecaptcha } from "../auth/useRecaptcha";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { Form } from "../form/Form";
import { UserMenuContainer } from "../UserMenuContainer";
import { generateRandomName } from "../auth/generateRandomName";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
export function RoomAuthView() {
const { setClient } = useClient();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const { registerPasswordlessUser, recaptchaId, privacyPolicyUrl } =
useRegisterPasswordlessUser();
const onSubmit = useCallback(
(e) => {
@@ -43,29 +38,13 @@ export function RoomAuthView() {
const data = new FormData(e.target);
const displayName = data.get("displayName");
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
);
setClient(client, session);
}
submit().catch((error) => {
console.error(error);
registerPasswordlessUser(displayName).catch((error) => {
console.error("Failed to register passwordless user", e);
setLoading(false);
setError(error);
reset();
});
},
[register, reset, execute]
[registerPasswordlessUser]
);
const location = useLocation();

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useLocation, useParams } from "react-router-dom";
import { useClient } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
@@ -22,6 +22,7 @@ import { RoomAuthView } from "./RoomAuthView";
import { GroupCallLoader } from "./GroupCallLoader";
import { GroupCallView } from "./GroupCallView";
import { MediaHandlerProvider } from "../settings/useMediaHandler";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
export function RoomPage() {
const { loading, isAuthenticated, error, client, isPasswordlessUser } =
@@ -29,13 +30,37 @@ export function RoomPage() {
const { roomId: maybeRoomId } = useParams();
const { hash, search } = useLocation();
const [viaServers] = useMemo(() => {
const [viaServers, isEmbedded, isPtt, displayName] = useMemo(() => {
const params = new URLSearchParams(search);
return [params.getAll("via")];
return [
params.getAll("via"),
params.has("embed"),
params.get("ptt") === "true",
params.get("displayName"),
];
}, [search]);
const roomId = (maybeRoomId || hash || "").toLowerCase();
const { registerPasswordlessUser, recaptchaId } =
useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false);
if (loading) {
useEffect(() => {
// If we're not already authed and we've been given a display name as
// a URL param, automatically register a passwordless user
if (!isAuthenticated && displayName) {
setIsRegistering(true);
registerPasswordlessUser(displayName).finally(() => {
setIsRegistering(false);
});
}
}, [
isAuthenticated,
displayName,
setIsRegistering,
registerPasswordlessUser,
]);
if (loading || isRegistering) {
return <LoadingView />;
}
@@ -49,13 +74,19 @@ export function RoomPage() {
return (
<MediaHandlerProvider client={client}>
<GroupCallLoader client={client} roomId={roomId} viaServers={viaServers}>
<GroupCallLoader
client={client}
roomId={roomId}
viaServers={viaServers}
createPtt={isPtt}
>
{(groupCall) => (
<GroupCallView
client={client}
roomId={roomId}
groupCall={groupCall}
isPasswordlessUser={isPasswordlessUser}
isEmbedded={isEmbedded}
/>
)}
</GroupCallLoader>

View File

@@ -14,11 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useReducer, useState } from "react";
import {
GroupCallEvent,
GroupCallState,
GroupCall,
GroupCallErrorCode,
GroupCallUnknownDeviceError,
GroupCallError,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
@@ -48,6 +51,7 @@ export interface UseGroupCallType {
localDesktopCapturerSourceId: string;
participants: RoomMember[];
hasLocalParticipant: boolean;
unencryptedEventsFromUsers: Set<string>;
}
interface State {
@@ -106,6 +110,13 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
hasLocalParticipant: false,
});
const [unencryptedEventsFromUsers, addUnencryptedEventUser] = useReducer(
(state: Set<string>, newVal: string) => {
return new Set(state).add(newVal);
},
new Set<string>()
);
const updateState = (state: Partial<State>) =>
setState((prevState) => ({ ...prevState, ...state }));
@@ -180,6 +191,13 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
});
}
function onError(e: GroupCallError): void {
if (e.code === GroupCallErrorCode.UnknownDevice) {
const unknownDeviceError = e as GroupCallUnknownDeviceError;
addUnencryptedEventUser(unknownDeviceError.userId);
}
}
groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged);
groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged);
groupCall.on(
@@ -194,6 +212,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
);
groupCall.on(GroupCallEvent.CallsChanged, onCallsChanged);
groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged);
groupCall.on(GroupCallEvent.Error, onError);
updateState({
error: null,
@@ -242,6 +261,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
GroupCallEvent.ParticipantsChanged,
onParticipantsChanged
);
groupCall.removeListener(GroupCallEvent.Error, onError);
groupCall.leave();
};
}, [groupCall]);
@@ -319,5 +339,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
localDesktopCapturerSourceId,
participants,
hasLocalParticipant,
unencryptedEventsFromUsers,
};
}

View File

@@ -1,108 +0,0 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useState, useEffect } from "react";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
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 useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
const [state, setState] = useState({
loading: true,
error: undefined,
groupCall: undefined,
});
useEffect(() => {
async function fetchOrCreateGroupCall() {
try {
const groupCall = await fetchGroupCall(
client,
roomId,
viaServers,
30000
);
return groupCall;
} catch (error) {
if (
createIfNotFound &&
(error.errcode === "M_NOT_FOUND" ||
(error.message &&
error.message.indexOf("Failed to fetch alias") !== -1)) &&
isLocalRoomId(roomId)
) {
const roomName = roomNameFromRoomId(roomId);
await createRoom(client, roomName);
const groupCall = await fetchGroupCall(
client,
roomId,
viaServers,
30000
);
return groupCall;
}
throw error;
}
}
setState({ loading: true });
fetchOrCreateGroupCall()
.then((groupCall) =>
setState((prevState) => ({ ...prevState, loading: false, groupCall }))
)
.catch((error) =>
setState((prevState) => ({ ...prevState, loading: false, error }))
);
}, [client, roomId, state.reloadId, createIfNotFound, viaServers]);
return state;
}

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 { useState, useEffect } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
GroupCallType,
GroupCallIntent,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
import { ClientEvent } from "matrix-js-sdk/src/client";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
export interface GroupCallLoadState {
loading: boolean;
error?: Error;
groupCall?: GroupCall;
}
export const useLoadGroupCall = (
client: MatrixClient,
roomIdOrAlias: string,
viaServers: string[],
createPtt: boolean
): GroupCallLoadState => {
const [state, setState] = useState<GroupCallLoadState>({ loading: true });
useEffect(() => {
setState({ loading: true });
const waitForRoom = async (roomId: string): Promise<Room> => {
const room = client.getRoom(roomId);
if (room) return room;
console.log(`Room ${roomId} hasn't arrived yet: waiting`);
const waitPromise = new Promise<Room>((resolve) => {
const onRoomEvent = async (room: Room) => {
if (room.roomId === roomId) {
client.removeListener(ClientEvent.Room, onRoomEvent);
resolve(room);
}
};
client.on(ClientEvent.Room, onRoomEvent);
});
// race the promise with a timeout so we don't
// wait forever for the room
const timeoutPromise = new Promise<Room>((_, reject) => {
setTimeout(() => {
reject(new Error("Timed out trying to join room"));
}, 30000);
});
return Promise.race([waitPromise, timeoutPromise]);
};
const fetchOrCreateRoom = async (): Promise<Room> => {
try {
const room = await client.joinRoom(roomIdOrAlias, { viaServers });
// wait for the room to come down the sync stream, otherwise
// client.getRoom() won't return the room.
return waitForRoom(room.roomId);
} catch (error) {
if (
isLocalRoomId(roomIdOrAlias) &&
(error.errcode === "M_NOT_FOUND" ||
(error.message &&
error.message.indexOf("Failed to fetch alias") !== -1))
) {
// The room doesn't exist, but we can create it
const [, roomId] = await createRoom(
client,
roomNameFromRoomId(roomIdOrAlias)
);
// likewise, wait for the room
return await waitForRoom(roomId);
} else {
throw error;
}
}
};
const fetchOrCreateGroupCall = async (): Promise<GroupCall> => {
const room = await fetchOrCreateRoom();
const groupCall = client.getGroupCallForRoom(room.roomId);
if (groupCall) return groupCall;
if (
room.currentState.mayClientSendStateEvent(
EventType.GroupCallPrefix,
client
)
) {
// The call doesn't exist, but we can create it
console.log(`Creating ${createPtt ? "PTT" : "video"} group call room`);
return await client.createGroupCall(
room.roomId,
createPtt ? GroupCallType.Voice : GroupCallType.Video,
createPtt,
GroupCallIntent.Room
);
}
// We don't have permission to create the call, so all we can do is wait
// for one to come in
return new Promise((resolve, reject) => {
const onGroupCallIncoming = (groupCall: GroupCall) => {
if (groupCall?.room.roomId === room.roomId) {
clearTimeout(timeout);
client.off(
GroupCallEventHandlerEvent.Incoming,
onGroupCallIncoming
);
resolve(groupCall);
}
};
client.on(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
const timeout = setTimeout(() => {
client.off(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
reject(new Error("Fetching group call timed out."));
}, 30000);
});
};
fetchOrCreateGroupCall()
.then((groupCall) =>
setState((prevState) => ({ ...prevState, loading: false, groupCall }))
)
.catch((error) =>
setState((prevState) => ({ ...prevState, loading: false, error }))
);
}, [client, roomIdOrAlias, viaServers, createPtt]);
return state;
};

View File

@@ -80,8 +80,7 @@ export const usePTT = (
client: MatrixClient,
groupCall: GroupCall,
userMediaFeeds: CallFeed[],
playClip: PlayClipFunction,
enablePTTButton: boolean
playClip: PlayClipFunction
): PTTState => {
// Used to serialise all the mute calls so they don't race. It has
// its own state as its always set separately from anything else.
@@ -131,7 +130,7 @@ export const usePTT = (
const onMuteStateChanged = useCallback(() => {
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
let blocked = false;
let blocked = transmitBlocked;
if (activeSpeakerUserId === null && activeSpeakerFeed !== null) {
if (activeSpeakerFeed.userId === client.getUserId()) {
playClip(PTTClipID.START_TALKING_LOCAL);
@@ -142,8 +141,8 @@ export const usePTT = (
playClip(PTTClipID.END_TALKING);
} else if (
pttButtonHeld &&
activeSpeakerUserId === client.getUserId() &&
activeSpeakerFeed?.userId !== client.getUserId()
activeSpeakerFeed?.userId !== client.getUserId() &&
!transmitBlocked
) {
// We were talking but we've been cut off: mute our own mic
// (this is the easier way of cutting other speakers off if an
@@ -168,6 +167,7 @@ export const usePTT = (
client,
userMediaFeeds,
setMicMuteWrapper,
transmitBlocked,
]);
useEffect(() => {
@@ -258,59 +258,6 @@ export const usePTT = (
[setConnected]
);
useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
if (event.code === "Space") {
if (!enablePTTButton) return;
event.preventDefault();
if (pttButtonHeld) return;
startTalking();
}
}
function onKeyUp(event: KeyboardEvent): void {
if (event.code === "Space") {
event.preventDefault();
stopTalking();
}
}
function onBlur(): void {
// TODO: We will need to disable this for a global PTT hotkey to work
if (!groupCall.isMicrophoneMuted()) {
setMicMuteWrapper(true);
}
setState((prevState) => ({ ...prevState, pttButtonHeld: false }));
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
};
}, [
groupCall,
startTalking,
stopTalking,
activeSpeakerUserId,
isAdmin,
talkOverEnabled,
pttButtonHeld,
enablePTTButton,
setMicMuteWrapper,
client,
onClientSync,
]);
useEffect(() => {
client.on(ClientEvent.Sync, onClientSync);

View File

@@ -15,6 +15,8 @@ limitations under the License.
*/
import React from "react";
import { Item } from "@react-stately/collections";
import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css";
import { TabContainer, TabItem } from "../tabs/Tabs";
@@ -22,7 +24,6 @@ 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 "../input/SelectInput";
import { Item } from "@react-stately/collections";
import { useMediaHandler } from "./useMediaHandler";
import { useSpatialAudio, useShowInspector } from "./useSetting";
import { FieldRow, InputField } from "../input/Input";
@@ -30,7 +31,13 @@ import { Button } from "../button";
import { useDownloadDebugLog } from "./submit-rageshake";
import { Body } from "../typography/Typography";
export const SettingsModal = (props) => {
interface Props {
setShowInspector: boolean;
showInspector: boolean;
[rest: string]: unknown;
}
export const SettingsModal = (props: Props) => {
const {
audioInput,
audioInputs,
@@ -42,6 +49,7 @@ export const SettingsModal = (props) => {
audioOutputs,
setAudioOutput,
} = useMediaHandler();
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
const [showInspector, setShowInspector] = useShowInspector();
@@ -69,8 +77,12 @@ export const SettingsModal = (props) => {
selectedKey={audioInput}
onSelectionChange={setAudioInput}
>
{audioInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{audioInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Microphone ${index + 1}`}
</Item>
))}
</SelectInput>
{audioOutputs.length > 0 && (
@@ -79,8 +91,12 @@ export const SettingsModal = (props) => {
selectedKey={audioOutput}
onSelectionChange={setAudioOutput}
>
{audioOutputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{audioOutputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Speaker ${index + 1}`}
</Item>
))}
</SelectInput>
)}
@@ -91,7 +107,9 @@ export const SettingsModal = (props) => {
type="checkbox"
checked={spatialAudio}
description="This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
onChange={(e) => setSpatialAudio(e.target.checked)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSpatialAudio(event.target.checked)
}
/>
</FieldRow>
</TabItem>
@@ -108,8 +126,12 @@ export const SettingsModal = (props) => {
selectedKey={videoInput}
onSelectionChange={setVideoInput}
>
{videoInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{videoInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Camera ${index + 1}`}
</Item>
))}
</SelectInput>
</TabItem>
@@ -133,7 +155,9 @@ export const SettingsModal = (props) => {
label="Show Call Inspector"
type="checkbox"
checked={showInspector}
onChange={(e) => setShowInspector(e.target.checked)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setShowInspector(e.target.checked)
}
/>
</FieldRow>
<FieldRow>

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/*
Copyright 2017 OpenMarket Ltd
Copyright 2018 New Vector Ltd
@@ -37,19 +38,33 @@ limitations under the License.
// actually timestamps. We then purge the remaining logs. We also do this
// purge on startup to prevent logs from accumulating.
// the frequency with which we flush to indexeddb
import { logger } from "matrix-js-sdk/src/logger";
import { randomString } from "matrix-js-sdk/src/randomstring";
// the frequency with which we flush to indexeddb
const FLUSH_RATE_MS = 30 * 1000;
// the length of log data we keep in indexeddb (and include in the reports)
const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB
// A class which monkey-patches the global console and stores log lines.
export class ConsoleLogger {
logs = "";
type LogFunction = (
...args: (Error | DOMException | object | string)[]
) => void;
type LogFunctionName = "log" | "info" | "warn" | "error";
monkeyPatch(consoleObj) {
// A class which monkey-patches the global console and stores log lines.
interface LogEntry {
id: string;
lines: string;
index?: number;
}
export class ConsoleLogger {
private logs = "";
private originalFunctions: { [key in LogFunctionName]?: LogFunction } = {};
public monkeyPatch(consoleObj: Console): void {
// Monkey-patch console logging
const consoleFunctionsToLevels = {
log: "I",
@@ -60,6 +75,7 @@ export class ConsoleLogger {
Object.keys(consoleFunctionsToLevels).forEach((fnName) => {
const level = consoleFunctionsToLevels[fnName];
const originalFn = consoleObj[fnName].bind(consoleObj);
this.originalFunctions[fnName] = originalFn;
consoleObj[fnName] = (...args) => {
this.log(level, ...args);
originalFn(...args);
@@ -67,7 +83,17 @@ export class ConsoleLogger {
});
}
log(level, ...args) {
public bypassRageshake(
fnName: LogFunctionName,
...args: (Error | DOMException | object | string)[]
): void {
this.originalFunctions[fnName](...args);
}
public log(
level: string,
...args: (Error | DOMException | object | string)[]
): void {
// We don't know what locale the user may be running so use ISO strings
const ts = new Date().toISOString();
@@ -78,21 +104,7 @@ export class ConsoleLogger {
} else if (arg instanceof Error) {
return arg.message + (arg.stack ? `\n${arg.stack}` : "");
} else if (typeof arg === "object") {
try {
return JSON.stringify(arg);
} catch (e) {
// In development, it can be useful to log complex cyclic
// objects to the console for inspection. This is fine for
// the console, but default `stringify` can't handle that.
// We workaround this by using a special replacer function
// to only log values of the root object and avoid cycles.
return JSON.stringify(arg, (key, value) => {
if (key && typeof value === "object") {
return "<object>";
}
return value;
});
}
return JSON.stringify(arg, getCircularReplacer());
} else {
return arg;
}
@@ -116,7 +128,7 @@ export class ConsoleLogger {
* @param {boolean} keepLogs True to not delete logs after flushing.
* @return {string} \n delimited log lines to flush.
*/
flush(keepLogs) {
public flush(keepLogs?: boolean): string {
// The ConsoleLogger doesn't care how these end up on disk, it just
// flushes them to the caller.
if (keepLogs) {
@@ -130,24 +142,23 @@ export class ConsoleLogger {
// A class which stores log lines in an IndexedDB instance.
export class IndexedDBLogStore {
index = 0;
db = null;
flushPromise = null;
flushAgainPromise = null;
private index = 0;
private db: IDBDatabase = null;
private flushPromise: Promise<void> = null;
private flushAgainPromise: Promise<void> = null;
private id: string;
constructor(indexedDB, logger) {
this.indexedDB = indexedDB;
this.logger = logger;
this.id = "instance-" + Math.random() + Date.now();
constructor(private indexedDB: IDBFactory, private logger: ConsoleLogger) {
this.id = "instance-" + randomString(16);
}
/**
* @return {Promise} Resolves when the store is ready.
*/
connect() {
public connect(): Promise<void> {
const req = this.indexedDB.open("logs");
return new Promise((resolve, reject) => {
req.onsuccess = (event) => {
req.onsuccess = (event: Event) => {
// @ts-ignore
this.db = event.target.result;
// Periodically flush logs to local storage / indexeddb
@@ -206,7 +217,7 @@ export class IndexedDBLogStore {
*
* @return {Promise} Resolved when the logs have been flushed.
*/
flush() {
public flush(): Promise<void> {
// check if a flush() operation is ongoing
if (this.flushPromise) {
if (this.flushAgainPromise) {
@@ -225,7 +236,7 @@ export class IndexedDBLogStore {
}
// there is no flush promise or there was but it has finished, so do
// a brand new one, destroying the chain which may have been built up.
this.flushPromise = new Promise((resolve, reject) => {
this.flushPromise = new Promise<void>((resolve, reject) => {
if (!this.db) {
// not connected yet or user rejected access for us to r/w to the db.
reject(new Error("No connected database"));
@@ -243,6 +254,7 @@ export class IndexedDBLogStore {
};
txn.onerror = (event) => {
logger.error("Failed to flush logs : ", event);
// @ts-ignore
reject(new Error("Failed to write logs: " + event.target.errorCode));
};
objStore.add(this.generateLogEntry(lines));
@@ -264,12 +276,12 @@ export class IndexedDBLogStore {
* log ID). The objects have said log ID in an "id" field and "lines" which
* is a big string with all the new-line delimited logs.
*/
async consume() {
public async consume(): Promise<LogEntry[]> {
const db = this.db;
// Returns: a string representing the concatenated logs for this ID.
// Stops adding log fragments when the size exceeds maxSize
function fetchLogs(id, maxSize) {
function fetchLogs(id: string, maxSize: number): Promise<string> {
const objectStore = db
.transaction("logs", "readonly")
.objectStore("logs");
@@ -280,9 +292,11 @@ export class IndexedDBLogStore {
.openCursor(IDBKeyRange.only(id), "prev");
let lines = "";
query.onerror = (event) => {
// @ts-ignore
reject(new Error("Query failed: " + event.target.errorCode));
};
query.onsuccess = (event) => {
// @ts-ignore
const cursor = event.target.result;
if (!cursor) {
resolve(lines);
@@ -299,12 +313,12 @@ export class IndexedDBLogStore {
}
// Returns: A sorted array of log IDs. (newest first)
function fetchLogIds() {
function fetchLogIds(): Promise<string[]> {
// To gather all the log IDs, query for all records in logslastmod.
const o = db
.transaction("logslastmod", "readonly")
.objectStore("logslastmod");
return selectQuery(o, undefined, (cursor) => {
return selectQuery<{ ts: number; id: string }>(o, undefined, (cursor) => {
return {
id: cursor.value.id,
ts: cursor.value.ts,
@@ -319,13 +333,14 @@ export class IndexedDBLogStore {
});
}
function deleteLogs(id) {
return new Promise((resolve, reject) => {
function deleteLogs(id: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const txn = db.transaction(["logs", "logslastmod"], "readwrite");
const o = txn.objectStore("logs");
// only load the key path, not the data which may be huge
const query = o.index("id").openKeyCursor(IDBKeyRange.only(id));
query.onsuccess = (event) => {
// @ts-ignore
const cursor = event.target.result;
if (!cursor) {
return;
@@ -340,6 +355,7 @@ export class IndexedDBLogStore {
reject(
new Error(
"Failed to delete logs for " +
// @ts-ignore
`'${id}' : ${event.target.errorCode}`
)
);
@@ -352,7 +368,7 @@ export class IndexedDBLogStore {
const allLogIds = await fetchLogIds();
let removeLogIds = [];
const logs = [];
const logs: LogEntry[] = [];
let size = 0;
for (let i = 0; i < allLogIds.length; i++) {
const lines = await fetchLogs(allLogIds[i], MAX_LOG_SIZE - size);
@@ -390,7 +406,7 @@ export class IndexedDBLogStore {
return logs;
}
generateLogEntry(lines) {
private generateLogEntry(lines: string): LogEntry {
return {
id: this.id,
lines: lines,
@@ -398,7 +414,7 @@ export class IndexedDBLogStore {
};
}
generateLastModifiedTime() {
private generateLastModifiedTime(): { id: string; ts: number } {
return {
id: this.id,
ts: Date.now(),
@@ -416,7 +432,11 @@ export class IndexedDBLogStore {
* @return {Promise<T[]>} Resolves to an array of whatever you returned from
* resultMapper.
*/
function selectQuery(store, keyRange, resultMapper) {
function selectQuery<T>(
store: IDBObjectStore,
keyRange: IDBKeyRange,
resultMapper: (cursor: IDBCursorWithValue) => T
): Promise<T[]> {
const query = store.openCursor(keyRange);
return new Promise((resolve, reject) => {
const results = [];
@@ -437,6 +457,16 @@ function selectQuery(store, keyRange, resultMapper) {
};
});
}
declare global {
// eslint-disable-next-line no-var, camelcase
var mx_rage_store: IndexedDBLogStore;
// eslint-disable-next-line no-var, camelcase
var mx_rage_logger: ConsoleLogger;
// eslint-disable-next-line no-var, camelcase
var mx_rage_initPromise: Promise<void>;
// eslint-disable-next-line no-var, camelcase
var mx_rage_initStoragePromise: Promise<void>;
}
/**
* Configure rage shaking support for sending bug reports.
@@ -445,7 +475,7 @@ function selectQuery(store, keyRange, resultMapper) {
* be set up immediately for the logs.
* @return {Promise} Resolves when set up.
*/
export function init(setUpPersistence = true) {
export function init(setUpPersistence = true): Promise<void> {
if (global.mx_rage_initPromise) {
return global.mx_rage_initPromise;
}
@@ -465,7 +495,7 @@ export function init(setUpPersistence = true) {
* then this no-ops.
* @return {Promise} Resolves when complete.
*/
export function tryInitStorage() {
export function tryInitStorage(): Promise<void> {
if (global.mx_rage_initStoragePromise) {
return global.mx_rage_initStoragePromise;
}
@@ -491,7 +521,7 @@ export function tryInitStorage() {
return global.mx_rage_initStoragePromise;
}
export function flush() {
export function flush(): Promise<void> {
if (!global.mx_rage_store) {
return;
}
@@ -502,7 +532,7 @@ export function flush() {
* Clean up old logs.
* @return {Promise} Resolves if cleaned logs.
*/
export async function cleanup() {
export async function cleanup(): Promise<void> {
if (!global.mx_rage_store) {
return;
}
@@ -512,9 +542,9 @@ export async function cleanup() {
/**
* Get a recent snapshot of the logs, ready for attaching to a bug report
*
* @return {Array<{lines: string, id, string}>} list of log data
* @return {LogEntry[]} list of log data
*/
export async function getLogsForReport() {
export async function getLogsForReport(): Promise<LogEntry[]> {
if (!global.mx_rage_logger) {
throw new Error("No console logger, did you forget to call init()?");
}
@@ -523,7 +553,7 @@ export async function getLogsForReport() {
if (global.mx_rage_store) {
// flush most recent logs
await global.mx_rage_store.flush();
return await global.mx_rage_store.consume();
return global.mx_rage_store.consume();
} else {
return [
{
@@ -533,3 +563,24 @@ export async function getLogsForReport() {
];
}
}
type StringifyReplacer = (
this: unknown,
key: string,
value: unknown
) => unknown;
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references
// Injects `<$ cycle-trimmed $>` wherever it cuts a cyclical object relationship
const getCircularReplacer = (): StringifyReplacer => {
const seen = new WeakSet();
return (key: string, value: unknown): unknown => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return "<$ cycle-trimmed $>";
}
seen.add(value);
}
return value;
};
};

View File

@@ -15,14 +15,31 @@ limitations under the License.
*/
import { useCallback, useContext, useEffect, useState } from "react";
import { getLogsForReport } from "./rageshake";
import pako from "pako";
import { MatrixEvent } from "matrix-js-sdk";
import { OverlayTriggerState } from "@react-stately/overlays";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { getLogsForReport } from "./rageshake";
import { useClient } from "../ClientContext";
import { InspectorContext } from "../room/GroupCallInspector";
import { useModalTriggerState } from "../Modal";
export function useSubmitRageshake() {
const { client } = useClient();
interface RageShakeSubmitOptions {
description: string;
roomId: string;
label: string;
sendLogs: boolean;
rageshakeRequestId: string;
}
export function useSubmitRageshake(): {
submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
sending: boolean;
sent: boolean;
error: Error;
} {
const client: MatrixClient = useClient().client;
const [{ json }] = useContext(InspectorContext);
const [{ sending, sent, error }, setState] = useState({
@@ -57,9 +74,12 @@ export function useSubmitRageshake() {
opts.description || "User did not supply any additional text."
);
body.append("app", "matrix-video-chat");
body.append("version", import.meta.env.VITE_APP_VERSION || "dev");
body.append(
"version",
(import.meta.env.VITE_APP_VERSION as string) || "dev"
);
body.append("user_agent", userAgent);
body.append("installed_pwa", false);
body.append("installed_pwa", "false");
body.append("touch_input", touchInput);
if (client) {
@@ -181,7 +201,11 @@ export function useSubmitRageshake() {
if (navigator.storage && navigator.storage.estimate) {
try {
const estimate = await navigator.storage.estimate();
const estimate: {
quota?: number;
usage?: number;
usageDetails?: { [x: string]: unknown };
} = await navigator.storage.estimate();
body.append("storageManager_quota", String(estimate.quota));
body.append("storageManager_usage", String(estimate.usage));
if (estimate.usageDetails) {
@@ -201,7 +225,6 @@ export function useSubmitRageshake() {
for (const entry of logs) {
// encode as UTF-8
let buf = new TextEncoder().encode(entry.lines);
// compress
buf = pako.gzip(buf);
@@ -225,7 +248,7 @@ export function useSubmitRageshake() {
}
await fetch(
import.meta.env.VITE_RAGESHAKE_SUBMIT_URL ||
(import.meta.env.VITE_RAGESHAKE_SUBMIT_URL as string) ||
"https://element.io/bugreports/submit",
{
method: "POST",
@@ -250,7 +273,7 @@ export function useSubmitRageshake() {
};
}
export function useDownloadDebugLog() {
export function useDownloadDebugLog(): () => void {
const [{ json }] = useContext(InspectorContext);
const downloadDebugLog = useCallback(() => {
@@ -271,7 +294,10 @@ export function useDownloadDebugLog() {
return downloadDebugLog;
}
export function useRageshakeRequest() {
export function useRageshakeRequest(): (
roomId: string,
rageshakeRequestId: string
) => void {
const { client } = useClient();
const sendRageshakeRequest = useCallback(
@@ -285,14 +311,27 @@ export function useRageshakeRequest() {
return sendRageshakeRequest;
}
interface ModalProps {
isOpen: boolean;
onClose: () => void;
}
interface ModalPropsWithId extends ModalProps {
rageshakeRequestId: string;
}
export function useRageshakeRequestModal(roomId) {
const { modalState, modalProps } = useModalTriggerState();
const { client } = useClient();
const [rageshakeRequestId, setRageshakeRequestId] = useState();
export function useRageshakeRequestModal(roomId: string): {
modalState: OverlayTriggerState;
modalProps: ModalPropsWithId;
} {
const { modalState, modalProps } = useModalTriggerState() as {
modalState: OverlayTriggerState;
modalProps: ModalProps;
};
const client: MatrixClient = useClient().client;
const [rageshakeRequestId, setRageshakeRequestId] = useState<string>();
useEffect(() => {
const onEvent = (event) => {
const onEvent = (event: MatrixEvent) => {
const type = event.getType();
if (
@@ -305,10 +344,10 @@ export function useRageshakeRequestModal(roomId) {
}
};
client.on("event", onEvent);
client.on(ClientEvent.Event, onEvent);
return () => {
client.removeListener("event", onEvent);
client.removeListener(ClientEvent.Event, onEvent);
};
}, [modalState.open, roomId, client, modalState]);

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/*
Copyright 2022 Matrix.org Foundation C.I.C.
@@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk";
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
import React, {
useState,
useEffect,
@@ -23,9 +26,27 @@ import React, {
createContext,
} from "react";
const MediaHandlerContext = createContext();
export interface MediaHandlerContextInterface {
audioInput: string;
audioInputs: MediaDeviceInfo[];
setAudioInput: (deviceId: string) => void;
videoInput: string;
videoInputs: MediaDeviceInfo[];
setVideoInput: (deviceId: string) => void;
audioOutput: string;
audioOutputs: MediaDeviceInfo[];
setAudioOutput: (deviceId: string) => void;
}
function getMediaPreferences() {
const MediaHandlerContext =
createContext<MediaHandlerContextInterface>(undefined);
interface MediaPreferences {
audioInput?: string;
videoInput?: string;
audioOutput?: string;
}
function getMediaPreferences(): MediaPreferences {
const mediaPreferences = localStorage.getItem("matrix-media-preferences");
if (mediaPreferences) {
@@ -39,8 +60,8 @@ function getMediaPreferences() {
}
}
function updateMediaPreferences(newPreferences) {
const oldPreferences = getMediaPreferences(newPreferences);
function updateMediaPreferences(newPreferences: MediaPreferences): void {
const oldPreferences = getMediaPreferences();
localStorage.setItem(
"matrix-media-preferences",
@@ -50,8 +71,11 @@ function updateMediaPreferences(newPreferences) {
})
);
}
export function MediaHandlerProvider({ client, children }) {
interface Props {
client: MatrixClient;
children: JSX.Element[];
}
export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
const [
{
audioInput,
@@ -72,7 +96,9 @@ export function MediaHandlerProvider({ client, children }) {
);
return {
// @ts-ignore, ignore that audioInput is a private members of mediaHandler
audioInput: mediaHandler.audioInput,
// @ts-ignore, ignore that videoInput is a private members of mediaHandler
videoInput: mediaHandler.videoInput,
audioOutput: undefined,
audioInputs: [],
@@ -84,7 +110,7 @@ export function MediaHandlerProvider({ client, children }) {
useEffect(() => {
const mediaHandler = client.getMediaHandler();
function updateDevices() {
function updateDevices(): void {
navigator.mediaDevices.enumerateDevices().then((devices) => {
const mediaPreferences = getMediaPreferences();
@@ -92,9 +118,10 @@ export function MediaHandlerProvider({ client, children }) {
(device) => device.kind === "audioinput"
);
const audioConnected = audioInputs.some(
// @ts-ignore
(device) => device.deviceId === mediaHandler.audioInput
);
// @ts-ignore
let audioInput = mediaHandler.audioInput;
if (!audioConnected && audioInputs.length > 0) {
@@ -105,9 +132,11 @@ export function MediaHandlerProvider({ client, children }) {
(device) => device.kind === "videoinput"
);
const videoConnected = videoInputs.some(
// @ts-ignore
(device) => device.deviceId === mediaHandler.videoInput
);
// @ts-ignore
let videoInput = mediaHandler.videoInput;
if (!videoConnected && videoInputs.length > 0) {
@@ -129,7 +158,9 @@ export function MediaHandlerProvider({ client, children }) {
}
if (
// @ts-ignore
mediaHandler.videoInput !== videoInput ||
// @ts-ignore
mediaHandler.audioInput !== audioInput
) {
mediaHandler.setMediaInputs(audioInput, videoInput);
@@ -149,18 +180,21 @@ export function MediaHandlerProvider({ client, children }) {
}
updateDevices();
mediaHandler.on("local_streams_changed", updateDevices);
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices);
navigator.mediaDevices.addEventListener("devicechange", updateDevices);
return () => {
mediaHandler.removeListener("local_streams_changed", updateDevices);
mediaHandler.removeListener(
MediaHandlerEvent.LocalStreamsChanged,
updateDevices
);
navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
mediaHandler.stopAllStreams();
};
}, [client]);
const setAudioInput = useCallback(
(deviceId) => {
const setAudioInput: (deviceId: string) => void = useCallback(
(deviceId: string) => {
updateMediaPreferences({ audioInput: deviceId });
setState((prevState) => ({ ...prevState, audioInput: deviceId }));
client.getMediaHandler().setAudioInput(deviceId);
@@ -168,7 +202,7 @@ export function MediaHandlerProvider({ client, children }) {
[client]
);
const setVideoInput = useCallback(
const setVideoInput: (deviceId: string) => void = useCallback(
(deviceId) => {
updateMediaPreferences({ videoInput: deviceId });
setState((prevState) => ({ ...prevState, videoInput: deviceId }));
@@ -177,35 +211,36 @@ export function MediaHandlerProvider({ client, children }) {
[client]
);
const setAudioOutput = useCallback((deviceId) => {
const setAudioOutput: (deviceId: string) => void = useCallback((deviceId) => {
updateMediaPreferences({ audioOutput: deviceId });
setState((prevState) => ({ ...prevState, audioOutput: deviceId }));
}, []);
const context = useMemo(
() => ({
audioInput,
audioInputs,
setAudioInput,
videoInput,
videoInputs,
setVideoInput,
audioOutput,
audioOutputs,
setAudioOutput,
}),
[
audioInput,
audioInputs,
setAudioInput,
videoInput,
videoInputs,
setVideoInput,
audioOutput,
audioOutputs,
setAudioOutput,
]
);
const context: MediaHandlerContextInterface =
useMemo<MediaHandlerContextInterface>(
() => ({
audioInput,
audioInputs,
setAudioInput,
videoInput,
videoInputs,
setVideoInput,
audioOutput,
audioOutputs,
setAudioOutput,
}),
[
audioInput,
audioInputs,
setAudioInput,
videoInput,
videoInputs,
setVideoInput,
audioOutput,
audioOutputs,
setAudioOutput,
]
);
return (
<MediaHandlerContext.Provider value={context}>

View File

@@ -42,7 +42,7 @@ export const PTTClips: React.FC<Props> = ({
return (
<>
<audio
preload="true"
preload="auto"
className={styles.pttClip}
ref={startTalkingLocalRef}
>
@@ -50,18 +50,18 @@ export const PTTClips: React.FC<Props> = ({
<source type="audio/mpeg" src={startTalkLocalMp3Url} />
</audio>
<audio
preload="true"
preload="auto"
className={styles.pttClip}
ref={startTalkingRemoteRef}
>
<source type="audio/ogg" src={startTalkRemoteOggUrl} />
<source type="audio/mpeg" src={startTalkRemoteMp3Url} />
</audio>
<audio preload="true" className={styles.pttClip} ref={endTalkingRef}>
<audio preload="auto" className={styles.pttClip} ref={endTalkingRef}>
<source type="audio/ogg" src={endTalkOggUrl} />
<source type="audio/mpeg" src={endTalkMp3Url} />
</audio>
<audio preload="true" className={styles.pttClip} ref={blockedRef}>
<audio preload="auto" className={styles.pttClip} ref={blockedRef}>
<source type="audio/ogg" src={blockedOggUrl} />
<source type="audio/mpeg" src={blockedMp3Url} />
</audio>

View File

@@ -58,8 +58,12 @@ export const usePTTSounds = (): PTTSounds => {
break;
}
if (ref.current) {
ref.current.currentTime = 0;
await ref.current.play();
try {
ref.current.currentTime = 0;
await ref.current.play();
} catch (e) {
console.log("Couldn't play sound effect", e);
}
} else {
console.log("No media element found");
}

49
src/useDelayedState.ts Normal file
View File

@@ -0,0 +1,49 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useState, useRef, useCallback } from "react";
// Like useState, except state updates can be enqueued with a configurable delay
export const useDelayedState = <T>(
initial?: T
): [T, (value: T, delay: number) => void, (value: T) => void] => {
const [state, setState] = useState<T>(initial);
const timers = useRef<Set<ReturnType<typeof setTimeout>>>();
if (!timers.current) timers.current = new Set();
const setStateDelayed = useCallback(
(value: T, delay: number) => {
const timer = setTimeout(() => {
setState(value);
timers.current.delete(timer);
}, delay);
timers.current.add(timer);
},
[setState, timers]
);
const setStateImmediate = useCallback(
(value: T) => {
// Clear all updates currently in the queue
for (const timer of timers.current) clearTimeout(timer);
timers.current.clear();
setState(value);
},
[setState, timers]
);
return [state, setStateDelayed, setStateImmediate];
};

34
src/useEvents.ts Normal file
View File

@@ -0,0 +1,34 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useEffect } from "react";
// Shortcut for registering a listener on an EventTarget
export const useEventTarget = <T extends Event>(
target: EventTarget,
eventType: string,
listener: (event: T) => void,
options?: AddEventListenerOptions
) => {
useEffect(() => {
if (target) {
target.addEventListener(eventType, listener, options);
return () => target.removeEventListener(eventType, listener, options);
}
}, [target, eventType, listener, options]);
};
// TODO: Have a similar hook for EventEmitters

View File

@@ -29,6 +29,7 @@ export function VideoTileContainer({
showName,
audioOutputDevice,
audioContext,
audioDestination,
disableSpeakingIndicator,
...rest
}) {
@@ -47,6 +48,7 @@ export function VideoTileContainer({
stream,
audioOutputDevice,
audioContext,
audioDestination,
isLocal
);

View File

@@ -1,144 +0,0 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useRef, useEffect } from "react";
import { useSpatialAudio } from "../settings/useSetting";
export function useMediaStream(stream, audioOutputDevice, mute = false) {
const mediaRef = useRef();
useEffect(() => {
console.log(
`useMediaStream update stream mediaRef.current ${!!mediaRef.current} stream ${
stream && stream.id
}`
);
if (mediaRef.current) {
const mediaEl = mediaRef.current;
if (stream) {
mediaEl.muted = mute;
mediaEl.srcObject = stream;
mediaEl.play();
// Unmuting the tab in Safari causes all video elements to be individually
// unmuted, so we need to reset the mute state here to prevent audio loops
const onVolumeChange = () => {
mediaEl.muted = mute;
};
mediaEl.addEventListener("volumechange", onVolumeChange);
return () =>
mediaEl.removeEventListener("volumechange", onVolumeChange);
} else {
mediaRef.current.srcObject = null;
}
}
}, [stream, mute]);
useEffect(() => {
if (
mediaRef.current &&
audioOutputDevice &&
mediaRef.current !== undefined
) {
console.log(`useMediaStream setSinkId ${audioOutputDevice}`);
// Chrome for Android doesn't support this
mediaRef.current.setSinkId?.(audioOutputDevice);
}
}, [audioOutputDevice]);
useEffect(() => {
const mediaEl = mediaRef.current;
return () => {
if (mediaEl) {
// Ensure we set srcObject to null before unmounting to prevent memory leak
// https://webrtchacks.com/srcobject-intervention/
mediaEl.srcObject = null;
}
};
}, []);
return mediaRef;
}
export const useSpatialMediaStream = (
stream,
audioOutputDevice,
audioContext,
mute = false
) => {
const tileRef = useRef();
const [spatialAudio] = useSpatialAudio();
// If spatial audio is enabled, we handle audio separately from the video element
const mediaRef = useMediaStream(
stream,
audioOutputDevice,
spatialAudio || mute
);
const pannerNodeRef = useRef();
if (!pannerNodeRef.current) {
pannerNodeRef.current = new PannerNode(audioContext, {
panningModel: "HRTF",
refDistance: 3,
});
}
const sourceRef = useRef();
useEffect(() => {
if (spatialAudio && tileRef.current && !mute) {
if (!sourceRef.current) {
sourceRef.current = audioContext.createMediaStreamSource(stream);
}
const tile = tileRef.current;
const source = sourceRef.current;
const pannerNode = pannerNodeRef.current;
const updatePosition = () => {
const bounds = tile.getBoundingClientRect();
const windowSize = Math.max(window.innerWidth, window.innerHeight);
// Position the source relative to its placement in the window
pannerNodeRef.current.positionX.value =
(bounds.x + bounds.width / 2) / windowSize - 0.5;
pannerNodeRef.current.positionY.value =
(bounds.y + bounds.height / 2) / windowSize - 0.5;
// Put the source in front of the listener
pannerNodeRef.current.positionZ.value = -2;
};
updatePosition();
source.connect(pannerNode);
pannerNode.connect(audioContext.destination);
// HACK: We abuse the CSS transitionrun event to detect when the tile
// moves, because useMeasure, IntersectionObserver, etc. all have no
// ability to track changes in the CSS transform property
tile.addEventListener("transitionrun", updatePosition);
return () => {
tile.removeEventListener("transitionrun", updatePosition);
source.disconnect();
pannerNode.disconnect();
};
}
}, [stream, spatialAudio, audioContext, mute]);
return [tileRef, mediaRef];
};

View File

@@ -0,0 +1,253 @@
/*
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 { useRef, useEffect, RefObject } from "react";
import { parse as parseSdp, write as writeSdp } from "sdp-transform";
import {
acquireContext,
releaseContext,
} from "matrix-js-sdk/src/webrtc/audioContext";
import { useSpatialAudio } from "../settings/useSetting";
declare global {
interface Window {
// For detecting whether this browser is Chrome or not
chrome?: unknown;
}
}
export const useMediaStream = (
stream: MediaStream,
audioOutputDevice: string,
mute = false
): RefObject<MediaElement> => {
const mediaRef = useRef<MediaElement>();
useEffect(() => {
console.log(
`useMediaStream update stream mediaRef.current ${!!mediaRef.current} stream ${
stream && stream.id
}`
);
if (mediaRef.current) {
const mediaEl = mediaRef.current;
if (stream) {
mediaEl.muted = mute;
mediaEl.srcObject = stream;
mediaEl.play();
// Unmuting the tab in Safari causes all video elements to be individually
// unmuted, so we need to reset the mute state here to prevent audio loops
const onVolumeChange = () => {
mediaEl.muted = mute;
};
mediaEl.addEventListener("volumechange", onVolumeChange);
return () =>
mediaEl.removeEventListener("volumechange", onVolumeChange);
} else {
mediaRef.current.srcObject = null;
}
}
}, [stream, mute]);
useEffect(() => {
if (
mediaRef.current &&
audioOutputDevice &&
mediaRef.current !== undefined
) {
if (mediaRef.current.setSinkId) {
console.log(
`useMediaStream setting output setSinkId ${audioOutputDevice}`
);
// Chrome for Android doesn't support this
mediaRef.current.setSinkId(audioOutputDevice);
} else {
console.log("Can't set output - no setsinkid");
}
}
}, [audioOutputDevice]);
useEffect(() => {
const mediaEl = mediaRef.current;
return () => {
if (mediaEl) {
// Ensure we set srcObject to null before unmounting to prevent memory leak
// https://webrtchacks.com/srcobject-intervention/
mediaEl.srcObject = null;
}
};
}, []);
return mediaRef;
};
// Loops the given audio stream back through a local peer connection, to make
// AEC work with Web Audio streams on Chrome. The resulting stream should be
// played through an audio element.
// This hack can be removed once the following bug is resolved:
// https://bugs.chromium.org/p/chromium/issues/detail?id=687574
const createLoopback = async (stream: MediaStream): Promise<MediaStream> => {
// Prepare our local peer connections
const conn = new RTCPeerConnection();
const loopbackConn = new RTCPeerConnection();
const loopbackStream = new MediaStream();
conn.addEventListener("icecandidate", ({ candidate }) => {
if (candidate) loopbackConn.addIceCandidate(new RTCIceCandidate(candidate));
});
loopbackConn.addEventListener("icecandidate", ({ candidate }) => {
if (candidate) conn.addIceCandidate(new RTCIceCandidate(candidate));
});
loopbackConn.addEventListener("track", ({ track }) =>
loopbackStream.addTrack(track)
);
// Hook the connections together
stream.getTracks().forEach((track) => conn.addTrack(track));
const offer = await conn.createOffer({
offerToReceiveAudio: false,
offerToReceiveVideo: false,
});
await conn.setLocalDescription(offer);
await loopbackConn.setRemoteDescription(offer);
const answer = await loopbackConn.createAnswer();
// Rewrite SDP to be stereo and (variable) max bitrate
const parsedSdp = parseSdp(answer.sdp);
parsedSdp.media.forEach((m) =>
m.fmtp.forEach(
(f) => (f.config += `;stereo=1;cbr=0;maxaveragebitrate=510000;`)
)
);
answer.sdp = writeSdp(parsedSdp);
await loopbackConn.setLocalDescription(answer);
await conn.setRemoteDescription(answer);
return loopbackStream;
};
export const useAudioContext = (): [
AudioContext,
AudioNode,
RefObject<HTMLAudioElement>
] => {
const context = useRef<AudioContext>();
const destination = useRef<AudioNode>();
const audioRef = useRef<HTMLAudioElement>();
useEffect(() => {
if (audioRef.current && !context.current) {
context.current = acquireContext();
if (window.chrome) {
// We're in Chrome, which needs a loopback hack applied to enable AEC
const streamDest = context.current.createMediaStreamDestination();
destination.current = streamDest;
const audioEl = audioRef.current;
(async () => {
audioEl.srcObject = await createLoopback(streamDest.stream);
await audioEl.play();
})();
return () => {
audioEl.srcObject = null;
releaseContext();
};
} else {
destination.current = context.current.destination;
return releaseContext;
}
}
}, []);
return [context.current, destination.current, audioRef];
};
export const useSpatialMediaStream = (
stream: MediaStream,
audioOutputDevice: string,
audioContext: AudioContext,
audioDestination: AudioNode,
mute = false
): [RefObject<Element>, RefObject<MediaElement>] => {
const tileRef = useRef<Element>();
const [spatialAudio] = useSpatialAudio();
// If spatial audio is enabled, we handle audio separately from the video element
const mediaRef = useMediaStream(
stream,
audioOutputDevice,
spatialAudio || mute
);
const pannerNodeRef = useRef<PannerNode>();
const sourceRef = useRef<MediaStreamAudioSourceNode>();
useEffect(() => {
if (
spatialAudio &&
tileRef.current &&
!mute &&
stream.getAudioTracks().length > 0
) {
if (!pannerNodeRef.current) {
pannerNodeRef.current = new PannerNode(audioContext, {
panningModel: "HRTF",
refDistance: 3,
});
}
if (!sourceRef.current) {
sourceRef.current = audioContext.createMediaStreamSource(stream);
}
const tile = tileRef.current;
const source = sourceRef.current;
const pannerNode = pannerNodeRef.current;
const updatePosition = () => {
const bounds = tile.getBoundingClientRect();
const windowSize = Math.max(window.innerWidth, window.innerHeight);
// Position the source relative to its placement in the window
pannerNodeRef.current.positionX.value =
(bounds.x + bounds.width / 2) / windowSize - 0.5;
pannerNodeRef.current.positionY.value =
(bounds.y + bounds.height / 2) / windowSize - 0.5;
// Put the source in front of the listener
pannerNodeRef.current.positionZ.value = -2;
};
updatePosition();
source.connect(pannerNode).connect(audioDestination);
// HACK: We abuse the CSS transitionrun event to detect when the tile
// moves, because useMeasure, IntersectionObserver, etc. all have no
// ability to track changes in the CSS transform property
tile.addEventListener("transitionrun", updatePosition);
return () => {
tile.removeEventListener("transitionrun", updatePosition);
source.disconnect();
pannerNode.disconnect();
};
}
}, [stream, spatialAudio, audioContext, audioDestination, mute]);
return [tileRef, mediaRef];
};

6858
yarn.lock

File diff suppressed because it is too large Load Diff