Compare commits
395 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca5ce7d468 | ||
|
|
a05f6a64a8 | ||
|
|
70dffe95ff | ||
|
|
0360889fd6 | ||
|
|
7304411c5d | ||
|
|
22dd095ea9 | ||
|
|
30a270193f | ||
|
|
ee1dd2293e | ||
|
|
34d5e88def | ||
|
|
30c9dfce02 | ||
|
|
48ad4d040d | ||
|
|
1b4f097b1c | ||
|
|
7b6193ab62 | ||
|
|
10a2733fd5 | ||
|
|
e7353e184f | ||
|
|
a479863f88 | ||
|
|
c550545116 | ||
|
|
1d7da9c455 | ||
|
|
5be0fdea0b | ||
|
|
a2a6eaf695 | ||
|
|
d08573b6b8 | ||
|
|
af7daee3e7 | ||
|
|
3406b46db5 | ||
|
|
2b45cf1f67 | ||
|
|
ba4258aa89 | ||
|
|
fc0a3f38ac | ||
|
|
ad96da59c3 | ||
|
|
c7ce689739 | ||
|
|
fa0a8d30e7 | ||
|
|
b57ef84e66 | ||
|
|
e5432ef260 | ||
|
|
719156aadf | ||
|
|
0720005c93 | ||
|
|
897f127fbd | ||
|
|
fd8ade1bf1 | ||
|
|
7f6b0f572b | ||
|
|
a4d982ea62 | ||
|
|
317f27e5f9 | ||
|
|
b2427bd810 | ||
|
|
4ac5c2c677 | ||
|
|
2234962acc | ||
|
|
8f95da4b07 | ||
|
|
102bde65ba | ||
|
|
3d5421819f | ||
|
|
5167cacee8 | ||
|
|
882eed0737 | ||
|
|
e82ed2cbcb | ||
|
|
05466fbd7f | ||
|
|
2bfd26b2b5 | ||
|
|
a17b62b14c | ||
|
|
88cffdb70e | ||
|
|
51ae1c819a | ||
|
|
2608f9558c | ||
|
|
8176d60d96 | ||
|
|
2ce99b969d | ||
|
|
8b97904144 | ||
|
|
0e34f9a464 | ||
|
|
c09380644b | ||
|
|
1dfffce606 | ||
|
|
7e98b19587 | ||
|
|
2a1689009a | ||
|
|
5ef3b055ff | ||
|
|
f554afd6b1 | ||
|
|
5474693711 | ||
|
|
f9a41be530 | ||
|
|
c61bc46673 | ||
|
|
dd304d3569 | ||
|
|
2eff251e0c | ||
|
|
531db48c25 | ||
|
|
9c0ce6526c | ||
|
|
96123ccf63 | ||
|
|
305c2cb806 | ||
|
|
9af122b96e | ||
|
|
7ca08f2f30 | ||
|
|
c7dbfca53d | ||
|
|
8aa66dddfd | ||
|
|
eb43b96a1b | ||
|
|
a2963adbee | ||
|
|
baebfdb0bb | ||
|
|
c3c2f409e7 | ||
|
|
89312ceb58 | ||
|
|
9b915d289b | ||
|
|
3de8f9077d | ||
|
|
90b4e44bbe | ||
|
|
bd25b7f3b7 | ||
|
|
85dfb3c1e5 | ||
|
|
d16e42374f | ||
|
|
d56b802786 | ||
|
|
93db217239 | ||
|
|
33ef680c41 | ||
|
|
a150619d08 | ||
|
|
7d5fb5f041 | ||
|
|
e824b3cfe2 | ||
|
|
cd885e3b3a | ||
|
|
005622800d | ||
|
|
aef4fd39b9 | ||
|
|
2e57eaad1d | ||
|
|
a5d5f75f52 | ||
|
|
130073689d | ||
|
|
2d99acabe2 | ||
|
|
0e5231ba43 | ||
|
|
e62d76a6f2 | ||
|
|
ce55ed8221 | ||
|
|
c5e7fe7bdc | ||
|
|
c723fae0e2 | ||
|
|
68172d12b0 | ||
|
|
44ce76bcb1 | ||
|
|
44b9bd0046 | ||
|
|
2e38558a9d | ||
|
|
a679bfcd95 | ||
|
|
44315f327b | ||
|
|
4f7724dbaf | ||
|
|
dc3cc33893 | ||
|
|
2537088099 | ||
|
|
02aaa06cb3 | ||
|
|
abf5121b74 | ||
|
|
cc7584a223 | ||
|
|
43b6351237 | ||
|
|
3b74920ece | ||
|
|
005762a1a2 | ||
|
|
5841c4f38d | ||
|
|
6acc84fd9e | ||
|
|
afc072da2c | ||
|
|
8634c16a47 | ||
|
|
0aa3359f96 | ||
|
|
077e5b2998 | ||
|
|
4b01000d4c | ||
|
|
949d28a88f | ||
|
|
57cde41983 | ||
|
|
cb5b3e9468 | ||
|
|
69f19d24a3 | ||
|
|
549c54e311 | ||
|
|
ec7f9effd8 | ||
|
|
1f4cc7bb19 | ||
|
|
1d78e2bc20 | ||
|
|
942800a2a6 | ||
|
|
414996c3f5 | ||
|
|
0c3dab8dd2 | ||
|
|
c48f9a69cc | ||
|
|
3277887089 | ||
|
|
304339f589 | ||
|
|
45cfdef45d | ||
|
|
f440c3f2c8 | ||
|
|
db74a486c5 | ||
|
|
4f36d149d7 | ||
|
|
3727bfb67f | ||
|
|
f26ab2f941 | ||
|
|
cf56b24dda | ||
|
|
2a8cb3c4e2 | ||
|
|
5478e648a7 | ||
|
|
b47d633727 | ||
|
|
810cdeeab4 | ||
|
|
075049abc4 | ||
|
|
56afbe6eb1 | ||
|
|
cf309102a2 | ||
|
|
32b37ed8f0 | ||
|
|
ce8ac0a81c | ||
|
|
4d8e0d7b85 | ||
|
|
6d7f52d2d6 | ||
|
|
e63b3d1b3e | ||
|
|
d77d953f84 | ||
|
|
689835cc17 | ||
|
|
6456a6b0c0 | ||
|
|
996c5f86c1 | ||
|
|
3fc8fe505b | ||
|
|
daeecc9b68 | ||
|
|
982398b32f | ||
|
|
fae4c504c9 | ||
|
|
b4a56f6dd7 | ||
|
|
fc26bef80a | ||
|
|
034552a063 | ||
|
|
bb505273f4 | ||
|
|
f876df6acc | ||
|
|
d097223d41 | ||
|
|
d01f7be58a | ||
|
|
d5375ca9ed | ||
|
|
eda8404144 | ||
|
|
e17a7cedb6 | ||
|
|
4ad4cff23f | ||
|
|
cc7a44dc17 | ||
|
|
873e68e1e1 | ||
|
|
4f44a68198 | ||
|
|
1eab957d85 | ||
|
|
4c145af7a3 | ||
|
|
7fab4ca1ba | ||
|
|
c1e45c4a30 | ||
|
|
5784a005dc | ||
|
|
a3e4d6998f | ||
|
|
32907764b3 | ||
|
|
cb34b1634d | ||
|
|
5199fd2566 | ||
|
|
b31c6c6780 | ||
|
|
aeec2c076e | ||
|
|
8bbce188ef | ||
|
|
dbdc010764 | ||
|
|
a81c48cc22 | ||
|
|
6eb77b7c2f | ||
|
|
92a50fe51d | ||
|
|
572caf6826 | ||
|
|
b0c8ceb302 | ||
|
|
c9ae6532a0 | ||
|
|
619e3c4852 | ||
|
|
e5cfcb601b | ||
|
|
2b92bf3694 | ||
|
|
cd42d09ea9 | ||
|
|
c56b1c8a86 | ||
|
|
e8d99e15f7 | ||
|
|
4dcec504ca | ||
|
|
1308e52e42 | ||
|
|
f6d356c5ce | ||
|
|
eb2de869b8 | ||
|
|
c6030d33ca | ||
|
|
655058a7e6 | ||
|
|
16d4ffbe3a | ||
|
|
775125c8a7 | ||
|
|
631e63a0b5 | ||
|
|
4cb2306de0 | ||
|
|
f15ee439a9 | ||
|
|
b9a2473d19 | ||
|
|
5b58223f9d | ||
|
|
f34fd0bd00 | ||
|
|
984b02700e | ||
|
|
e310392800 | ||
|
|
2cc291dccd | ||
|
|
2dcf043787 | ||
|
|
6b03ae0dc3 | ||
|
|
5dd5668389 | ||
|
|
8380894692 | ||
|
|
94f16b986a | ||
|
|
2928df8b8c | ||
|
|
71a819fcf0 | ||
|
|
713136672a | ||
|
|
f1bd47be8c | ||
|
|
2e82960ae6 | ||
|
|
a31fcd7346 | ||
|
|
4a1a53d3ab | ||
|
|
be173a838d | ||
|
|
623bd52e1f | ||
|
|
5ebdf3e878 | ||
|
|
761eee2cdc | ||
|
|
831e49919b | ||
|
|
6d90586aee | ||
|
|
a7f0ade83a | ||
|
|
c49e300247 | ||
|
|
6d8e34762e | ||
|
|
33461f5ac2 | ||
|
|
4e3345482f | ||
|
|
7dc6fb27ea | ||
|
|
5ced94755b | ||
|
|
0ffd860fdb | ||
|
|
05e786e3d6 | ||
|
|
d5e638c8f7 | ||
|
|
122ffeeab5 | ||
|
|
1448eac7c1 | ||
|
|
f2dbd5ff96 | ||
|
|
dcae5ad5f2 | ||
|
|
9bd3ade93d | ||
|
|
22dcb883b3 | ||
|
|
2e945780de | ||
|
|
9033b688ab | ||
|
|
1d4ed6609d | ||
|
|
b0269e310f | ||
|
|
74ccf7d820 | ||
|
|
2eae6243bb | ||
|
|
276532e2e1 | ||
|
|
fc07dd2af9 | ||
|
|
989712c2d5 | ||
|
|
ee43fcc91f | ||
|
|
18ca92cec4 | ||
|
|
dc11814695 | ||
|
|
17a31e0904 | ||
|
|
f990530031 | ||
|
|
46f1f0f8e9 | ||
|
|
885e933948 | ||
|
|
9b2e99c559 | ||
|
|
60ed54d6d3 | ||
|
|
939398b277 | ||
|
|
d2c820f080 | ||
|
|
375578177b | ||
|
|
eb9f2ccbaa | ||
|
|
d4b211e678 | ||
|
|
9fc4fbc3e7 | ||
|
|
1f5ac411f6 | ||
|
|
a7748a8492 | ||
|
|
edbcf95ead | ||
|
|
0aa29f775c | ||
|
|
a4a6105bc9 | ||
|
|
23098131b8 | ||
|
|
fdcedb5592 | ||
|
|
17098cf2ab | ||
|
|
7ef3dcc56c | ||
|
|
8a38276f5d | ||
|
|
21ec08ffbd | ||
|
|
1a7211198b | ||
|
|
4f9efb3563 | ||
|
|
190c57e853 | ||
|
|
785eca7289 | ||
|
|
2667e78b43 | ||
|
|
878b48aa7a | ||
|
|
b314e047c1 | ||
|
|
69cfa1db6d | ||
|
|
977016fbb2 | ||
|
|
fb3d9e2a16 | ||
|
|
8da492d00d | ||
|
|
9676014120 | ||
|
|
7d87b8d1e5 | ||
|
|
ecb139721b | ||
|
|
aa45261b0d | ||
|
|
017ec13981 | ||
|
|
880a2ca127 | ||
|
|
5282ab5f12 | ||
|
|
582e6637dc | ||
|
|
65804cd962 | ||
|
|
0411e1cac8 | ||
|
|
bab5c9aa42 | ||
|
|
d680a36cab | ||
|
|
25bde3560b | ||
|
|
ddac2ba5ef | ||
|
|
cd55098921 | ||
|
|
f1bdad0d7f | ||
|
|
9fac2c95e5 | ||
|
|
486d0abd30 | ||
|
|
d9bd48b9a6 | ||
|
|
64e30c89e3 | ||
|
|
1860eaae7a | ||
|
|
771424cbf0 | ||
|
|
925a909ec1 | ||
|
|
f07ee54e05 | ||
|
|
7ee2f630db | ||
|
|
626fdb9f79 | ||
|
|
2cf40ff0b8 | ||
|
|
9edc1acc90 | ||
|
|
641e6c53b6 | ||
|
|
14fbddf780 | ||
|
|
2a69b72bed | ||
|
|
e21094b525 | ||
|
|
da3d038547 | ||
|
|
c6b90803f8 | ||
|
|
93baa19ba1 | ||
|
|
9444f43c72 | ||
|
|
26251e1e60 | ||
|
|
5b3183cbd3 | ||
|
|
e9b963080c | ||
|
|
1164e6f1e7 | ||
|
|
21c7bb979e | ||
|
|
1ff9073a1a | ||
|
|
7ed2f9bd9a | ||
|
|
2cdbeb6f12 | ||
|
|
7bd95621f1 | ||
|
|
a05501a909 | ||
|
|
e6960a1e15 | ||
|
|
c057713004 | ||
|
|
35e2135e3c | ||
|
|
af74228f8e | ||
|
|
9a44790450 | ||
|
|
5c4bab2a8a | ||
|
|
94380b64bd | ||
|
|
cbfd03f9c6 | ||
|
|
edf58f1d7d | ||
|
|
17fed7cd9c | ||
|
|
266861bdad | ||
|
|
426e1a433b | ||
|
|
3b8dfcec51 | ||
|
|
6f892edd5e | ||
|
|
126bfec339 | ||
|
|
59938cd46b | ||
|
|
a445bcd0b9 | ||
|
|
2acb6825e9 | ||
|
|
7d44a1e979 | ||
|
|
aa1fabf857 | ||
|
|
c714a0608c | ||
|
|
92d15e110a | ||
|
|
1367ff9914 | ||
|
|
7a2d64c0ef | ||
|
|
60b5f7cab2 | ||
|
|
d81c52e9bb | ||
|
|
c54f1bd7a3 | ||
|
|
24f721e414 | ||
|
|
3e19843bf7 | ||
|
|
183eea9f24 | ||
|
|
548ea7220b | ||
|
|
8cd45b64a1 | ||
|
|
c33d97a2ed | ||
|
|
7926a1f9b9 | ||
|
|
c7da1177ab | ||
|
|
1e5539f165 | ||
|
|
d019add257 | ||
|
|
cc8ce7a05c | ||
|
|
6913fddcd3 | ||
|
|
b3285974f9 | ||
|
|
7a9ff98550 | ||
|
|
3d54047f87 | ||
|
|
e2aee0be81 | ||
|
|
44486aa62d | ||
|
|
a0e4de73cc |
@@ -14,12 +14,17 @@
|
||||
# VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
|
||||
|
||||
# VITE_CUSTOM_THEME=true
|
||||
# VITE_PRIMARY_COLOR=#0dbd8b
|
||||
# VITE_BG_COLOR_1=#ffffff
|
||||
# VITE_BG_COLOR_2=#f0f1f4
|
||||
# VITE_BG_COLOR_3=#dbdfe4
|
||||
# VITE_BG_COLOR_4=#d1d3d7
|
||||
# VITE_INPUT_BORDER_COLOR=#e7e7e7
|
||||
# VITE_INPUT_BORDER_COLOR_FOCUSED=#238cf5
|
||||
# VITE_TEXT_COLOR_1=#17191c
|
||||
# VITE_TEXT_COLOR_2=#61708b
|
||||
# VITE_THEME_ACCENT=#0dbd8b
|
||||
# VITE_THEME_ACCENT_20=#0dbd8b33
|
||||
# VITE_THEME_ALERT=#ff5b55
|
||||
# VITE_THEME_ALERT_20=#ff5b5533
|
||||
# VITE_THEME_LINKS=#0086e6
|
||||
# VITE_THEME_PRIMARY_CONTENT=#ffffff
|
||||
# VITE_THEME_SECONDARY_CONTENT=#a9b2bc
|
||||
# VITE_THEME_TERTIARY_CONTENT=#8e99a4
|
||||
# VITE_THEME_TERTIARY_CONTENT_20=#8e99a433
|
||||
# VITE_THEME_QUATERNARY_CONTENT=#6f7882
|
||||
# VITE_THEME_QUINARY_CONTENT=#394049
|
||||
# VITE_THEME_SYSTEM=#21262c
|
||||
# VITE_THEME_BACKGROUND=#15191e
|
||||
# VITE_THEME_BACKGROUND_85=#15191ed9
|
||||
@@ -16,9 +16,6 @@ module.exports = {
|
||||
"sourceType": "module",
|
||||
},
|
||||
rules: {
|
||||
// We break this rule in a few places: dial it back to a warning
|
||||
// (and run with max warnings) to tolerate the existing code
|
||||
"react-hooks/exhaustive-deps": ["warn"],
|
||||
"jsx-a11y/media-has-caption": ["off"],
|
||||
},
|
||||
overrides: [
|
||||
|
||||
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @vector-im/element-call-reviewers
|
||||
67
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal 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
@@ -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
@@ -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
|
||||
4
.github/workflows/publish.yaml
vendored
@@ -32,10 +32,14 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
.env
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
18
.vscode/settings.json
vendored
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16-buster as builder
|
||||
FROM --platform=$BUILDPLATFORM node:16-buster as builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
13
package.json
@@ -8,11 +8,13 @@
|
||||
"build-storybook": "build-storybook",
|
||||
"prettier:check": "prettier -c src",
|
||||
"prettier:format": "prettier -w src",
|
||||
"lint:js": "eslint --max-warnings 2 src",
|
||||
"lint": "yarn lint:types && yarn lint:js",
|
||||
"lint:js": "eslint --max-warnings 0 src",
|
||||
"lint:types": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@juggle/resize-observer": "^3.3.1",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
|
||||
"@react-aria/button": "^3.3.4",
|
||||
"@react-aria/dialog": "^3.1.4",
|
||||
"@react-aria/focus": "^3.5.0",
|
||||
@@ -30,16 +32,18 @@
|
||||
"@react-stately/tree": "^3.2.0",
|
||||
"@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",
|
||||
"lodash-move": "^1.1.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#acef1d7dd0b915368730efabee94deb42b2e4058",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#83c848093fe49652aedee71d963dfe07fd6d73f2",
|
||||
"matrix-widget-api": "^1.0.0",
|
||||
"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",
|
||||
@@ -48,6 +52,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": {
|
||||
|
||||
13
src/@types/global.d.ts
vendored
@@ -15,3 +15,16 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import "matrix-js-sdk/src/@types/global";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
||||
OLM_OPTIONS: Record<string, string>;
|
||||
}
|
||||
|
||||
// TypeScript doesn't know about the experimental setSinkId method, so we
|
||||
// declare it ourselves
|
||||
interface MediaElement extends HTMLVideoElement {
|
||||
setSinkId: (id: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import React from "react";
|
||||
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { OverlayProvider } from "@react-aria/overlays";
|
||||
|
||||
import { HomePage } from "./home/HomePage";
|
||||
import { LoginPage } from "./auth/LoginPage";
|
||||
import { RegisterPage } from "./auth/RegisterPage";
|
||||
@@ -26,37 +27,49 @@ import { RoomRedirect } from "./room/RoomRedirect";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { usePageFocusStyle } from "./usePageFocusStyle";
|
||||
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
|
||||
import { InspectorContextProvider } from "./room/GroupCallInspector";
|
||||
import { CrashView } from "./FullScreenView";
|
||||
|
||||
const SentryRoute = Sentry.withSentryRouting(Route);
|
||||
|
||||
export default function App({ history }) {
|
||||
interface AppProps {
|
||||
history: History;
|
||||
}
|
||||
|
||||
export default function App({ history }: AppProps) {
|
||||
usePageFocusStyle();
|
||||
|
||||
const errorPage = <CrashView />;
|
||||
|
||||
return (
|
||||
<Router history={history}>
|
||||
<ClientProvider>
|
||||
<OverlayProvider>
|
||||
<Switch>
|
||||
<SentryRoute exact path="/">
|
||||
<HomePage />
|
||||
</SentryRoute>
|
||||
<SentryRoute exact path="/login">
|
||||
<LoginPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute exact path="/register">
|
||||
<RegisterPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="/room/:roomId?">
|
||||
<RoomPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="/inspector">
|
||||
<SequenceDiagramViewerPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="*">
|
||||
<RoomRedirect />
|
||||
</SentryRoute>
|
||||
</Switch>
|
||||
</OverlayProvider>
|
||||
<InspectorContextProvider>
|
||||
<Sentry.ErrorBoundary fallback={errorPage}>
|
||||
<OverlayProvider>
|
||||
<Switch>
|
||||
<SentryRoute exact path="/">
|
||||
<HomePage />
|
||||
</SentryRoute>
|
||||
<SentryRoute exact path="/login">
|
||||
<LoginPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute exact path="/register">
|
||||
<RegisterPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="/room/:roomId?">
|
||||
<RoomPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="/inspector">
|
||||
<SequenceDiagramViewerPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="*">
|
||||
<RoomRedirect />
|
||||
</SentryRoute>
|
||||
</Switch>
|
||||
</OverlayProvider>
|
||||
</Sentry.ErrorBoundary>
|
||||
</InspectorContextProvider>
|
||||
</ClientProvider>
|
||||
</Router>
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
.avatar {
|
||||
position: relative;
|
||||
color: #ffffff;
|
||||
color: var(--primary-content);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.avatar svg * {
|
||||
fill: #ffffff;
|
||||
fill: var(--primary-content);
|
||||
}
|
||||
|
||||
.avatar span {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, CSSProperties } from "react";
|
||||
import classNames from "classnames";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import { getAvatarUrl } from "./matrix-utils";
|
||||
import { useClient } from "./ClientContext";
|
||||
import styles from "./Avatar.module.css";
|
||||
|
||||
const backgroundColors = [
|
||||
@@ -14,6 +17,22 @@ const backgroundColors = [
|
||||
"#74D12C",
|
||||
];
|
||||
|
||||
export enum Size {
|
||||
XS = "xs",
|
||||
SM = "sm",
|
||||
MD = "md",
|
||||
LG = "lg",
|
||||
XL = "xl",
|
||||
}
|
||||
|
||||
export const sizes = new Map([
|
||||
[Size.XS, 22],
|
||||
[Size.SM, 32],
|
||||
[Size.MD, 36],
|
||||
[Size.LG, 42],
|
||||
[Size.XL, 90],
|
||||
]);
|
||||
|
||||
function hashStringToArrIndex(str: string, arrLength: number) {
|
||||
let sum = 0;
|
||||
|
||||
@@ -24,24 +43,51 @@ function hashStringToArrIndex(str: string, arrLength: number) {
|
||||
return sum % arrLength;
|
||||
}
|
||||
|
||||
const resolveAvatarSrc = (client: MatrixClient, src: string, size: number) =>
|
||||
src?.startsWith("mxc://") ? client && getAvatarUrl(client, src, size) : src;
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
bgKey?: string;
|
||||
src: string;
|
||||
src?: string;
|
||||
size?: Size | number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
fallback: string;
|
||||
size?: number;
|
||||
className: string;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const Avatar: React.FC<Props> = ({
|
||||
bgKey,
|
||||
src,
|
||||
fallback,
|
||||
size,
|
||||
size = Size.MD,
|
||||
className,
|
||||
style,
|
||||
style = {},
|
||||
...rest
|
||||
}) => {
|
||||
const { client } = useClient();
|
||||
|
||||
const [sizeClass, sizePx, sizeStyle] = useMemo(
|
||||
() =>
|
||||
Object.values(Size).includes(size as Size)
|
||||
? [styles[size as string], sizes.get(size as Size), {}]
|
||||
: [
|
||||
null,
|
||||
size as number,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size,
|
||||
fontSize: Math.round((size as number) / 2),
|
||||
},
|
||||
],
|
||||
[size]
|
||||
);
|
||||
|
||||
const resolvedSrc = useMemo(
|
||||
() => resolveAvatarSrc(client, src, sizePx),
|
||||
[client, src, sizePx]
|
||||
);
|
||||
|
||||
const backgroundColor = useMemo(() => {
|
||||
const index = hashStringToArrIndex(
|
||||
bgKey || fallback || src || "",
|
||||
@@ -53,12 +99,12 @@ export const Avatar: React.FC<Props> = ({
|
||||
/* eslint-disable jsx-a11y/alt-text */
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.avatar, styles[size || "md"], className)}
|
||||
style={{ backgroundColor, ...style }}
|
||||
className={classNames(styles.avatar, sizeClass, className)}
|
||||
style={{ backgroundColor, ...sizeStyle, ...style }}
|
||||
{...rest}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} />
|
||||
{resolvedSrc ? (
|
||||
<img src={resolvedSrc} />
|
||||
) : typeof fallback === "string" ? (
|
||||
<span>{fallback}</span>
|
||||
) : (
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
createContext,
|
||||
useMemo,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { ErrorView } from "./FullScreenView";
|
||||
import { initClient, defaultHomeserver } from "./matrix-utils";
|
||||
|
||||
const ClientContext = createContext();
|
||||
|
||||
export function ClientProvider({ children }) {
|
||||
const history = useHistory();
|
||||
const [
|
||||
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
|
||||
setState,
|
||||
] = useState({
|
||||
loading: true,
|
||||
isAuthenticated: false,
|
||||
isPasswordlessUser: false,
|
||||
client: undefined,
|
||||
userName: null,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function restore() {
|
||||
try {
|
||||
const authStore = localStorage.getItem("matrix-auth-store");
|
||||
|
||||
if (authStore) {
|
||||
const {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
passwordlessUser,
|
||||
tempPassword,
|
||||
} = JSON.parse(authStore);
|
||||
|
||||
const client = await initClient({
|
||||
baseUrl: defaultHomeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
});
|
||||
|
||||
localStorage.setItem(
|
||||
"matrix-auth-store",
|
||||
JSON.stringify({
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
|
||||
passwordlessUser,
|
||||
tempPassword,
|
||||
})
|
||||
);
|
||||
|
||||
return { client, passwordlessUser };
|
||||
}
|
||||
|
||||
return { client: undefined };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
localStorage.removeItem("matrix-auth-store");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
restore()
|
||||
.then(({ client, passwordlessUser }) => {
|
||||
setState({
|
||||
client,
|
||||
loading: false,
|
||||
isAuthenticated: !!client,
|
||||
isPasswordlessUser: !!passwordlessUser,
|
||||
userName: client?.getUserIdLocalpart(),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setState({
|
||||
client: undefined,
|
||||
loading: false,
|
||||
isAuthenticated: false,
|
||||
isPasswordlessUser: false,
|
||||
userName: null,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password) => {
|
||||
const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse(
|
||||
localStorage.getItem("matrix-auth-store")
|
||||
);
|
||||
|
||||
await client.setPassword(
|
||||
{
|
||||
type: "m.login.password",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: existingSession.user_id,
|
||||
},
|
||||
user: existingSession.user_id,
|
||||
password: tempPassword,
|
||||
},
|
||||
password
|
||||
);
|
||||
|
||||
localStorage.setItem(
|
||||
"matrix-auth-store",
|
||||
JSON.stringify({
|
||||
...existingSession,
|
||||
passwordlessUser: false,
|
||||
})
|
||||
);
|
||||
|
||||
setState({
|
||||
client,
|
||||
loading: false,
|
||||
isAuthenticated: true,
|
||||
isPasswordlessUser: false,
|
||||
userName: client.getUserIdLocalpart(),
|
||||
});
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
const setClient = useCallback(
|
||||
(newClient, session) => {
|
||||
if (client && client !== newClient) {
|
||||
client.stopClient();
|
||||
}
|
||||
|
||||
if (newClient) {
|
||||
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
|
||||
|
||||
setState({
|
||||
client: newClient,
|
||||
loading: false,
|
||||
isAuthenticated: true,
|
||||
isPasswordlessUser: !!session.passwordlessUser,
|
||||
userName: newClient.getUserIdLocalpart(),
|
||||
});
|
||||
} else {
|
||||
localStorage.removeItem("matrix-auth-store");
|
||||
|
||||
setState({
|
||||
client: undefined,
|
||||
loading: false,
|
||||
isAuthenticated: false,
|
||||
isPasswordlessUser: false,
|
||||
userName: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem("matrix-auth-store");
|
||||
window.location = "/";
|
||||
}, [history]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
const loadTime = Date.now();
|
||||
|
||||
const onToDeviceEvent = (event) => {
|
||||
if (event.getType() !== "org.matrix.call_duplicate_session") {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = event.getContent();
|
||||
|
||||
if (content.session_id === client.getSessionId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.timestamp > loadTime) {
|
||||
if (client) {
|
||||
client.stopClient();
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: new Error(
|
||||
"This application has been opened in another tab."
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
client.on("toDeviceEvent", onToDeviceEvent);
|
||||
|
||||
client.sendToDevice("org.matrix.call_duplicate_session", {
|
||||
[client.getUserId()]: {
|
||||
"*": { session_id: client.getSessionId(), timestamp: loadTime },
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
client.removeListener("toDeviceEvent", onToDeviceEvent);
|
||||
};
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
loading,
|
||||
isAuthenticated,
|
||||
isPasswordlessUser,
|
||||
client,
|
||||
changePassword,
|
||||
logout,
|
||||
userName,
|
||||
setClient,
|
||||
}),
|
||||
[
|
||||
loading,
|
||||
isAuthenticated,
|
||||
isPasswordlessUser,
|
||||
client,
|
||||
changePassword,
|
||||
logout,
|
||||
userName,
|
||||
setClient,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.matrixclient = client;
|
||||
}, [client]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorView error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useClient() {
|
||||
return useContext(ClientContext);
|
||||
}
|
||||
338
src/ClientContext.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
Copyright 2021-2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
createContext,
|
||||
useMemo,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { ErrorView } from "./FullScreenView";
|
||||
import {
|
||||
initClient,
|
||||
initMatroskaClient,
|
||||
defaultHomeserver,
|
||||
CryptoStoreIntegrityError,
|
||||
} from "./matrix-utils";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
matrixclient: MatrixClient;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
access_token: string;
|
||||
passwordlessUser: boolean;
|
||||
tempPassword?: string;
|
||||
}
|
||||
|
||||
const loadSession = (): Session => {
|
||||
const data = localStorage.getItem("matrix-auth-store");
|
||||
if (data) return JSON.parse(data);
|
||||
return null;
|
||||
};
|
||||
const saveSession = (session: Session) =>
|
||||
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
|
||||
const clearSession = () => localStorage.removeItem("matrix-auth-store");
|
||||
|
||||
interface ClientState {
|
||||
loading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isPasswordlessUser: boolean;
|
||||
client: MatrixClient;
|
||||
userName: string;
|
||||
changePassword: (password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
setClient: (client: MatrixClient, session: Session) => void;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
const ClientContext = createContext<ClientState>(null);
|
||||
|
||||
type ClientProviderState = Omit<
|
||||
ClientState,
|
||||
"changePassword" | "logout" | "setClient"
|
||||
> & { error?: Error };
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
const history = useHistory();
|
||||
const [
|
||||
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
|
||||
setState,
|
||||
] = useState<ClientProviderState>({
|
||||
loading: true,
|
||||
isAuthenticated: false,
|
||||
isPasswordlessUser: false,
|
||||
client: undefined,
|
||||
userName: null,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const init = async (): Promise<
|
||||
Pick<ClientProviderState, "client" | "isPasswordlessUser">
|
||||
> => {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const widgetId = query.get("widgetId");
|
||||
const parentUrl = query.get("parentUrl");
|
||||
|
||||
if (widgetId && parentUrl) {
|
||||
// We're inside a widget, so let's engage *Matroska mode*
|
||||
logger.log("Using a Matroska client");
|
||||
|
||||
return {
|
||||
client: await initMatroskaClient(widgetId, parentUrl),
|
||||
isPasswordlessUser: false,
|
||||
};
|
||||
} else {
|
||||
// We're running as a standalone application
|
||||
try {
|
||||
const session = loadSession();
|
||||
if (!session) return { client: undefined, isPasswordlessUser: false };
|
||||
|
||||
logger.log("Using a standalone client");
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
const { user_id, device_id, access_token, passwordlessUser } =
|
||||
session;
|
||||
|
||||
try {
|
||||
return {
|
||||
client: await initClient(
|
||||
{
|
||||
baseUrl: defaultHomeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
true
|
||||
),
|
||||
isPasswordlessUser: passwordlessUser,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof CryptoStoreIntegrityError) {
|
||||
// We can't use this session anymore, so let's log it out
|
||||
try {
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: defaultHomeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false // Don't need the crypto store just to log out
|
||||
);
|
||||
await client.logout(undefined, true);
|
||||
} catch (err_) {
|
||||
logger.warn(
|
||||
"The previous session was lost, and we couldn't log it out, " +
|
||||
"either"
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
} catch (err) {
|
||||
clearSession();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init()
|
||||
.then(({ client, isPasswordlessUser }) => {
|
||||
setState({
|
||||
client,
|
||||
loading: false,
|
||||
isAuthenticated: Boolean(client),
|
||||
isPasswordlessUser,
|
||||
userName: client?.getUserIdLocalpart(),
|
||||
error: undefined,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err);
|
||||
setState({
|
||||
client: undefined,
|
||||
loading: false,
|
||||
isAuthenticated: false,
|
||||
isPasswordlessUser: false,
|
||||
userName: null,
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
const { tempPassword, ...session } = loadSession();
|
||||
|
||||
await client.setPassword(
|
||||
{
|
||||
type: "m.login.password",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: session.user_id,
|
||||
},
|
||||
user: session.user_id,
|
||||
password: tempPassword,
|
||||
},
|
||||
password
|
||||
);
|
||||
|
||||
saveSession({ ...session, passwordlessUser: false });
|
||||
|
||||
setState({
|
||||
client,
|
||||
loading: false,
|
||||
isAuthenticated: true,
|
||||
isPasswordlessUser: false,
|
||||
userName: client.getUserIdLocalpart(),
|
||||
error: undefined,
|
||||
});
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
const setClient = useCallback(
|
||||
(newClient: MatrixClient, session: Session) => {
|
||||
if (client && client !== newClient) {
|
||||
client.stopClient();
|
||||
}
|
||||
|
||||
if (newClient) {
|
||||
saveSession(session);
|
||||
|
||||
setState({
|
||||
client: newClient,
|
||||
loading: false,
|
||||
isAuthenticated: true,
|
||||
isPasswordlessUser: session.passwordlessUser,
|
||||
userName: newClient.getUserIdLocalpart(),
|
||||
error: undefined,
|
||||
});
|
||||
} else {
|
||||
clearSession();
|
||||
|
||||
setState({
|
||||
client: undefined,
|
||||
loading: false,
|
||||
isAuthenticated: false,
|
||||
isPasswordlessUser: false,
|
||||
userName: null,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearSession();
|
||||
history.push("/");
|
||||
}, [history]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
const loadTime = Date.now();
|
||||
|
||||
const onToDeviceEvent = (event: MatrixEvent) => {
|
||||
if (event.getType() !== "org.matrix.call_duplicate_session") return;
|
||||
|
||||
const content = event.getContent();
|
||||
|
||||
if (content.session_id === client.getSessionId()) return;
|
||||
|
||||
if (content.timestamp > loadTime) {
|
||||
client?.stopClient();
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: new Error(
|
||||
"This application has been opened in another tab."
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
client.on(ClientEvent.ToDeviceEvent, onToDeviceEvent);
|
||||
|
||||
client.sendToDevice("org.matrix.call_duplicate_session", {
|
||||
[client.getUserId()]: {
|
||||
"*": { session_id: client.getSessionId(), timestamp: loadTime },
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
|
||||
};
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const context = useMemo<ClientState>(
|
||||
() => ({
|
||||
loading,
|
||||
isAuthenticated,
|
||||
isPasswordlessUser,
|
||||
client,
|
||||
changePassword,
|
||||
logout,
|
||||
userName,
|
||||
setClient,
|
||||
error: undefined,
|
||||
}),
|
||||
[
|
||||
loading,
|
||||
isAuthenticated,
|
||||
isPasswordlessUser,
|
||||
client,
|
||||
changePassword,
|
||||
logout,
|
||||
userName,
|
||||
setClient,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.matrixclient = client;
|
||||
}, [client]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorView error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useClient = () => useContext(ClientContext);
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from "react";
|
||||
import styles from "./Facepile.module.css";
|
||||
import classNames from "classnames";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { getAvatarUrl } from "./matrix-utils";
|
||||
|
||||
const overlapMap = {
|
||||
xs: 2,
|
||||
sm: 4,
|
||||
md: 8,
|
||||
};
|
||||
|
||||
const sizeMap = {
|
||||
xs: 24,
|
||||
sm: 32,
|
||||
md: 36,
|
||||
};
|
||||
|
||||
export function Facepile({
|
||||
className,
|
||||
client,
|
||||
participants,
|
||||
max,
|
||||
size,
|
||||
...rest
|
||||
}) {
|
||||
const _size = sizeMap[size];
|
||||
const _overlap = overlapMap[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.facepile, styles[size], className)}
|
||||
title={participants.map((member) => member.name).join(", ")}
|
||||
style={{ width: participants.length * (_size - _overlap) + _overlap }}
|
||||
{...rest}
|
||||
>
|
||||
{participants.slice(0, max).map((member, i) => {
|
||||
const avatarUrl = member.user?.avatarUrl;
|
||||
return (
|
||||
<Avatar
|
||||
key={member.userId}
|
||||
size={size}
|
||||
src={avatarUrl && getAvatarUrl(client, avatarUrl, _size)}
|
||||
fallback={member.name.slice(0, 1).toUpperCase()}
|
||||
className={styles.avatar}
|
||||
style={{ left: i * (_size - _overlap) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{participants.length > max && (
|
||||
<Avatar
|
||||
key="additional"
|
||||
size={size}
|
||||
fallback={`+${participants.length - max}`}
|
||||
className={styles.avatar}
|
||||
style={{ left: max * (_size - _overlap) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Facepile.defaultProps = {
|
||||
max: 3,
|
||||
size: "xs",
|
||||
};
|
||||
@@ -18,7 +18,7 @@
|
||||
.facepile .avatar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
border: 1px solid var(--bgColor2);
|
||||
border: 1px solid var(--system);
|
||||
}
|
||||
|
||||
.facepile.md .avatar {
|
||||
|
||||
85
src/Facepile.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { HTMLAttributes } from "react";
|
||||
import classNames from "classnames";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
import styles from "./Facepile.module.css";
|
||||
import { Avatar, Size, sizes } from "./Avatar";
|
||||
|
||||
const overlapMap: Partial<Record<Size, number>> = {
|
||||
[Size.XS]: 2,
|
||||
[Size.SM]: 4,
|
||||
[Size.MD]: 8,
|
||||
};
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
className: string;
|
||||
client: MatrixClient;
|
||||
participants: RoomMember[];
|
||||
max?: number;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export function Facepile({
|
||||
className,
|
||||
client,
|
||||
participants,
|
||||
max = 3,
|
||||
size = Size.XS,
|
||||
...rest
|
||||
}: Props) {
|
||||
const _size = sizes.get(size);
|
||||
const _overlap = overlapMap[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.facepile, styles[size], className)}
|
||||
title={participants.map((member) => member.name).join(", ")}
|
||||
style={{
|
||||
width:
|
||||
Math.min(participants.length, max + 1) * (_size - _overlap) +
|
||||
_overlap,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{participants.slice(0, max).map((member, i) => {
|
||||
const avatarUrl = member.user?.avatarUrl;
|
||||
return (
|
||||
<Avatar
|
||||
key={member.userId}
|
||||
size={size}
|
||||
src={avatarUrl}
|
||||
fallback={member.name.slice(0, 1).toUpperCase()}
|
||||
className={styles.avatar}
|
||||
style={{ left: i * (_size - _overlap) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{participants.length > max && (
|
||||
<Avatar
|
||||
key="additional"
|
||||
size={size}
|
||||
fallback={`+${participants.length - max}`}
|
||||
className={styles.avatar}
|
||||
style={{ left: max * (_size - _overlap) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styles from "./FullScreenView.module.css";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
|
||||
import classNames from "classnames";
|
||||
import { LinkButton, Button } from "./button";
|
||||
|
||||
export function FullScreenView({ className, children }) {
|
||||
return (
|
||||
<div className={classNames(styles.page, className)}>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav />
|
||||
</Header>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorView({ error }) {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
const onReload = useCallback(() => {
|
||||
window.location = "/";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>Error</h1>
|
||||
<p>{error.message}</p>
|
||||
{location.pathname === "/" ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
onPress={onReload}
|
||||
>
|
||||
Return to home screen
|
||||
</Button>
|
||||
) : (
|
||||
<LinkButton
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
to="/"
|
||||
>
|
||||
Return to home screen
|
||||
</LinkButton>
|
||||
)}
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingView() {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>Loading...</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,12 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.homeLink {
|
||||
/* Make the buttons the same width */
|
||||
.wideButton {
|
||||
width: 291px;
|
||||
}
|
||||
|
||||
/* Fixed height to avoid content jumping around*/
|
||||
.sendLogsSection {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
130
src/FullScreenView.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { ReactNode, useCallback, useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
|
||||
import { LinkButton, Button } from "./button";
|
||||
import { useSubmitRageshake } from "./settings/submit-rageshake";
|
||||
import { ErrorMessage } from "./input/Input";
|
||||
import styles from "./FullScreenView.module.css";
|
||||
|
||||
interface FullScreenViewProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FullScreenView({ className, children }: FullScreenViewProps) {
|
||||
return (
|
||||
<div className={classNames(styles.page, className)}>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav />
|
||||
</Header>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorViewProps {
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export function ErrorView({ error }: ErrorViewProps) {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
const onReload = useCallback(() => {
|
||||
window.location.href = "/";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>Error</h1>
|
||||
<p>{error.message}</p>
|
||||
{location.pathname === "/" ? (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
onPress={onReload}
|
||||
>
|
||||
Return to home screen
|
||||
</Button>
|
||||
) : (
|
||||
<LinkButton
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.homeLink}
|
||||
to="/"
|
||||
>
|
||||
Return to home screen
|
||||
</LinkButton>
|
||||
)}
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
||||
export function CrashView() {
|
||||
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
|
||||
|
||||
const sendDebugLogs = useCallback(() => {
|
||||
submitRageshake({
|
||||
description: "**Soft Crash**",
|
||||
sendLogs: true,
|
||||
});
|
||||
}, [submitRageshake]);
|
||||
|
||||
const onReload = useCallback(() => {
|
||||
window.location.href = "/";
|
||||
}, []);
|
||||
|
||||
let logsComponent;
|
||||
if (sent) {
|
||||
logsComponent = <div>Thanks! We'll get right on it.</div>;
|
||||
} else if (sending) {
|
||||
logsComponent = <div>Sending...</div>;
|
||||
} else {
|
||||
logsComponent = (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="default"
|
||||
onPress={sendDebugLogs}
|
||||
className={styles.wideButton}
|
||||
>
|
||||
Send debug logs
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>Oops, something's gone wrong.</h1>
|
||||
<p>Submitting debug logs will help us track down the problem.</p>
|
||||
<div className={styles.sendLogsSection}>{logsComponent}</div>
|
||||
{error && <ErrorMessage>Couldn't send debug logs!</ErrorMessage>}
|
||||
<Button
|
||||
size="lg"
|
||||
variant="default"
|
||||
className={styles.wideButton}
|
||||
onPress={onReload}
|
||||
>
|
||||
Return to home screen
|
||||
</Button>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingView() {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>Loading...</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import React, { useRef } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styles from "./Header.module.css";
|
||||
import { ReactComponent as Logo } from "./icons/Logo.svg";
|
||||
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
|
||||
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
|
||||
import { useButton } from "@react-aria/button";
|
||||
import { Subtitle } from "./typography/Typography";
|
||||
import { Avatar } from "./Avatar";
|
||||
|
||||
export function Header({ children, className, ...rest }) {
|
||||
return (
|
||||
<header className={classNames(styles.header, className)} {...rest}>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeftNav({ children, className, hideMobile, ...rest }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.nav,
|
||||
styles.leftNav,
|
||||
{ [styles.hideMobile]: hideMobile },
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RightNav({ children, className, hideMobile, ...rest }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.nav,
|
||||
styles.rightNav,
|
||||
{ [styles.hideMobile]: hideMobile },
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderLogo({ className }) {
|
||||
return (
|
||||
<Link className={classNames(styles.headerLogo, className)} to="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomHeaderInfo({ roomName }) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.roomAvatar}>
|
||||
<Avatar
|
||||
size="md"
|
||||
bgKey={roomName}
|
||||
fallback={roomName.slice(0, 1).toUpperCase()}
|
||||
/>
|
||||
<VideoIcon width={16} height={16} />
|
||||
</div>
|
||||
<Subtitle fontWeight="semiBold">{roomName}</Subtitle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomSetupHeaderInfo({ roomName, ...rest }) {
|
||||
const ref = useRef();
|
||||
const { buttonProps } = useButton(rest, ref);
|
||||
return (
|
||||
<button className={styles.backButton} ref={ref} {...buttonProps}>
|
||||
<ArrowLeftIcon width={16} height={16} />
|
||||
<RoomHeaderInfo roomName={roomName} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
color: var(--textColor1);
|
||||
color: var(--primary-content);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
178
src/Header.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import classNames from "classnames";
|
||||
import React, { HTMLAttributes, ReactNode, useCallback, useRef } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useButton } from "@react-aria/button";
|
||||
import { AriaButtonProps } from "@react-types/button";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import styles from "./Header.module.css";
|
||||
import { useModalTriggerState } from "./Modal";
|
||||
import { Button } from "./button";
|
||||
import { ReactComponent as Logo } from "./icons/Logo.svg";
|
||||
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
|
||||
import { Subtitle } from "./typography/Typography";
|
||||
import { Avatar, Size } from "./Avatar";
|
||||
import { IncompatibleVersionModal } from "./IncompatibleVersionModal";
|
||||
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
|
||||
|
||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Header({ children, className, ...rest }: HeaderProps) {
|
||||
return (
|
||||
<header className={classNames(styles.header, className)} {...rest}>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
interface LeftNavProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hideMobile?: boolean;
|
||||
}
|
||||
|
||||
export function LeftNav({
|
||||
children,
|
||||
className,
|
||||
hideMobile,
|
||||
...rest
|
||||
}: LeftNavProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.nav,
|
||||
styles.leftNav,
|
||||
{ [styles.hideMobile]: hideMobile },
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RightNavProps extends HTMLAttributes<HTMLElement> {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
hideMobile?: boolean;
|
||||
}
|
||||
|
||||
export function RightNav({
|
||||
children,
|
||||
className,
|
||||
hideMobile,
|
||||
...rest
|
||||
}: RightNavProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.nav,
|
||||
styles.rightNav,
|
||||
{ [styles.hideMobile]: hideMobile },
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeaderLogoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HeaderLogo({ className }: HeaderLogoProps) {
|
||||
return (
|
||||
<Link className={classNames(styles.headerLogo, className)} to="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoomHeaderInfo {
|
||||
roomName: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.roomAvatar}>
|
||||
<Avatar
|
||||
size={Size.MD}
|
||||
src={avatarUrl}
|
||||
bgKey={roomName}
|
||||
fallback={roomName.slice(0, 1).toUpperCase()}
|
||||
/>
|
||||
<VideoIcon width={16} height={16} />
|
||||
</div>
|
||||
<Subtitle fontWeight="semiBold">{roomName}</Subtitle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoomSetupHeaderInfoProps extends AriaButtonProps<"button"> {
|
||||
roomName: string;
|
||||
avatarUrl: string;
|
||||
isEmbedded: boolean;
|
||||
}
|
||||
|
||||
export function RoomSetupHeaderInfo({
|
||||
roomName,
|
||||
avatarUrl,
|
||||
isEmbedded,
|
||||
...rest
|
||||
}: RoomSetupHeaderInfoProps) {
|
||||
const ref = useRef();
|
||||
const { buttonProps } = useButton(rest, ref);
|
||||
|
||||
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} />
|
||||
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface VersionMismatchWarningProps {
|
||||
users: Set<string>;
|
||||
room: Room;
|
||||
}
|
||||
|
||||
export function VersionMismatchWarning({
|
||||
users,
|
||||
room,
|
||||
}: VersionMismatchWarningProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
48
src/IncompatibleVersionModal.tsx
Normal 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/src/models/room";
|
||||
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>
|
||||
);
|
||||
};
|
||||
6
src/IndexedDBWorker.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IndexedDBStoreWorker } from "matrix-js-sdk/src/indexeddb-worker";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const remoteWorker = new IndexedDBStoreWorker((self as any).postMessage);
|
||||
|
||||
self.onmessage = remoteWorker.onMessage;
|
||||
@@ -1,50 +0,0 @@
|
||||
import React, { useRef } from "react";
|
||||
import { useListBox, useOption } from "@react-aria/listbox";
|
||||
import styles from "./ListBox.module.css";
|
||||
import classNames from "classnames";
|
||||
|
||||
export function ListBox(props) {
|
||||
const ref = useRef();
|
||||
let { listBoxRef = ref, state } = props;
|
||||
const { listBoxProps } = useListBox(props, state, listBoxRef);
|
||||
|
||||
return (
|
||||
<ul
|
||||
{...listBoxProps}
|
||||
ref={listBoxRef}
|
||||
className={classNames(styles.listBox, props.className)}
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Option
|
||||
key={item.key}
|
||||
item={item}
|
||||
state={state}
|
||||
className={props.optionClassName}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Option({ item, state, className }) {
|
||||
const ref = useRef();
|
||||
const { optionProps, isSelected, isFocused, isDisabled } = useOption(
|
||||
{ key: item.key },
|
||||
state,
|
||||
ref
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
{...optionProps}
|
||||
ref={ref}
|
||||
className={classNames(styles.option, className, {
|
||||
[styles.selected]: isSelected,
|
||||
[styles.focused]: isFocused,
|
||||
[styles.disables]: isDisabled,
|
||||
})}
|
||||
>
|
||||
{item.rendered}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
background-color: var(--bgColor1);
|
||||
border: 1px solid var(--quinary-content);
|
||||
background-color: var(--background);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: transparent;
|
||||
color: var(--textColor1);
|
||||
color: var(--primary-content);
|
||||
padding: 8px 16px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
@@ -28,6 +28,6 @@
|
||||
}
|
||||
|
||||
.option.disabled {
|
||||
color: var(--textColor2);
|
||||
color: var(--quaternary-content);
|
||||
background-color: var(--bgColor3);
|
||||
}
|
||||
|
||||
89
src/ListBox.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { useListBox, useOption, AriaListBoxOptions } from "@react-aria/listbox";
|
||||
import { ListState } from "@react-stately/list";
|
||||
import { Node } from "@react-types/shared";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./ListBox.module.css";
|
||||
|
||||
interface ListBoxProps<T> extends AriaListBoxOptions<T> {
|
||||
optionClassName: string;
|
||||
state: ListState<T>;
|
||||
className?: string;
|
||||
listBoxRef?: React.MutableRefObject<HTMLUListElement>;
|
||||
}
|
||||
|
||||
export function ListBox<T>({
|
||||
state,
|
||||
optionClassName,
|
||||
className,
|
||||
listBoxRef,
|
||||
...rest
|
||||
}: ListBoxProps<T>) {
|
||||
const ref = useRef<HTMLUListElement>();
|
||||
if (!listBoxRef) listBoxRef = ref;
|
||||
|
||||
const { listBoxProps } = useListBox(rest, state, listBoxRef);
|
||||
|
||||
return (
|
||||
<ul
|
||||
{...listBoxProps}
|
||||
ref={listBoxRef}
|
||||
className={classNames(styles.listBox, className)}
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Option
|
||||
key={item.key}
|
||||
item={item}
|
||||
state={state}
|
||||
className={optionClassName}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionProps<T> {
|
||||
className: string;
|
||||
state: ListState<T>;
|
||||
item: Node<T>;
|
||||
}
|
||||
|
||||
function Option<T>({ item, state, className }: OptionProps<T>) {
|
||||
const ref = useRef();
|
||||
const { optionProps, isSelected, isFocused, isDisabled } = useOption(
|
||||
{ key: item.key },
|
||||
state,
|
||||
ref
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
{...optionProps}
|
||||
ref={ref}
|
||||
className={classNames(styles.option, className, {
|
||||
[styles.selected]: isSelected,
|
||||
[styles.focused]: isFocused,
|
||||
[styles.disables]: isDisabled,
|
||||
})}
|
||||
>
|
||||
{item.rendered}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
color: var(--textColor1);
|
||||
color: var(--primary-content);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
.menuItem.focused,
|
||||
.menuItem:hover {
|
||||
background-color: var(--bgColor4);
|
||||
background-color: var(--quinary-content);
|
||||
}
|
||||
|
||||
.menuItem.focused:first-child,
|
||||
@@ -39,3 +39,12 @@
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.checkIcon * {
|
||||
stroke: var(--primary-content);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import styles from "./Menu.module.css";
|
||||
import { useMenu, useMenuItem } from "@react-aria/menu";
|
||||
import { useTreeState } from "@react-stately/tree";
|
||||
import React, { Key, useRef, useState } from "react";
|
||||
import { AriaMenuOptions, useMenu, useMenuItem } from "@react-aria/menu";
|
||||
import { TreeState, useTreeState } from "@react-stately/tree";
|
||||
import { mergeProps } from "@react-aria/utils";
|
||||
import { useFocus } from "@react-aria/interactions";
|
||||
import classNames from "classnames";
|
||||
import { Node } from "@react-types/shared";
|
||||
|
||||
export function Menu({ className, onAction, ...rest }) {
|
||||
const state = useTreeState({ ...rest, selectionMode: "none" });
|
||||
import styles from "./Menu.module.css";
|
||||
|
||||
interface MenuProps<T> extends AriaMenuOptions<T> {
|
||||
className?: String;
|
||||
onClose?: () => void;
|
||||
onAction: (value: Key) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Menu<T extends object>({
|
||||
className,
|
||||
onAction,
|
||||
onClose,
|
||||
label,
|
||||
...rest
|
||||
}: MenuProps<T>) {
|
||||
const state = useTreeState<T>({ ...rest, selectionMode: "none" });
|
||||
const menuRef = useRef();
|
||||
const { menuProps } = useMenu(rest, state, menuRef);
|
||||
const { menuProps } = useMenu<T>(rest, state, menuRef);
|
||||
|
||||
return (
|
||||
<ul
|
||||
@@ -23,19 +38,25 @@ export function Menu({ className, onAction, ...rest }) {
|
||||
item={item}
|
||||
state={state}
|
||||
onAction={onAction}
|
||||
onClose={rest.onClose}
|
||||
onClose={onClose}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({ item, state, onAction, onClose }) {
|
||||
interface MenuItemProps<T> {
|
||||
item: Node<T>;
|
||||
state: TreeState<T>;
|
||||
onAction: (value: Key) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function MenuItem<T>({ item, state, onAction, onClose }: MenuItemProps<T>) {
|
||||
const ref = useRef();
|
||||
const { menuItemProps } = useMenuItem(
|
||||
{
|
||||
key: item.key,
|
||||
isDisabled: item.isDisabled,
|
||||
onAction,
|
||||
onClose,
|
||||
},
|
||||
@@ -28,6 +28,7 @@
|
||||
}
|
||||
|
||||
.modalHeader h3 {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,73 @@
|
||||
import React, { useRef, useMemo } from "react";
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable jsx-a11y/no-autofocus */
|
||||
|
||||
import React, { useRef, useMemo, ReactNode } from "react";
|
||||
import {
|
||||
useOverlay,
|
||||
usePreventScroll,
|
||||
useModal,
|
||||
OverlayContainer,
|
||||
OverlayProps,
|
||||
} from "@react-aria/overlays";
|
||||
import { useOverlayTriggerState } from "@react-stately/overlays";
|
||||
import {
|
||||
OverlayTriggerState,
|
||||
useOverlayTriggerState,
|
||||
} from "@react-stately/overlays";
|
||||
import { useDialog } from "@react-aria/dialog";
|
||||
import { FocusScope } from "@react-aria/focus";
|
||||
import { useButton } from "@react-aria/button";
|
||||
import { ButtonAria, useButton } from "@react-aria/button";
|
||||
import classNames from "classnames";
|
||||
import { AriaDialogProps } from "@react-types/dialog";
|
||||
|
||||
import { ReactComponent as CloseIcon } from "./icons/Close.svg";
|
||||
import styles from "./Modal.module.css";
|
||||
import classNames from "classnames";
|
||||
|
||||
export function Modal(props) {
|
||||
const { title, children, className, mobileFullScreen } = props;
|
||||
export interface ModalProps extends OverlayProps, AriaDialogProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
mobileFullScreen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
mobileFullScreen,
|
||||
onClose,
|
||||
...rest
|
||||
}: ModalProps) {
|
||||
const modalRef = useRef();
|
||||
const { overlayProps, underlayProps } = useOverlay(props, modalRef);
|
||||
const { overlayProps, underlayProps } = useOverlay(
|
||||
{ ...rest, onClose },
|
||||
modalRef
|
||||
);
|
||||
usePreventScroll();
|
||||
const { modalProps } = useModal();
|
||||
const { dialogProps, titleProps } = useDialog(props, modalRef);
|
||||
const { dialogProps, titleProps } = useDialog(rest, modalRef);
|
||||
const closeButtonRef = useRef();
|
||||
const { buttonProps: closeButtonProps } = useButton({
|
||||
onPress: () => props.onClose(),
|
||||
});
|
||||
const { buttonProps: closeButtonProps } = useButton(
|
||||
{
|
||||
onPress: () => onClose(),
|
||||
},
|
||||
closeButtonRef
|
||||
);
|
||||
|
||||
return (
|
||||
<OverlayContainer>
|
||||
@@ -58,7 +102,16 @@ export function Modal(props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalContent({ children, className, ...rest }) {
|
||||
interface ModalContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModalContent({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: ModalContentProps) {
|
||||
return (
|
||||
<div className={classNames(styles.content, className)} {...rest}>
|
||||
{children}
|
||||
@@ -66,7 +119,10 @@ export function ModalContent({ children, className, ...rest }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function useModalTriggerState() {
|
||||
export function useModalTriggerState(): {
|
||||
modalState: OverlayTriggerState;
|
||||
modalProps: { isOpen: boolean; onClose: () => void };
|
||||
} {
|
||||
const modalState = useOverlayTriggerState({});
|
||||
const modalProps = useMemo(
|
||||
() => ({ isOpen: modalState.isOpen, onClose: modalState.close }),
|
||||
@@ -75,7 +131,10 @@ export function useModalTriggerState() {
|
||||
return { modalState, modalProps };
|
||||
}
|
||||
|
||||
export function useToggleModalButton(modalState, ref) {
|
||||
export function useToggleModalButton(
|
||||
modalState: OverlayTriggerState,
|
||||
ref: React.RefObject<HTMLButtonElement>
|
||||
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
||||
return useButton(
|
||||
{
|
||||
onPress: () => modalState.toggle(),
|
||||
@@ -84,7 +143,10 @@ export function useToggleModalButton(modalState, ref) {
|
||||
);
|
||||
}
|
||||
|
||||
export function useOpenModalButton(modalState, ref) {
|
||||
export function useOpenModalButton(
|
||||
modalState: OverlayTriggerState,
|
||||
ref: React.RefObject<HTMLButtonElement>
|
||||
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
||||
return useButton(
|
||||
{
|
||||
onPress: () => modalState.open(),
|
||||
@@ -93,7 +155,10 @@ export function useOpenModalButton(modalState, ref) {
|
||||
);
|
||||
}
|
||||
|
||||
export function useCloseModalButton(modalState, ref) {
|
||||
export function useCloseModalButton(
|
||||
modalState: OverlayTriggerState,
|
||||
ref: React.RefObject<HTMLButtonElement>
|
||||
): ButtonAria<React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
||||
return useButton(
|
||||
{
|
||||
onPress: () => modalState.close(),
|
||||
@@ -102,8 +167,12 @@ export function useCloseModalButton(modalState, ref) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalTrigger({ children }) {
|
||||
const { modalState, modalProps } = useModalState();
|
||||
interface ModalTriggerProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ModalTrigger({ children }: ModalTriggerProps) {
|
||||
const { modalState, modalProps } = useModalTriggerState();
|
||||
const buttonRef = useRef();
|
||||
const { buttonProps } = useToggleModalButton(modalState, buttonRef);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { SequenceDiagramViewer } from "./room/GroupCallInspector";
|
||||
import { FieldRow, InputField } from "./input/Input";
|
||||
import { usePageTitle } from "./usePageTitle";
|
||||
|
||||
export function SequenceDiagramViewerPage() {
|
||||
usePageTitle("Inspector");
|
||||
|
||||
const [debugLog, setDebugLog] = useState();
|
||||
const [selectedUserId, setSelectedUserId] = useState();
|
||||
const onChangeDebugLog = useCallback((e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
e.target.files[0].text().then((text) => {
|
||||
setDebugLog(JSON.parse(text));
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
type="file"
|
||||
id="debugLog"
|
||||
name="debugLog"
|
||||
label="Debug Log"
|
||||
onChange={onChangeDebugLog}
|
||||
/>
|
||||
</FieldRow>
|
||||
{debugLog && (
|
||||
<SequenceDiagramViewer
|
||||
localUserId={debugLog.localUserId}
|
||||
selectedUserId={selectedUserId}
|
||||
onSelectUserId={setSelectedUserId}
|
||||
remoteUserIds={debugLog.remoteUserIds}
|
||||
events={debugLog.eventsByUserId[selectedUserId]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/SequenceDiagramViewerPage.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
SequenceDiagramViewer,
|
||||
SequenceDiagramMatrixEvent,
|
||||
} from "./room/GroupCallInspector";
|
||||
import { FieldRow, InputField } from "./input/Input";
|
||||
import { usePageTitle } from "./usePageTitle";
|
||||
|
||||
interface DebugLog {
|
||||
localUserId: string;
|
||||
eventsByUserId: { [userId: string]: SequenceDiagramMatrixEvent[] };
|
||||
remoteUserIds: string[];
|
||||
}
|
||||
|
||||
export function SequenceDiagramViewerPage() {
|
||||
usePageTitle("Inspector");
|
||||
|
||||
const [debugLog, setDebugLog] = useState<DebugLog>();
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>();
|
||||
const onChangeDebugLog = useCallback((e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
e.target.files[0].text().then((text: string) => {
|
||||
setDebugLog(JSON.parse(text));
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
type="file"
|
||||
id="debugLog"
|
||||
name="debugLog"
|
||||
label="Debug Log"
|
||||
onChange={onChangeDebugLog}
|
||||
/>
|
||||
</FieldRow>
|
||||
{debugLog && (
|
||||
<SequenceDiagramViewer
|
||||
localUserId={debugLog.localUserId}
|
||||
selectedUserId={selectedUserId}
|
||||
onSelectUserId={setSelectedUserId}
|
||||
remoteUserIds={debugLog.remoteUserIds}
|
||||
events={debugLog.eventsByUserId[selectedUserId]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import { useTooltipTriggerState } from "@react-stately/tooltip";
|
||||
import { FocusableProvider } from "@react-aria/focus";
|
||||
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
|
||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||
import styles from "./Tooltip.module.css";
|
||||
import classNames from "classnames";
|
||||
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
|
||||
|
||||
export const Tooltip = forwardRef(
|
||||
({ position, state, className, ...props }, ref) => {
|
||||
let { tooltipProps } = useTooltip(props, state);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.tooltip, className)}
|
||||
{...mergeProps(props, tooltipProps)}
|
||||
ref={ref}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => {
|
||||
const tooltipState = useTooltipTriggerState(rest);
|
||||
const triggerRef = useObjectRef(ref);
|
||||
const overlayRef = useRef();
|
||||
const { triggerProps, tooltipProps } = useTooltipTrigger(
|
||||
rest,
|
||||
tooltipState,
|
||||
triggerRef
|
||||
);
|
||||
|
||||
const { overlayProps } = useOverlayPosition({
|
||||
placement: rest.placement || "top",
|
||||
targetRef: triggerRef,
|
||||
overlayRef,
|
||||
isOpen: tooltipState.isOpen,
|
||||
offset: 5,
|
||||
});
|
||||
|
||||
if (
|
||||
!Array.isArray(children) ||
|
||||
children.length > 2 ||
|
||||
typeof children[1] !== "function"
|
||||
) {
|
||||
throw new Error(
|
||||
"TooltipTrigger must have two props. The first being a button and the second being a render prop."
|
||||
);
|
||||
}
|
||||
|
||||
const [tooltipTrigger, tooltip] = children;
|
||||
|
||||
return (
|
||||
<FocusableProvider ref={triggerRef} {...triggerProps}>
|
||||
{<tooltipTrigger.type {...mergeProps(tooltipTrigger.props, rest)} />}
|
||||
{tooltipState.isOpen && (
|
||||
<OverlayContainer>
|
||||
<Tooltip
|
||||
state={tooltipState}
|
||||
{...mergeProps(tooltipProps, overlayProps)}
|
||||
ref={overlayRef}
|
||||
>
|
||||
{tooltip()}
|
||||
</Tooltip>
|
||||
</OverlayContainer>
|
||||
)}
|
||||
</FocusableProvider>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipTrigger.defaultProps = {
|
||||
delay: 250,
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
.tooltip {
|
||||
background-color: var(--bgColor2);
|
||||
background-color: var(--system);
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
color: var(--textColor1);
|
||||
color: var(--primary-content);
|
||||
border-radius: 8px;
|
||||
max-width: 135px;
|
||||
width: max-content;
|
||||
|
||||
114
src/Tooltip.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
TooltipTriggerState,
|
||||
useTooltipTriggerState,
|
||||
} from "@react-stately/tooltip";
|
||||
import { FocusableProvider } from "@react-aria/focus";
|
||||
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
|
||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||
import classNames from "classnames";
|
||||
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
|
||||
import { Placement } from "@react-types/overlays";
|
||||
|
||||
import styles from "./Tooltip.module.css";
|
||||
|
||||
interface TooltipProps {
|
||||
className?: string;
|
||||
state: TooltipTriggerState;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
|
||||
(
|
||||
{ state, className, children, ...rest }: TooltipProps,
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const { tooltipProps } = useTooltip(rest, state);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.tooltip, className)}
|
||||
{...mergeProps(rest, tooltipProps)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface TooltipTriggerProps {
|
||||
children: ReactElement;
|
||||
placement?: Placement;
|
||||
delay?: number;
|
||||
tooltip: () => string;
|
||||
}
|
||||
|
||||
export const TooltipTrigger = forwardRef<HTMLElement, TooltipTriggerProps>(
|
||||
(
|
||||
{ children, placement, tooltip, ...rest }: TooltipTriggerProps,
|
||||
ref: ForwardedRef<HTMLElement>
|
||||
) => {
|
||||
const tooltipTriggerProps = { delay: 250, ...rest };
|
||||
const tooltipState = useTooltipTriggerState(tooltipTriggerProps);
|
||||
const triggerRef = useObjectRef<HTMLElement>(ref);
|
||||
const overlayRef = useRef();
|
||||
const { triggerProps, tooltipProps } = useTooltipTrigger(
|
||||
tooltipTriggerProps,
|
||||
tooltipState,
|
||||
triggerRef
|
||||
);
|
||||
|
||||
const { overlayProps } = useOverlayPosition({
|
||||
placement: placement || "top",
|
||||
targetRef: triggerRef,
|
||||
overlayRef,
|
||||
isOpen: tooltipState.isOpen,
|
||||
offset: 5,
|
||||
});
|
||||
|
||||
return (
|
||||
<FocusableProvider ref={triggerRef} {...triggerProps}>
|
||||
<children.type
|
||||
{...mergeProps<typeof children.props | typeof rest>(
|
||||
children.props,
|
||||
rest
|
||||
)}
|
||||
/>
|
||||
{tooltipState.isOpen && (
|
||||
<OverlayContainer>
|
||||
<Tooltip
|
||||
state={tooltipState}
|
||||
ref={overlayRef}
|
||||
{...mergeProps(tooltipProps, overlayProps)}
|
||||
>
|
||||
{tooltip()}
|
||||
</Tooltip>
|
||||
</OverlayContainer>
|
||||
)}
|
||||
</FocusableProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -4,7 +4,7 @@
|
||||
}
|
||||
|
||||
.userButton svg * {
|
||||
fill: var(--textColor1);
|
||||
fill: var(--primary-content);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Item } from "@react-stately/collections";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import { Button, LinkButton } from "./button";
|
||||
import { PopoverMenuTrigger } from "./popover/PopoverMenu";
|
||||
import { Menu } from "./Menu";
|
||||
import { Tooltip, TooltipTrigger } from "./Tooltip";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { TooltipTrigger } from "./Tooltip";
|
||||
import { Avatar, Size } from "./Avatar";
|
||||
import { ReactComponent as UserIcon } from "./icons/User.svg";
|
||||
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
|
||||
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
|
||||
import styles from "./UserMenu.module.css";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Body } from "./typography/Typography";
|
||||
import styles from "./UserMenu.module.css";
|
||||
|
||||
interface UserMenuProps {
|
||||
preventNavigation: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isPasswordlessUser: boolean;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
onAction: (value: string) => void;
|
||||
}
|
||||
|
||||
export function UserMenu({
|
||||
preventNavigation,
|
||||
@@ -19,7 +29,7 @@ export function UserMenu({
|
||||
displayName,
|
||||
avatarUrl,
|
||||
onAction,
|
||||
}) {
|
||||
}: UserMenuProps) {
|
||||
const location = useLocation();
|
||||
|
||||
const items = useMemo(() => {
|
||||
@@ -62,11 +72,11 @@ export function UserMenu({
|
||||
|
||||
return (
|
||||
<PopoverMenuTrigger placement="bottom right">
|
||||
<TooltipTrigger placement="bottom left">
|
||||
<TooltipTrigger tooltip={() => "Profile"} placement="bottom left">
|
||||
<Button variant="icon" className={styles.userButton}>
|
||||
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
|
||||
<Avatar
|
||||
size="sm"
|
||||
size={Size.SM}
|
||||
className={styles.avatar}
|
||||
src={avatarUrl}
|
||||
fallback={displayName.slice(0, 1).toUpperCase()}
|
||||
@@ -75,12 +85,11 @@ export function UserMenu({
|
||||
<UserIcon />
|
||||
)}
|
||||
</Button>
|
||||
{() => "Profile"}
|
||||
</TooltipTrigger>
|
||||
{(props) => (
|
||||
<Menu {...props} label="User menu" onAction={onAction}>
|
||||
{items.map(({ key, icon: Icon, label }) => (
|
||||
<Item key={key} textValue={label} className={styles.menuItem}>
|
||||
<Item key={key} textValue={label}>
|
||||
<Icon width={24} height={24} className={styles.menuIcon} />
|
||||
<Body overflowEllipsis>{label}</Body>
|
||||
</Item>
|
||||
@@ -1,12 +1,17 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
|
||||
import { useClient } from "./ClientContext";
|
||||
import { useProfile } from "./profile/useProfile";
|
||||
import { useModalTriggerState } from "./Modal";
|
||||
import { ProfileModal } from "./profile/ProfileModal";
|
||||
import { UserMenu } from "./UserMenu";
|
||||
|
||||
export function UserMenuContainer({ preventNavigation }) {
|
||||
interface Props {
|
||||
preventNavigation?: boolean;
|
||||
}
|
||||
|
||||
export function UserMenuContainer({ preventNavigation = false }: Props) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { isAuthenticated, isPasswordlessUser, logout, userName, client } =
|
||||
@@ -15,7 +20,7 @@ export function UserMenuContainer({ preventNavigation }) {
|
||||
const { modalState, modalProps } = useModalTriggerState();
|
||||
|
||||
const onAction = useCallback(
|
||||
(value) => {
|
||||
(value: string) => {
|
||||
switch (value) {
|
||||
case "user":
|
||||
modalState.open();
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
.authLinks a {
|
||||
color: #0dbd8b;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
Copyright 2021-2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,9 +14,18 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState, useMemo } from "react";
|
||||
import React, {
|
||||
FC,
|
||||
FormEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useHistory, useLocation, Link } from "react-router-dom";
|
||||
|
||||
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { Button } from "../button";
|
||||
import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils";
|
||||
@@ -24,27 +33,30 @@ import styles from "./LoginPage.module.css";
|
||||
import { useInteractiveLogin } from "./useInteractiveLogin";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
|
||||
export function LoginPage() {
|
||||
export const LoginPage: FC = () => {
|
||||
usePageTitle("Login");
|
||||
|
||||
const [_, login] = useInteractiveLogin();
|
||||
const [homeserver, setHomeServer] = useState(defaultHomeserver);
|
||||
const usernameRef = useRef();
|
||||
const passwordRef = useRef();
|
||||
const { setClient } = useClient();
|
||||
const login = useInteractiveLogin();
|
||||
const homeserver = defaultHomeserver; // TODO: Make this configurable
|
||||
const usernameRef = useRef<HTMLInputElement>();
|
||||
const passwordRef = useRef<HTMLInputElement>();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [error, setError] = useState<Error>();
|
||||
|
||||
// TODO: Handle hitting login page with authenticated client
|
||||
|
||||
const onSubmitLoginForm = useCallback(
|
||||
(e) => {
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
login(homeserver, usernameRef.current.value, passwordRef.current.value)
|
||||
.then(() => {
|
||||
.then(([client, session]) => {
|
||||
setClient(client, session);
|
||||
|
||||
if (location.state && location.state.from) {
|
||||
history.push(location.state.from);
|
||||
} else {
|
||||
@@ -56,13 +68,13 @@ export function LoginPage() {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[login, location, history, homeserver]
|
||||
[login, location, history, homeserver, setClient]
|
||||
);
|
||||
|
||||
const homeserverHost = useMemo(() => {
|
||||
try {
|
||||
return new URL(homeserver).host;
|
||||
} catch (_error) {
|
||||
} catch (error) {
|
||||
return defaultHomeserverHost;
|
||||
}
|
||||
}, [homeserver]);
|
||||
@@ -121,4 +133,4 @@ export function LoginPage() {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
Copyright 2021-2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,8 +14,19 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
FC,
|
||||
FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { captureException } from "@sentry/react";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { Button } from "../button";
|
||||
import { useClient } from "../ClientContext";
|
||||
@@ -28,68 +39,69 @@ import { useRecaptcha } from "./useRecaptcha";
|
||||
import { Caption, Link } from "../typography/Typography";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
|
||||
export function RegisterPage() {
|
||||
export const RegisterPage: FC = () => {
|
||||
usePageTitle("Register");
|
||||
|
||||
const { loading, isAuthenticated, isPasswordlessUser, client } = useClient();
|
||||
const confirmPasswordRef = useRef();
|
||||
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
|
||||
useClient();
|
||||
const confirmPasswordRef = useRef<HTMLInputElement>();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [error, setError] = useState<Error>();
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
||||
const [{ privacyPolicyUrl, recaptchaKey }, register] =
|
||||
const [privacyPolicyUrl, recaptchaKey, register] =
|
||||
useInteractiveRegistration();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const onSubmitRegisterForm = useCallback(
|
||||
(e) => {
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target);
|
||||
const userName = data.get("userName");
|
||||
const password = data.get("password");
|
||||
const passwordConfirmation = data.get("passwordConfirmation");
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const userName = data.get("userName") as string;
|
||||
const password = data.get("password") as string;
|
||||
const passwordConfirmation = data.get("passwordConfirmation") as string;
|
||||
|
||||
if (password !== passwordConfirmation) {
|
||||
return;
|
||||
}
|
||||
if (password !== passwordConfirmation) return;
|
||||
|
||||
async function submit() {
|
||||
const submit = async () => {
|
||||
setRegistering(true);
|
||||
|
||||
let roomIds;
|
||||
|
||||
if (client && isPasswordlessUser) {
|
||||
const groupCalls = client.groupCallEventHandler.groupCalls.values();
|
||||
roomIds = Array.from(groupCalls).map(
|
||||
(groupCall) => groupCall.room.roomId
|
||||
);
|
||||
}
|
||||
|
||||
const recaptchaResponse = await execute();
|
||||
const newClient = await register(
|
||||
const [newClient, session] = await register(
|
||||
userName,
|
||||
password,
|
||||
userName,
|
||||
recaptchaResponse
|
||||
);
|
||||
|
||||
if (roomIds) {
|
||||
for (const roomId of roomIds) {
|
||||
if (client && isPasswordlessUser) {
|
||||
// Migrate the user's rooms
|
||||
for (const groupCall of client.groupCallEventHandler.groupCalls.values()) {
|
||||
const roomId = groupCall.room.roomId;
|
||||
|
||||
try {
|
||||
await newClient.joinRoom(roomId);
|
||||
} catch (error) {
|
||||
console.warn(`Couldn't join room ${roomId}`, error);
|
||||
if (error.errcode === "M_LIMIT_EXCEEDED") {
|
||||
await sleep(error.data.retry_after_ms);
|
||||
await newClient.joinRoom(roomId);
|
||||
} else {
|
||||
captureException(error);
|
||||
console.error(`Couldn't join room ${roomId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setClient(newClient, session);
|
||||
};
|
||||
|
||||
submit()
|
||||
.then(() => {
|
||||
if (location.state && location.state.from) {
|
||||
history.push(location.state.from);
|
||||
if (location.state?.from) {
|
||||
history.push(location.state?.from);
|
||||
} else {
|
||||
history.push("/");
|
||||
}
|
||||
@@ -100,18 +112,23 @@ export function RegisterPage() {
|
||||
reset();
|
||||
});
|
||||
},
|
||||
[register, location, history, isPasswordlessUser, reset, execute, client]
|
||||
[
|
||||
register,
|
||||
location,
|
||||
history,
|
||||
isPasswordlessUser,
|
||||
reset,
|
||||
execute,
|
||||
client,
|
||||
setClient,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmPasswordRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (password && passwordConfirmation && password !== passwordConfirmation) {
|
||||
confirmPasswordRef.current.setCustomValidity("Passwords must match");
|
||||
confirmPasswordRef.current?.setCustomValidity("Passwords must match");
|
||||
} else {
|
||||
confirmPasswordRef.current.setCustomValidity("");
|
||||
confirmPasswordRef.current?.setCustomValidity("");
|
||||
}
|
||||
}, [password, passwordConfirmation]);
|
||||
|
||||
@@ -119,7 +136,7 @@ export function RegisterPage() {
|
||||
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
|
||||
history.push("/");
|
||||
}
|
||||
}, [history, isAuthenticated, isPasswordlessUser, registering]);
|
||||
}, [loading, history, isAuthenticated, isPasswordlessUser, registering]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingView />;
|
||||
@@ -150,7 +167,9 @@ export function RegisterPage() {
|
||||
required
|
||||
name="password"
|
||||
type="password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setPassword(e.target.value)
|
||||
}
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
label="Password"
|
||||
@@ -161,7 +180,9 @@ export function RegisterPage() {
|
||||
required
|
||||
type="password"
|
||||
name="passwordConfirmation"
|
||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setPasswordConfirmation(e.target.value)
|
||||
}
|
||||
value={passwordConfirmation}
|
||||
placeholder="Confirm Password"
|
||||
label="Confirm Password"
|
||||
@@ -207,4 +228,4 @@ export function RegisterPage() {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,64 +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 matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { initClient, defaultHomeserver } from "../matrix-utils";
|
||||
|
||||
export function useInteractiveLogin() {
|
||||
const { setClient } = useClient();
|
||||
const [state, setState] = useState({ loading: false });
|
||||
|
||||
const auth = useCallback(
|
||||
async (homeserver, username, password) => {
|
||||
const authClient = matrix.createClient(homeserver);
|
||||
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
busyChanged(loading) {
|
||||
setState((prev) => ({ ...prev, loading }));
|
||||
},
|
||||
async doRequest(_auth, _background) {
|
||||
return authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { user_id, access_token, device_id } =
|
||||
await interactiveAuth.attemptAuth();
|
||||
|
||||
const client = await initClient({
|
||||
baseUrl: defaultHomeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
});
|
||||
|
||||
setClient(client, { user_id, access_token, device_id });
|
||||
|
||||
return client;
|
||||
},
|
||||
[setClient]
|
||||
);
|
||||
|
||||
return [state, auth];
|
||||
}
|
||||
72
src/auth/useInteractiveLogin.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { initClient, defaultHomeserver } from "../matrix-utils";
|
||||
import { Session } from "../ClientContext";
|
||||
|
||||
export const useInteractiveLogin = () =>
|
||||
useCallback<
|
||||
(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string
|
||||
) => Promise<[MatrixClient, Session]>
|
||||
>(async (homeserver: string, username: string, password: string) => {
|
||||
const authClient = createClient(homeserver);
|
||||
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: () =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
}),
|
||||
stateUpdated: null,
|
||||
requestEmailToken: null,
|
||||
});
|
||||
|
||||
// XXX: This claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||
const { user_id, access_token, device_id } =
|
||||
(await interactiveAuth.attemptAuth()) as any;
|
||||
const session = {
|
||||
user_id,
|
||||
access_token,
|
||||
device_id,
|
||||
passwordlessUser: false,
|
||||
};
|
||||
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: defaultHomeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false
|
||||
);
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
return [client, session];
|
||||
}, []);
|
||||
@@ -1,112 +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 matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { initClient, defaultHomeserver } from "../matrix-utils";
|
||||
|
||||
export function useInteractiveRegistration() {
|
||||
const { setClient } = useClient();
|
||||
const [state, setState] = useState({ privacyPolicyUrl: "#", loading: false });
|
||||
|
||||
const authClientRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
authClientRef.current = matrix.createClient(defaultHomeserver);
|
||||
|
||||
authClientRef.current.registerRequest({}).catch((error) => {
|
||||
const privacyPolicyUrl =
|
||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url;
|
||||
|
||||
const recaptchaKey = error.data?.params["m.login.recaptcha"]?.public_key;
|
||||
|
||||
if (privacyPolicyUrl || recaptchaKey) {
|
||||
setState((prev) => ({ ...prev, privacyPolicyUrl, recaptchaKey }));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const register = useCallback(
|
||||
async (
|
||||
username,
|
||||
password,
|
||||
displayName,
|
||||
recaptchaResponse,
|
||||
passwordlessUser
|
||||
) => {
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClientRef.current,
|
||||
busyChanged(loading) {
|
||||
setState((prev) => ({ ...prev, loading }));
|
||||
},
|
||||
async doRequest(auth, _background) {
|
||||
return authClientRef.current.registerRequest({
|
||||
username,
|
||||
password,
|
||||
auth: auth || undefined,
|
||||
});
|
||||
},
|
||||
stateUpdated(nextStage, status) {
|
||||
if (status.error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
if (nextStage === "m.login.terms") {
|
||||
interactiveAuth.submitAuthDict({
|
||||
type: "m.login.terms",
|
||||
});
|
||||
} else if (nextStage === "m.login.recaptcha") {
|
||||
interactiveAuth.submitAuthDict({
|
||||
type: "m.login.recaptcha",
|
||||
response: recaptchaResponse,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { user_id, access_token, device_id } =
|
||||
await interactiveAuth.attemptAuth();
|
||||
|
||||
const client = await initClient({
|
||||
baseUrl: defaultHomeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
});
|
||||
|
||||
await client.setDisplayName(displayName);
|
||||
|
||||
const session = { user_id, device_id, access_token, passwordlessUser };
|
||||
|
||||
if (passwordlessUser) {
|
||||
session.tempPassword = password;
|
||||
}
|
||||
|
||||
setClient(client, session);
|
||||
|
||||
const user = client.getUser(client.getUserId());
|
||||
|
||||
user.setRawDisplayName(displayName);
|
||||
user.setDisplayName(displayName);
|
||||
|
||||
return client;
|
||||
},
|
||||
[setClient]
|
||||
);
|
||||
|
||||
return [state, register];
|
||||
}
|
||||
127
src/auth/useInteractiveRegistration.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { initClient, defaultHomeserver } from "../matrix-utils";
|
||||
import { Session } from "../ClientContext";
|
||||
|
||||
export const useInteractiveRegistration = (): [
|
||||
string,
|
||||
string,
|
||||
(
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser?: boolean
|
||||
) => Promise<[MatrixClient, Session]>
|
||||
] => {
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string>();
|
||||
const [recaptchaKey, setRecaptchaKey] = useState<string>();
|
||||
|
||||
const authClient = useRef<MatrixClient>();
|
||||
if (!authClient.current) {
|
||||
authClient.current = createClient(defaultHomeserver);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
authClient.current.registerRequest({}).catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const register = useCallback(
|
||||
async (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser?: boolean
|
||||
): Promise<[MatrixClient, Session]> => {
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient.current,
|
||||
doRequest: (auth) =>
|
||||
authClient.current.registerRequest({
|
||||
username,
|
||||
password,
|
||||
auth: auth || undefined,
|
||||
}),
|
||||
stateUpdated: (nextStage, status) => {
|
||||
if (status.error) {
|
||||
throw new Error(status.error);
|
||||
}
|
||||
|
||||
if (nextStage === "m.login.terms") {
|
||||
interactiveAuth.submitAuthDict({
|
||||
type: "m.login.terms",
|
||||
});
|
||||
} else if (nextStage === "m.login.recaptcha") {
|
||||
interactiveAuth.submitAuthDict({
|
||||
type: "m.login.recaptcha",
|
||||
response: recaptchaResponse,
|
||||
});
|
||||
}
|
||||
},
|
||||
requestEmailToken: null,
|
||||
});
|
||||
|
||||
// XXX: This claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||
const { user_id, access_token, device_id } =
|
||||
(await interactiveAuth.attemptAuth()) as any;
|
||||
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: defaultHomeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
await client.setDisplayName(displayName);
|
||||
|
||||
const session: Session = {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
passwordlessUser,
|
||||
};
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
if (passwordlessUser) {
|
||||
session.tempPassword = password;
|
||||
}
|
||||
|
||||
const user = client.getUser(client.getUserId());
|
||||
user.setRawDisplayName(displayName);
|
||||
user.setDisplayName(displayName);
|
||||
|
||||
return [client, session];
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return [privacyPolicyUrl, recaptchaKey, register];
|
||||
};
|
||||
@@ -14,52 +14,49 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { useEffect, useCallback, useRef, useState } from "react";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mxOnRecaptchaLoaded: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const RECAPTCHA_SCRIPT_URL =
|
||||
"https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit";
|
||||
|
||||
export function useRecaptcha(sitekey) {
|
||||
interface RecaptchaPromiseRef {
|
||||
resolve: (response: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const useRecaptcha = (sitekey: string) => {
|
||||
const [recaptchaId] = useState(() => randomString(16));
|
||||
const promiseRef = useRef();
|
||||
const promiseRef = useRef<RecaptchaPromiseRef>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sitekey) {
|
||||
return;
|
||||
}
|
||||
if (!sitekey) return;
|
||||
|
||||
const onRecaptchaLoaded = () => {
|
||||
if (!document.getElementById(recaptchaId)) {
|
||||
return;
|
||||
}
|
||||
if (!document.getElementById(recaptchaId)) return;
|
||||
|
||||
window.grecaptcha.render(recaptchaId, {
|
||||
sitekey,
|
||||
size: "invisible",
|
||||
callback: (response) => {
|
||||
if (promiseRef.current) {
|
||||
promiseRef.current.resolve(response);
|
||||
}
|
||||
},
|
||||
"error-callback": (error) => {
|
||||
if (promiseRef.current) {
|
||||
promiseRef.current.reject(error);
|
||||
}
|
||||
},
|
||||
callback: (response: string) => promiseRef.current?.resolve(response),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
"error-callback": () => promiseRef.current?.reject(new Error()),
|
||||
});
|
||||
};
|
||||
|
||||
if (
|
||||
typeof window.grecaptcha !== "undefined" &&
|
||||
typeof window.grecaptcha.render === "function"
|
||||
) {
|
||||
if (typeof window.grecaptcha?.render === "function") {
|
||||
onRecaptchaLoaded();
|
||||
} else {
|
||||
window.mxOnRecaptchaLoaded = onRecaptchaLoaded;
|
||||
|
||||
if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) {
|
||||
const scriptTag = document.createElement("script");
|
||||
const scriptTag = document.createElement("script") as HTMLScriptElement;
|
||||
scriptTag.src = RECAPTCHA_SCRIPT_URL;
|
||||
scriptTag.async = true;
|
||||
document.body.appendChild(scriptTag);
|
||||
@@ -80,7 +77,7 @@ export function useRecaptcha(sitekey) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
for (const item of mutationsList) {
|
||||
if (item.target?.style?.visibility !== "visible") {
|
||||
if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
|
||||
reject(new Error("Recaptcha dismissed"));
|
||||
observer.disconnect();
|
||||
return;
|
||||
@@ -101,7 +98,7 @@ export function useRecaptcha(sitekey) {
|
||||
|
||||
window.grecaptcha.execute();
|
||||
|
||||
const iframe = document.querySelector(
|
||||
const iframe = document.querySelector<HTMLIFrameElement>(
|
||||
'iframe[src*="recaptcha/api2/bframe"]'
|
||||
);
|
||||
|
||||
@@ -111,13 +108,11 @@ export function useRecaptcha(sitekey) {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [recaptchaId, sitekey]);
|
||||
}, [sitekey]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (window.grecaptcha) {
|
||||
window.grecaptcha.reset();
|
||||
}
|
||||
}, [recaptchaId]);
|
||||
window.grecaptcha?.reset();
|
||||
}, []);
|
||||
|
||||
return { execute, reset, recaptchaId };
|
||||
}
|
||||
};
|
||||
59
src/auth/useRegisterPasswordlessUser.ts
Normal 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 };
|
||||
}
|
||||
@@ -21,7 +21,8 @@ limitations under the License.
|
||||
.iconCopyButton,
|
||||
.secondary,
|
||||
.secondaryHangup,
|
||||
.copyButton {
|
||||
.copyButton,
|
||||
.dropdownButton {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -45,8 +46,8 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.button {
|
||||
color: #fff;
|
||||
background-color: var(--primaryColor);
|
||||
color: var(--primary-content);
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.button:focus,
|
||||
@@ -65,46 +66,46 @@ limitations under the License.
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50px;
|
||||
background-color: var(--bgColor2);
|
||||
background-color: var(--system);
|
||||
}
|
||||
|
||||
.toolbarButton:hover,
|
||||
.toolbarButtonSecondary:hover {
|
||||
background-color: var(--bgColor4);
|
||||
background-color: var(--quinary-content);
|
||||
}
|
||||
|
||||
.toolbarButton.on,
|
||||
.toolbarButton.off {
|
||||
background-color: #ffffff;
|
||||
background-color: var(--primary-content);
|
||||
}
|
||||
|
||||
.toolbarButtonSecondary.on {
|
||||
background-color: #0dbd8b;
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.iconButton:not(.stroke) svg * {
|
||||
fill: #ffffff;
|
||||
fill: var(--primary-content);
|
||||
}
|
||||
|
||||
.iconButton:not(.stroke):hover svg * {
|
||||
fill: #0dbd8b;
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.iconButton.on:not(.stroke) svg * {
|
||||
fill: #0dbd8b;
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.iconButton.on.stroke svg * {
|
||||
stroke: #0dbd8b;
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.hangupButton,
|
||||
.hangupButton:hover {
|
||||
background-color: #ff5b55;
|
||||
background-color: var(--alert);
|
||||
}
|
||||
|
||||
.toolbarButton.on svg * {
|
||||
fill: #0dbd8b;
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.toolbarButton.off svg * {
|
||||
@@ -112,25 +113,25 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.toolbarButtonSecondary.on svg * {
|
||||
fill: #ffffff;
|
||||
fill: var(--primary-content);
|
||||
}
|
||||
|
||||
.secondary,
|
||||
.copyButton {
|
||||
color: #0dbd8b;
|
||||
border: 2px solid #0dbd8b;
|
||||
color: var(--accent);
|
||||
border: 2px solid var(--accent);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.secondaryHangup {
|
||||
color: #ff5b55;
|
||||
border: 2px solid #ff5b55;
|
||||
color: var(--alert);
|
||||
border: 2px solid var(--alert);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.copyButton.secondaryCopy {
|
||||
color: var(--textColor1);
|
||||
border-color: var(--textColor1);
|
||||
color: var(--primary-content);
|
||||
border-color: var(--primary-content);
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
@@ -153,12 +154,12 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.copyButton:not(.on) svg * {
|
||||
fill: #0dbd8b;
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.copyButton.on {
|
||||
border-color: transparent;
|
||||
background-color: #0dbd8b;
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -167,23 +168,49 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.copyButton.secondaryCopy:not(.on) svg * {
|
||||
fill: var(--textColor1);
|
||||
fill: var(--primary-content);
|
||||
}
|
||||
|
||||
.iconCopyButton svg * {
|
||||
fill: var(--textColor3);
|
||||
fill: var(--tertiary-content);
|
||||
}
|
||||
|
||||
.iconCopyButton:hover svg * {
|
||||
fill: #0dbd8b;
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.iconCopyButton.on svg *,
|
||||
.iconCopyButton.on:hover svg * {
|
||||
fill: transparent;
|
||||
stroke: #0dbd8b;
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.dropdownButton {
|
||||
color: var(--primary-content);
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dropdownButton:hover,
|
||||
.dropdownButton.on {
|
||||
background-color: var(--quinary-content);
|
||||
}
|
||||
|
||||
.dropdownButton svg {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.dropdownButton svg * {
|
||||
fill: var(--primary-content);
|
||||
}
|
||||
|
||||
.lg {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -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 React, { forwardRef, useCallback } 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";
|
||||
@@ -25,9 +28,24 @@ import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
|
||||
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
|
||||
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
|
||||
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
|
||||
import { useButton } from "@react-aria/button";
|
||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
|
||||
import { ReactComponent as Fullscreen } from "../icons/Fullscreen.svg";
|
||||
import { ReactComponent as FullscreenExit } from "../icons/FullscreenExit.svg";
|
||||
import { TooltipTrigger } from "../Tooltip";
|
||||
import { VolumeIcon } from "./VolumeIcon";
|
||||
|
||||
export type ButtonVariant =
|
||||
| "default"
|
||||
| "toolbar"
|
||||
| "toolbarSecondary"
|
||||
| "icon"
|
||||
| "secondary"
|
||||
| "copy"
|
||||
| "secondaryCopy"
|
||||
| "iconCopy"
|
||||
| "secondaryHangup"
|
||||
| "dropdown"
|
||||
| "link";
|
||||
|
||||
export const variantToClassName = {
|
||||
default: [styles.button],
|
||||
@@ -36,16 +54,32 @@ export const variantToClassName = {
|
||||
icon: [styles.iconButton],
|
||||
secondary: [styles.secondary],
|
||||
copy: [styles.copyButton],
|
||||
secondaryCopy: [styles.secondaryCopy, styles.copyButton],
|
||||
iconCopy: [styles.iconCopyButton],
|
||||
secondaryCopy: [styles.copyButton],
|
||||
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;
|
||||
// TODO: add all props for <Button>
|
||||
[index: string]: unknown;
|
||||
}
|
||||
export const Button = forwardRef<HTMLButtonElement, Props>(
|
||||
(
|
||||
{
|
||||
variant = "default",
|
||||
@@ -61,7 +95,7 @@ export const Button = forwardRef(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const buttonRef = useObjectRef(ref);
|
||||
const buttonRef = useObjectRef<HTMLButtonElement>(ref);
|
||||
const { buttonProps } = useButton(
|
||||
{ onPress, onPressStart, ...rest },
|
||||
buttonRef
|
||||
@@ -72,7 +106,7 @@ export const Button = forwardRef(
|
||||
let filteredButtonProps = buttonProps;
|
||||
|
||||
if (rest.type === "submit" && !rest.onPress) {
|
||||
const { onKeyDown, onKeyUp, ...filtered } = buttonProps;
|
||||
const { ...filtered } = buttonProps;
|
||||
filteredButtonProps = filtered;
|
||||
}
|
||||
|
||||
@@ -86,54 +120,89 @@ export const Button = forwardRef(
|
||||
{
|
||||
[styles.on]: on,
|
||||
[styles.off]: off,
|
||||
[styles.secondaryCopy]: variant === "secondaryCopy",
|
||||
}
|
||||
)}
|
||||
{...mergeProps(rest, filteredButtonProps)}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{children}
|
||||
<>
|
||||
{children}
|
||||
{variant === "dropdown" && <ArrowDownIcon />}
|
||||
</>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export function MicButton({ muted, ...rest }) {
|
||||
export function MicButton({
|
||||
muted,
|
||||
...rest
|
||||
}: {
|
||||
muted: boolean;
|
||||
// TODO: add all props for <Button>
|
||||
[index: string]: unknown;
|
||||
}) {
|
||||
return (
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger
|
||||
tooltip={() => (muted ? "Unmute microphone" : "Mute microphone")}
|
||||
>
|
||||
<Button variant="toolbar" {...rest} off={muted}>
|
||||
{muted ? <MuteMicIcon /> : <MicIcon />}
|
||||
</Button>
|
||||
{() => (muted ? "Unmute microphone" : "Mute microphone")}
|
||||
</TooltipTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoButton({ muted, ...rest }) {
|
||||
export function VideoButton({
|
||||
muted,
|
||||
...rest
|
||||
}: {
|
||||
muted: boolean;
|
||||
// TODO: add all props for <Button>
|
||||
[index: string]: unknown;
|
||||
}) {
|
||||
return (
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger
|
||||
tooltip={() => (muted ? "Turn on camera" : "Turn off camera")}
|
||||
>
|
||||
<Button variant="toolbar" {...rest} off={muted}>
|
||||
{muted ? <DisableVideoIcon /> : <VideoIcon />}
|
||||
</Button>
|
||||
{() => (muted ? "Turn on camera" : "Turn off camera")}
|
||||
</TooltipTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenshareButton({ enabled, className, ...rest }) {
|
||||
export function ScreenshareButton({
|
||||
enabled,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
enabled: boolean;
|
||||
className?: string;
|
||||
// TODO: add all props for <Button>
|
||||
[index: string]: unknown;
|
||||
}) {
|
||||
return (
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger
|
||||
tooltip={() => (enabled ? "Stop sharing screen" : "Share screen")}
|
||||
>
|
||||
<Button variant="toolbarSecondary" {...rest} on={enabled}>
|
||||
<ScreenshareIcon />
|
||||
</Button>
|
||||
{() => (enabled ? "Stop sharing screen" : "Share screen")}
|
||||
</TooltipTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function HangupButton({ className, ...rest }) {
|
||||
export function HangupButton({
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
className?: string;
|
||||
// TODO: add all props for <Button>
|
||||
[index: string]: unknown;
|
||||
}) {
|
||||
return (
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger tooltip={() => "Leave"}>
|
||||
<Button
|
||||
variant="toolbar"
|
||||
className={classNames(styles.hangupButton, className)}
|
||||
@@ -141,29 +210,78 @@ export function HangupButton({ className, ...rest }) {
|
||||
>
|
||||
<HangupIcon />
|
||||
</Button>
|
||||
{() => "Leave"}
|
||||
</TooltipTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsButton({ className, ...rest }) {
|
||||
export function SettingsButton({
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
className?: string;
|
||||
// TODO: add all props for <Button>
|
||||
[index: string]: unknown;
|
||||
}) {
|
||||
return (
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger tooltip={() => "Settings"}>
|
||||
<Button variant="toolbar" {...rest}>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
{() => "Settings"}
|
||||
</TooltipTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteButton({ className, ...rest }) {
|
||||
export function InviteButton({
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
className?: string;
|
||||
// TODO: add all props for <Button>
|
||||
[index: string]: unknown;
|
||||
}) {
|
||||
return (
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger tooltip={() => "Invite"}>
|
||||
<Button variant="toolbar" {...rest}>
|
||||
<AddUserIcon />
|
||||
</Button>
|
||||
{() => "Invite"}
|
||||
</TooltipTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface AudioButtonProps extends Omit<Props, "variant"> {
|
||||
/**
|
||||
* A number between 0 and 1
|
||||
*/
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export function AudioButton({ volume, ...rest }: AudioButtonProps) {
|
||||
return (
|
||||
<TooltipTrigger tooltip={() => "Local volume"}>
|
||||
<Button variant="icon" {...rest}>
|
||||
<VolumeIcon volume={volume} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface FullscreenButtonProps extends Omit<Props, "variant"> {
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
|
||||
export function FullscreenButton({
|
||||
fullscreen,
|
||||
...rest
|
||||
}: FullscreenButtonProps) {
|
||||
const getTooltip = useCallback(() => {
|
||||
return fullscreen ? "Exit full screen" : "Full screen";
|
||||
}, [fullscreen]);
|
||||
|
||||
return (
|
||||
<TooltipTrigger tooltip={getTooltip}>
|
||||
<Button variant="icon" {...rest}>
|
||||
{fullscreen ? <FullscreenExit /> : <Fullscreen />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
);
|
||||
}
|
||||
@@ -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 | string;
|
||||
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 (
|
||||
@@ -14,12 +14,34 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { HTMLAttributes } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import classNames from "classnames";
|
||||
import { variantToClassName, sizeToClassName } from "./Button";
|
||||
import * as H from "history";
|
||||
|
||||
export function LinkButton({ className, variant, size, children, ...rest }) {
|
||||
import {
|
||||
variantToClassName,
|
||||
sizeToClassName,
|
||||
ButtonVariant,
|
||||
ButtonSize,
|
||||
} from "./Button";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLAnchorElement> {
|
||||
children: JSX.Element | string;
|
||||
to: H.LocationDescriptor | ((location: H.Location) => H.LocationDescriptor);
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LinkButton({
|
||||
children,
|
||||
to,
|
||||
size,
|
||||
variant,
|
||||
className,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
@@ -27,6 +49,7 @@ export function LinkButton({ className, variant, size, children, ...rest }) {
|
||||
sizeToClassName[size],
|
||||
className
|
||||
)}
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
35
src/button/VolumeIcon.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { ReactComponent as AudioMuted } from "../icons/AudioMuted.svg";
|
||||
import { ReactComponent as AudioLow } from "../icons/AudioLow.svg";
|
||||
import { ReactComponent as Audio } from "../icons/Audio.svg";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Number between 0 and 1
|
||||
*/
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export function VolumeIcon({ volume }: Props) {
|
||||
if (volume <= 0) return <AudioMuted />;
|
||||
if (volume <= 0.5) return <AudioLow />;
|
||||
return <Audio />;
|
||||
}
|
||||
40
src/form/Form.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { FormEventHandler, forwardRef } from "react";
|
||||
|
||||
import styles from "./Form.module.css";
|
||||
|
||||
interface FormProps {
|
||||
className: string;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
children: JSX.Element[];
|
||||
}
|
||||
|
||||
export const Form = forwardRef<HTMLFormElement, FormProps>(
|
||||
({ children, className, onSubmit }, ref) => {
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className={classNames(styles.form, className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -10,7 +10,7 @@
|
||||
.callTile {
|
||||
height: 95px;
|
||||
padding: 12px;
|
||||
background-color: var(--bgColor2);
|
||||
background-color: var(--system);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
@@ -36,7 +36,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 0 16px;
|
||||
color: var(--textColor1);
|
||||
color: var(--primary-content);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,23 @@ limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
import { CopyButton } from "../button";
|
||||
import { Facepile } from "../Facepile";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { Avatar, Size } from "../Avatar";
|
||||
import styles from "./CallList.module.css";
|
||||
import { getRoomUrl } from "../matrix-utils";
|
||||
import { Body, Caption } from "../typography/Typography";
|
||||
import { GroupCallRoom } from "./useGroupCallRooms";
|
||||
|
||||
export function CallList({ rooms, client, disableFacepile }) {
|
||||
interface CallListProps {
|
||||
rooms: GroupCallRoom[];
|
||||
client: MatrixClient;
|
||||
disableFacepile?: boolean;
|
||||
}
|
||||
export function CallList({ rooms, client, disableFacepile }: CallListProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.callList}>
|
||||
@@ -48,7 +57,14 @@ export function CallList({ rooms, client, disableFacepile }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CallTileProps {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
roomId: string;
|
||||
participants: RoomMember[];
|
||||
client: MatrixClient;
|
||||
disableFacepile?: boolean;
|
||||
}
|
||||
function CallTile({
|
||||
name,
|
||||
avatarUrl,
|
||||
@@ -56,12 +72,12 @@ function CallTile({
|
||||
participants,
|
||||
client,
|
||||
disableFacepile,
|
||||
}) {
|
||||
}: CallTileProps) {
|
||||
return (
|
||||
<div className={styles.callTile}>
|
||||
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
|
||||
<Avatar
|
||||
size="lg"
|
||||
size={Size.LG}
|
||||
bgKey={name}
|
||||
src={avatarUrl}
|
||||
fallback={name.slice(0, 1).toUpperCase()}
|
||||
3
src/home/CallTypeDropdown.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
69
src/home/CallTypeDropdown.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { Item } from "@react-stately/collections";
|
||||
|
||||
import { Headline } from "../typography/Typography";
|
||||
import { Button } from "../button";
|
||||
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
|
||||
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
|
||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
||||
import styles from "./CallTypeDropdown.module.css";
|
||||
import commonStyles from "./common.module.css";
|
||||
import menuStyles from "../Menu.module.css";
|
||||
import { Menu } from "../Menu";
|
||||
|
||||
export enum CallType {
|
||||
Video = "video",
|
||||
Radio = "radio",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
callType: CallType;
|
||||
setCallType: (value: CallType) => void;
|
||||
}
|
||||
|
||||
export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
|
||||
return (
|
||||
<PopoverMenuTrigger placement="bottom">
|
||||
<Button variant="dropdown" className={commonStyles.headline}>
|
||||
<Headline className={styles.label}>
|
||||
{callType === CallType.Video ? "Video call" : "Walkie-talkie call"}
|
||||
</Headline>
|
||||
</Button>
|
||||
{(props: JSX.IntrinsicAttributes) => (
|
||||
<Menu {...props} label="Call type menu" onAction={setCallType}>
|
||||
<Item key={CallType.Video} textValue="Video call">
|
||||
<VideoIcon />
|
||||
<span>Video call</span>
|
||||
{callType === CallType.Video && (
|
||||
<CheckIcon className={menuStyles.checkIcon} />
|
||||
)}
|
||||
</Item>
|
||||
<Item key={CallType.Radio} textValue="Walkie-talkie call">
|
||||
<MicIcon />
|
||||
<span>Walkie-talkie call</span>
|
||||
{callType === CallType.Radio && (
|
||||
<CheckIcon className={menuStyles.checkIcon} />
|
||||
)}
|
||||
</Item>
|
||||
</Menu>
|
||||
)}
|
||||
</PopoverMenuTrigger>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useClient } from "../ClientContext";
|
||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
@@ -15,18 +15,26 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { PressEvent } from "@react-types/shared";
|
||||
|
||||
import { Modal, ModalContent } from "../Modal";
|
||||
import { Button } from "../button";
|
||||
import { FieldRow } from "../input/Input";
|
||||
import styles from "./JoinExistingCallModal.module.css";
|
||||
|
||||
export function JoinExistingCallModal({ onJoin, ...rest }) {
|
||||
interface Props {
|
||||
onJoin: (e: PressEvent) => void;
|
||||
onClose: (e: PressEvent) => void;
|
||||
// TODO: add used parameters for <Modal>
|
||||
[index: string]: unknown;
|
||||
}
|
||||
export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) {
|
||||
return (
|
||||
<Modal title="Join existing call?" isDismissable {...rest}>
|
||||
<ModalContent>
|
||||
<p>This call already exists, would you like to join?</p>
|
||||
<FieldRow rightAlign className={styles.buttons}>
|
||||
<Button onPress={rest.onClose}>No</Button>
|
||||
<Button onPress={onClose}>No</Button>
|
||||
<Button onPress={onJoin}>Yes, join call</Button>
|
||||
</FieldRow>
|
||||
</ModalContent>
|
||||
@@ -14,8 +14,16 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { createRoom, roomAliasFromRoomName } from "../matrix-utils";
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
FormEvent,
|
||||
FormEventHandler,
|
||||
} from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
|
||||
import { useGroupCallRooms } from "./useGroupCallRooms";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import commonStyles from "./common.module.css";
|
||||
@@ -26,28 +34,35 @@ import { CallList } from "./CallList";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Headline, Title } from "../typography/Typography";
|
||||
import { Title } from "../typography/Typography";
|
||||
import { Form } from "../form/Form";
|
||||
import { useShouldShowPtt } from "../useShouldShowPtt";
|
||||
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
|
||||
|
||||
export function RegisteredView({ client }) {
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
}
|
||||
|
||||
export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
const [callType, setCallType] = useState(CallType.Video);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [error, setError] = useState<Error>();
|
||||
const history = useHistory();
|
||||
const shouldShowPtt = useShouldShowPtt();
|
||||
const onSubmit = useCallback(
|
||||
(e) => {
|
||||
const { modalState, modalProps } = useModalTriggerState();
|
||||
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target);
|
||||
const roomName = data.get("callName");
|
||||
const ptt = data.get("ptt") !== null;
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const roomNameData = data.get("callName");
|
||||
const roomName = typeof roomNameData === "string" ? roomNameData : "";
|
||||
const ptt = callType === CallType.Radio;
|
||||
|
||||
async function submit() {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
|
||||
const roomIdOrAlias = await createRoom(client, roomName, ptt);
|
||||
const [roomIdOrAlias] = await createRoom(client, roomName, ptt);
|
||||
|
||||
if (roomIdOrAlias) {
|
||||
history.push(`/room/${roomIdOrAlias}`);
|
||||
@@ -56,7 +71,7 @@ export function RegisteredView({ client }) {
|
||||
|
||||
submit().catch((error) => {
|
||||
if (error.errcode === "M_ROOM_IN_USE") {
|
||||
setExistingRoomId(roomAliasFromRoomName(roomName));
|
||||
setExistingRoomId(roomAliasLocalpartFromRoomName(roomName));
|
||||
setLoading(false);
|
||||
setError(undefined);
|
||||
modalState.open();
|
||||
@@ -64,21 +79,22 @@ export function RegisteredView({ client }) {
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
setError(error);
|
||||
reset();
|
||||
}
|
||||
});
|
||||
},
|
||||
[client]
|
||||
[client, history, modalState, callType]
|
||||
);
|
||||
|
||||
const recentRooms = useGroupCallRooms(client);
|
||||
|
||||
const { modalState, modalProps } = useModalTriggerState();
|
||||
const [existingRoomId, setExistingRoomId] = useState();
|
||||
const [existingRoomId, setExistingRoomId] = useState<string>();
|
||||
const onJoinExistingRoom = useCallback(() => {
|
||||
history.push(`/${existingRoomId}`);
|
||||
}, [history, existingRoomId]);
|
||||
|
||||
const callNameLabel =
|
||||
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
@@ -92,16 +108,14 @@ export function RegisteredView({ client }) {
|
||||
<div className={commonStyles.container}>
|
||||
<main className={commonStyles.main}>
|
||||
<HeaderLogo className={commonStyles.logo} />
|
||||
<Headline className={commonStyles.headline}>
|
||||
Enter a call name
|
||||
</Headline>
|
||||
<CallTypeDropdown callType={callType} setCallType={setCallType} />
|
||||
<Form className={styles.form} onSubmit={onSubmit}>
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<InputField
|
||||
id="callName"
|
||||
name="callName"
|
||||
label="Call name"
|
||||
placeholder="Call name"
|
||||
label={callNameLabel}
|
||||
placeholder={callNameLabel}
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
@@ -116,16 +130,6 @@ export function RegisteredView({ client }) {
|
||||
{loading ? "Loading..." : "Go"}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
{shouldShowPtt && (
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<InputField
|
||||
id="ptt"
|
||||
name="ptt"
|
||||
label="Push to Talk"
|
||||
type="checkbox"
|
||||
/>
|
||||
</FieldRow>
|
||||
)}
|
||||
{error && (
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<ErrorMessage>{error.message}</ErrorMessage>
|
||||
@@ -14,82 +14,98 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { FC, useCallback, useState, FormEventHandler } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import { useClient } from "../ClientContext";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { Button } from "../button";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { createRoom, roomAliasFromRoomName } from "../matrix-utils";
|
||||
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { Body, Caption, Link, Headline } from "../typography/Typography";
|
||||
import { Body, Caption, Link } from "../typography/Typography";
|
||||
import { Form } from "../form/Form";
|
||||
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
|
||||
import styles from "./UnauthenticatedView.module.css";
|
||||
import commonStyles from "./common.module.css";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { useShouldShowPtt } from "../useShouldShowPtt";
|
||||
|
||||
export function UnauthenticatedView() {
|
||||
const shouldShowPtt = useShouldShowPtt();
|
||||
export const UnauthenticatedView: FC = () => {
|
||||
const { setClient } = useClient();
|
||||
const [callType, setCallType] = useState(CallType.Video);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [{ privacyPolicyUrl, recaptchaKey }, register] =
|
||||
const [error, setError] = useState<Error>();
|
||||
const [privacyPolicyUrl, recaptchaKey, register] =
|
||||
useInteractiveRegistration();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
const onSubmit = useCallback(
|
||||
|
||||
const { modalState, modalProps } = useModalTriggerState();
|
||||
const [onFinished, setOnFinished] = useState<() => void>();
|
||||
const history = useHistory();
|
||||
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target);
|
||||
const roomName = data.get("callName");
|
||||
const displayName = data.get("displayName");
|
||||
const ptt = data.get("ptt") !== null;
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
const roomName = data.get("callName") as string;
|
||||
const displayName = data.get("displayName") as string;
|
||||
const ptt = callType === CallType.Radio;
|
||||
|
||||
async function submit() {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
const recaptchaResponse = await execute();
|
||||
const userName = generateRandomName();
|
||||
const client = await register(
|
||||
const [client, session] = await register(
|
||||
userName,
|
||||
randomString(16),
|
||||
displayName,
|
||||
recaptchaResponse,
|
||||
true
|
||||
);
|
||||
const roomIdOrAlias = await createRoom(client, roomName, ptt);
|
||||
|
||||
if (roomIdOrAlias) {
|
||||
history.push(`/room/${roomIdOrAlias}`);
|
||||
let roomIdOrAlias: string;
|
||||
try {
|
||||
[roomIdOrAlias] = await createRoom(client, roomName, ptt);
|
||||
} catch (error) {
|
||||
if (error.errcode === "M_ROOM_IN_USE") {
|
||||
setOnFinished(() => {
|
||||
setClient(client, session);
|
||||
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
|
||||
const [, serverName] = client.getUserId().split(":");
|
||||
history.push(`/room/#${aliasLocalpart}:${serverName}`);
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
modalState.open();
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Only consider the registration successful if we managed to create the room, too
|
||||
setClient(client, session);
|
||||
history.push(`/room/${roomIdOrAlias}`);
|
||||
}
|
||||
|
||||
submit().catch((error) => {
|
||||
if (error.errcode === "M_ROOM_IN_USE") {
|
||||
setExistingRoomId(roomAliasFromRoomName(roomName));
|
||||
setLoading(false);
|
||||
setError(undefined);
|
||||
modalState.open();
|
||||
} else {
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
setError(error);
|
||||
reset();
|
||||
}
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
setError(error);
|
||||
reset();
|
||||
});
|
||||
},
|
||||
[register, reset, execute]
|
||||
[register, reset, execute, history, callType, modalState, setClient]
|
||||
);
|
||||
|
||||
const { modalState, modalProps } = useModalTriggerState();
|
||||
const [existingRoomId, setExistingRoomId] = useState();
|
||||
const history = useHistory();
|
||||
const onJoinExistingRoom = useCallback(() => {
|
||||
history.push(`/${existingRoomId}`);
|
||||
}, [history, existingRoomId]);
|
||||
const callNameLabel =
|
||||
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -104,16 +120,14 @@ export function UnauthenticatedView() {
|
||||
<div className={commonStyles.container}>
|
||||
<main className={commonStyles.main}>
|
||||
<HeaderLogo className={commonStyles.logo} />
|
||||
<Headline className={commonStyles.headline}>
|
||||
Enter a call name
|
||||
</Headline>
|
||||
<CallTypeDropdown callType={callType} setCallType={setCallType} />
|
||||
<Form className={styles.form} onSubmit={onSubmit}>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="callName"
|
||||
name="callName"
|
||||
label="Call name"
|
||||
placeholder="Call name"
|
||||
label={callNameLabel}
|
||||
placeholder={callNameLabel}
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
@@ -130,16 +144,6 @@ export function UnauthenticatedView() {
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldRow>
|
||||
{shouldShowPtt && (
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="ptt"
|
||||
name="ptt"
|
||||
label="Push to Talk"
|
||||
type="checkbox"
|
||||
/>
|
||||
</FieldRow>
|
||||
)}
|
||||
<Caption>
|
||||
By clicking "Go", you agree to our{" "}
|
||||
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
||||
@@ -170,8 +174,8 @@ export function UnauthenticatedView() {
|
||||
</footer>
|
||||
</div>
|
||||
{modalState.isOpen && (
|
||||
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
|
||||
<JoinExistingCallModal onJoin={onFinished} {...modalProps} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -14,11 +14,24 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const tsCache = {};
|
||||
export interface GroupCallRoom {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
avatarUrl: string;
|
||||
room: Room;
|
||||
groupCall: GroupCall;
|
||||
participants: RoomMember[];
|
||||
}
|
||||
const tsCache: { [index: string]: number } = {};
|
||||
|
||||
function getLastTs(client, r) {
|
||||
function getLastTs(client: MatrixClient, r: Room) {
|
||||
if (tsCache[r.roomId]) {
|
||||
return tsCache[r.roomId];
|
||||
}
|
||||
@@ -59,13 +72,13 @@ function getLastTs(client, r) {
|
||||
return ts;
|
||||
}
|
||||
|
||||
function sortRooms(client, rooms) {
|
||||
function sortRooms(client: MatrixClient, rooms: Room[]): Room[] {
|
||||
return rooms.sort((a, b) => {
|
||||
return getLastTs(client, b) - getLastTs(client, a);
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupCallRooms(client) {
|
||||
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||
const [rooms, setRooms] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,7 +92,7 @@ export function useGroupCallRooms(client) {
|
||||
return {
|
||||
roomId: room.getCanonicalAlias() || room.roomId,
|
||||
roomName: room.name,
|
||||
avatarUrl: null,
|
||||
avatarUrl: room.getMxcAvatarUrl(),
|
||||
room,
|
||||
groupCall,
|
||||
participants: [...groupCall.participants],
|
||||
@@ -90,12 +103,15 @@ export function useGroupCallRooms(client) {
|
||||
|
||||
updateRooms();
|
||||
|
||||
client.on("GroupCall.incoming", updateRooms);
|
||||
client.on("GroupCall.participants", updateRooms);
|
||||
client.on(GroupCallEventHandlerEvent.Incoming, updateRooms);
|
||||
client.on(GroupCallEventHandlerEvent.Participants, updateRooms);
|
||||
|
||||
return () => {
|
||||
client.removeListener("GroupCall.incoming", updateRooms);
|
||||
client.removeListener("GroupCall.participants", updateRooms);
|
||||
client.removeListener(GroupCallEventHandlerEvent.Incoming, updateRooms);
|
||||
client.removeListener(
|
||||
GroupCallEventHandlerEvent.Participants,
|
||||
updateRooms
|
||||
);
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
3
src/icons/AlertTriangleFilled.svg
Normal 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 |
@@ -1,5 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.97991 1.48403L4 4.80062L1 4.80062C0.447715 4.80062 0 5.24834 0 5.80062V10.2006C0 10.7529 0.447714 11.2006 0.999999 11.2006L4 11.2006L7.97991 14.5172C8.30557 14.7886 8.8 14.557 8.8 14.1331V1.86814C8.8 1.44422 8.30557 1.21265 7.97991 1.48403Z" fill="white"/>
|
||||
<path d="M14.1258 2.79107C13.8998 2.50044 13.4809 2.44808 13.1903 2.67413C12.9 2.89992 12.8475 3.3181 13.0726 3.6087L13.0731 3.60935L13.0738 3.61021L13.0829 3.62231C13.0917 3.63418 13.1059 3.65355 13.1248 3.68011C13.1625 3.73326 13.2187 3.81496 13.2872 3.92256C13.4243 4.13812 13.6097 4.45554 13.7955 4.85371C14.169 5.65407 14.5329 6.75597 14.5329 8.00036C14.5329 9.24475 14.169 10.3466 13.7955 11.147C13.6097 11.5452 13.4243 11.8626 13.2872 12.0782C13.2187 12.1858 13.1625 12.2675 13.1248 12.3206C13.1059 12.3472 13.0917 12.3665 13.0829 12.3784L13.0738 12.3905L13.0731 12.3914L13.0725 12.3921C12.8475 12.6827 12.9 13.1008 13.1903 13.3266C13.4809 13.5526 13.8998 13.5003 14.1258 13.2097L13.629 12.8232C14.1258 13.2096 14.1258 13.2097 14.1258 13.2097L14.1272 13.2079L14.1291 13.2055L14.1346 13.1982L14.1523 13.1748C14.1669 13.1552 14.187 13.1277 14.2119 13.0926C14.2617 13.0225 14.3305 12.9221 14.4121 12.794C14.5749 12.5381 14.7895 12.1698 15.0037 11.7109C15.4302 10.7969 15.8663 9.49883 15.8663 8.00036C15.8663 6.50189 15.4302 5.20379 15.0037 4.28987C14.7895 3.83089 14.5749 3.4626 14.4121 3.20673C14.3305 3.07862 14.2617 2.97818 14.2119 2.90811C14.187 2.87306 14.1669 2.84556 14.1523 2.82596L14.1346 2.80249L14.1291 2.79525L14.1272 2.79278L14.1264 2.79183C14.1264 2.79183 14.1258 2.79107 13.5996 3.20036L14.1258 2.79107Z" fill="white"/>
|
||||
<path d="M11.7264 5.19121C11.5004 4.90058 11.0815 4.84823 10.7909 5.07427C10.501 5.29973 10.4482 5.71698 10.6722 6.00752L10.6745 6.01057C10.6775 6.01457 10.6831 6.02223 10.691 6.03338C10.7069 6.05572 10.7318 6.09189 10.7628 6.14057C10.8249 6.23827 10.9103 6.38426 10.9961 6.56815C11.1696 6.93993 11.3335 7.44183 11.3335 8.00051C11.3335 8.55918 11.1696 9.06108 10.9961 9.43287C10.9103 9.61675 10.8249 9.76275 10.7628 9.86045C10.7318 9.90912 10.7069 9.94529 10.691 9.96763C10.6831 9.97879 10.6775 9.98645 10.6745 9.99044L10.6722 9.9935C10.4482 10.284 10.501 10.7013 10.7909 10.9267C11.0815 11.1528 11.5004 11.1004 11.7264 10.8098L11.2002 10.4005C11.7264 10.8098 11.7264 10.8098 11.7264 10.8098L11.7276 10.8083L11.7291 10.8064L11.7329 10.8014L11.7439 10.7868C11.7526 10.7751 11.7642 10.7593 11.7781 10.7396C11.806 10.7004 11.8436 10.6455 11.8876 10.5763C11.9755 10.4383 12.0901 10.2414 12.2043 9.99672C12.4308 9.51136 12.6669 8.81326 12.6669 8.00051C12.6669 7.18775 12.4308 6.48965 12.2043 6.0043C12.0901 5.75961 11.9755 5.56275 11.8876 5.42473C11.8436 5.35555 11.806 5.30065 11.7781 5.26138C11.7642 5.24173 11.7526 5.22596 11.7439 5.21422L11.7329 5.19964L11.7291 5.19465L11.7276 5.19274L11.727 5.19193C11.727 5.19193 11.7264 5.19121 11.2002 5.60051L11.7264 5.19121Z" fill="white"/>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.9699 2.22605L6 7.20093L1.5 7.20093C0.671573 7.20093 0 7.8725 0 8.70093V15.3009C0 16.1294 0.671571 16.8009 1.5 16.8009L6 16.8009L11.9699 21.7758C12.4584 22.1829 13.2 21.8355 13.2 21.1996V2.80221C13.2 2.16634 12.4584 1.81897 11.9699 2.22605Z" fill="white"/>
|
||||
<path d="M21.1888 4.1866C20.8497 3.75065 20.2214 3.67212 19.7855 4.01119C19.35 4.34988 19.2712 4.97715 19.6089 5.41304L19.6097 5.41402L19.6107 5.41531L19.6243 5.43347C19.6376 5.45126 19.6589 5.48033 19.6872 5.52017C19.7438 5.59988 19.828 5.72244 19.9308 5.88385C20.1365 6.20718 20.4145 6.68332 20.6932 7.28057C21.2535 8.48111 21.7994 10.134 21.7994 12.0005C21.7994 13.8671 21.2535 15.52 20.6932 16.7205C20.4145 17.3178 20.1365 17.7939 19.9308 18.1172C19.828 18.2786 19.7438 18.4012 19.6872 18.4809C19.6589 18.5208 19.6376 18.5498 19.6243 18.5676L19.6107 18.5858L19.6097 18.5871L19.6088 18.5882C19.2712 19.0241 19.3501 19.6512 19.7855 19.9899C20.2214 20.329 20.8497 20.2504 21.1888 19.8145L20.4435 19.2348C21.1888 19.8145 21.1888 19.8145 21.1888 19.8145L21.1908 19.8119L21.1936 19.8082L21.2019 19.7974L21.2284 19.7621C21.2503 19.7327 21.2805 19.6915 21.3179 19.6389C21.3925 19.5338 21.4958 19.3832 21.6181 19.191C21.8623 18.8072 22.1843 18.2547 22.5056 17.5663C23.1453 16.1954 23.7994 14.2482 23.7994 12.0005C23.7994 9.75284 23.1453 7.80569 22.5056 6.4348C22.1843 5.74634 21.8623 5.1939 21.6181 4.81009C21.4958 4.61793 21.3925 4.46727 21.3179 4.36217C21.2805 4.30959 21.2503 4.26835 21.2284 4.23893L21.2019 4.20373L21.1936 4.19288L21.1908 4.18917L21.1897 4.18774C21.1897 4.18774 21.1888 4.1866 20.3994 4.80054L21.1888 4.1866Z" fill="white"/>
|
||||
<path d="M17.5896 7.78682C17.2506 7.35087 16.6223 7.27234 16.1864 7.61141C15.7515 7.94959 15.6723 8.57548 16.0083 9.01128L16.0117 9.01586C16.0162 9.02185 16.0246 9.03334 16.0365 9.05007C16.0603 9.08359 16.0977 9.13784 16.1441 9.21085C16.2374 9.3574 16.3654 9.57639 16.4941 9.85222C16.7544 10.4099 17.0003 11.1627 17.0003 12.0008C17.0003 12.8388 16.7544 13.5916 16.4941 14.1493C16.3654 14.4251 16.2374 14.6441 16.1441 14.7907C16.0977 14.8637 16.0603 14.9179 16.0365 14.9514C16.0246 14.9682 16.0162 14.9797 16.0117 14.9857L16.0083 14.9903C15.6723 15.4261 15.7515 16.0519 16.1864 16.3901C16.6223 16.7292 17.2506 16.6506 17.5896 16.2147L16.8003 15.6008C17.5896 16.2147 17.5896 16.2147 17.5896 16.2147L17.5914 16.2124L17.5936 16.2095L17.5994 16.2021L17.6158 16.1802C17.6289 16.1626 17.6463 16.1389 17.6672 16.1094C17.709 16.0505 17.7654 15.9682 17.8315 15.8644C17.9632 15.6574 18.1352 15.3621 18.3065 14.9951C18.6462 14.267 19.0003 13.2199 19.0003 12.0008C19.0003 10.7816 18.6462 9.73448 18.3065 9.00645C18.1352 8.63942 17.9632 8.34412 17.8315 8.1371C17.7654 8.03333 17.709 7.95097 17.6672 7.89207C17.6463 7.8626 17.6289 7.83893 17.6158 7.82132L17.5994 7.79946L17.5936 7.79198L17.5914 7.78911L17.5905 7.78789C17.5905 7.78789 17.5896 7.78682 16.8003 8.40076L17.5896 7.78682Z" fill="white"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
4
src/icons/AudioLow.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.9699 2.22605L6 7.20093L1.5 7.20093C0.671573 7.20093 0 7.8725 0 8.70093V15.3009C0 16.1294 0.671571 16.8009 1.5 16.8009L6 16.8009L11.9699 21.7758C12.4584 22.1829 13.2 21.8355 13.2 21.1996V2.80221C13.2 2.16634 12.4584 1.81897 11.9699 2.22605Z" fill="white"/>
|
||||
<path d="M17.5896 7.78682C17.2506 7.35087 16.6223 7.27234 16.1864 7.61141C15.7515 7.94959 15.6723 8.57548 16.0083 9.01128L16.0117 9.01586C16.0162 9.02185 16.0246 9.03334 16.0365 9.05007C16.0603 9.08359 16.0977 9.13784 16.1441 9.21085C16.2374 9.3574 16.3654 9.57639 16.4941 9.85222C16.7544 10.4099 17.0003 11.1627 17.0003 12.0008C17.0003 12.8388 16.7544 13.5916 16.4941 14.1493C16.3654 14.4251 16.2374 14.6441 16.1441 14.7907C16.0977 14.8637 16.0603 14.9179 16.0365 14.9514C16.0246 14.9682 16.0162 14.9797 16.0117 14.9857L16.0083 14.9903C15.6723 15.4261 15.7515 16.0519 16.1864 16.3901C16.6223 16.7292 17.2506 16.6506 17.5896 16.2147L16.8003 15.6008C17.5896 16.2147 17.5896 16.2147 17.5896 16.2147L17.5914 16.2124L17.5936 16.2095L17.5994 16.2021L17.6158 16.1802C17.6289 16.1626 17.6463 16.1389 17.6672 16.1094C17.709 16.0505 17.7654 15.9682 17.8315 15.8644C17.9632 15.6574 18.1352 15.3621 18.3065 14.9951C18.6462 14.267 19.0003 13.2199 19.0003 12.0008C19.0003 10.7816 18.6462 9.73448 18.3065 9.00645C18.1352 8.63942 17.9632 8.34412 17.8315 8.1371C17.7654 8.03333 17.709 7.95097 17.6672 7.89207C17.6463 7.8626 17.6289 7.83893 17.6158 7.82132L17.5994 7.79946L17.5936 7.79198L17.5914 7.78911L17.5905 7.78789C17.5905 7.78789 17.5896 7.78682 16.8003 8.40076L17.5896 7.78682Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
3
src/icons/AudioMuted.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.63174 0.583224C2.15798 0.109466 1.38987 0.109466 0.91611 0.583224C0.442351 1.05698 0.442351 1.8251 0.91611 2.29885L5.3958 6.77855H5.37083L15.3629 16.7706V16.7456L20.7144 22.0972C21.1882 22.5709 21.9563 22.5709 22.4301 22.0972C22.9038 21.6234 22.9038 20.8553 22.4301 20.3816L2.63174 0.583224ZM15.3629 3.23319V9.88521L10.2275 4.74987L13.2404 2.2391C14.0833 1.53675 15.3629 2.13608 15.3629 3.23319ZM4.07191 16.8718H7.7929V16.872L13.2404 21.4116C14.0833 22.114 15.3629 21.5146 15.3629 20.4175V20.2018L2.4839 7.32287C1.87536 7.79641 1.48389 8.53577 1.48389 9.36657V14.2838C1.48389 15.7131 2.64258 16.8718 4.07191 16.8718Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 788 B |
3
src/icons/Fullscreen.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 8.59V4C21 3.45 20.55 3 20 3H15.41C14.52 3 14.07 4.08 14.7 4.71L16.29 6.3L6.29 16.3L4.7 14.71C4.08 14.08 3 14.52 3 15.41V20C3 20.55 3.45 21 4 21H8.59C9.48 21 9.93 19.92 9.3 19.29L7.71 17.7L17.71 7.7L19.3 9.29C19.92 9.92 21 9.48 21 8.59Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 368 B |
3
src/icons/FullscreenExit.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.29 4.12L16.7 8.71L18.29 10.3C18.92 10.93 18.47 12.01 17.58 12.01H13C12.45 12.01 12 11.56 12 11.01V6.41C12 5.52 13.08 5.07 13.71 5.7L15.3 7.29L19.89 2.7C20.28 2.31 20.91 2.31 21.3 2.7C21.68 3.1 21.68 3.73 21.29 4.12ZM4.11997 21.29L8.70997 16.7L10.3 18.29C10.93 18.92 12.01 18.47 12.01 17.58V13C12.01 12.45 11.56 12 11.01 12H6.40997C5.51997 12 5.06997 13.08 5.69997 13.71L7.28997 15.3L2.69997 19.89C2.30997 20.28 2.30997 20.91 2.69997 21.3C3.09997 21.68 3.72997 21.68 4.11997 21.29Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 613 B |
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
Copyright 2021-2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -25,19 +25,21 @@ limitations under the License.
|
||||
:root {
|
||||
--inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f,
|
||||
U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF;
|
||||
--primaryColor: #0dbd8b;
|
||||
--bgColor1: #15191e;
|
||||
--bgColor2: #21262c;
|
||||
--bgColor3: #444;
|
||||
--bgColor4: #394049;
|
||||
--bgColor5: #8d97a5;
|
||||
--textColor1: #fff;
|
||||
--textColor2: #6f7882;
|
||||
--textColor3: #8e99a4;
|
||||
--textColor4: #a9b2bc;
|
||||
--inputBorderColor: #394049;
|
||||
--inputBorderColorFocused: #0086e6;
|
||||
--linkColor: #0086e6;
|
||||
--accent: #0dbd8b;
|
||||
--accent-20: #0dbd8b33;
|
||||
--alert: #ff5b55;
|
||||
--alert-20: #ff5b5533;
|
||||
--links: #0086e6;
|
||||
--primary-content: #ffffff;
|
||||
--secondary-content: #a9b2bc;
|
||||
--tertiary-content: #8e99a4;
|
||||
--tertiary-content-20: #8e99a433;
|
||||
--quaternary-content: #6f7882;
|
||||
--quinary-content: #394049;
|
||||
--system: #21262c;
|
||||
--background: #15191e;
|
||||
--background-85: rgba(23, 25, 28, 0.85);
|
||||
--bgColor3: #444; /* This isn't found anywhere in the designs or Compound */
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@@ -121,8 +123,9 @@ limitations under the License.
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bgColor1);
|
||||
color: var(--textColor1);
|
||||
background-color: var(--background);
|
||||
color: var(--primary-content);
|
||||
color-scheme: dark;
|
||||
margin: 0;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
||||
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
@@ -181,7 +184,7 @@ p {
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primaryColor);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -193,8 +196,8 @@ a:active {
|
||||
hr {
|
||||
width: calc(100% - 24px);
|
||||
border: none;
|
||||
border-top: 1px solid var(--bgColor4);
|
||||
color: var(--textColor2);
|
||||
border-top: 1px solid var(--quinary-content);
|
||||
color: var(--quaternary-content);
|
||||
overflow: visible;
|
||||
text-align: center;
|
||||
height: 5px;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
position: absolute;
|
||||
bottom: 11px;
|
||||
right: -4px;
|
||||
background-color: var(--bgColor4);
|
||||
background-color: var(--quinary-content);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
@@ -37,5 +37,5 @@
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
color: #0dbd8b;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -15,42 +15,52 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { useObjectRef } from "@react-aria/utils";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { AllHTMLAttributes, useEffect } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useState } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { Button } from "../button";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Avatar, Size } from "../Avatar";
|
||||
import { Button } from "../button";
|
||||
import { ReactComponent as EditIcon } from "../icons/Edit.svg";
|
||||
import styles from "./AvatarInputField.module.css";
|
||||
|
||||
export const AvatarInputField = forwardRef(
|
||||
interface Props extends AllHTMLAttributes<HTMLInputElement> {
|
||||
id: string;
|
||||
label: string;
|
||||
avatarUrl: string;
|
||||
displayName: string;
|
||||
onRemoveAvatar: () => void;
|
||||
}
|
||||
|
||||
export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{ id, label, className, avatarUrl, displayName, onRemoveAvatar, ...rest },
|
||||
ref
|
||||
) => {
|
||||
const [removed, setRemoved] = useState(false);
|
||||
const [objUrl, setObjUrl] = useState(null);
|
||||
const [objUrl, setObjUrl] = useState<string>(null);
|
||||
|
||||
const fileInputRef = useObjectRef(ref);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
setObjUrl(URL.createObjectURL(e.target.files[0]));
|
||||
const currentInput = fileInputRef.current;
|
||||
|
||||
const onChange = (e: Event) => {
|
||||
const inputEvent = e as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||
if (inputEvent.target.files.length > 0) {
|
||||
setObjUrl(URL.createObjectURL(inputEvent.target.files[0]));
|
||||
setRemoved(false);
|
||||
} else {
|
||||
setObjUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
fileInputRef.current.addEventListener("change", onChange);
|
||||
currentInput.addEventListener("change", onChange);
|
||||
|
||||
return () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.removeEventListener("change", onChange);
|
||||
}
|
||||
currentInput?.removeEventListener("change", onChange);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -63,7 +73,7 @@ export const AvatarInputField = forwardRef(
|
||||
<div className={classNames(styles.avatarInputField, className)}>
|
||||
<div className={styles.avatarContainer}>
|
||||
<Avatar
|
||||
size="xl"
|
||||
size={Size.XL}
|
||||
src={removed ? null : objUrl || avatarUrl}
|
||||
fallback={displayName.slice(0, 1).toUpperCase()}
|
||||
/>
|
||||
@@ -26,7 +26,7 @@
|
||||
.inputField {
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.25s;
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
border: 1px solid var(--quinary-content);
|
||||
}
|
||||
|
||||
.inputField input,
|
||||
@@ -36,8 +36,8 @@
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 12px 9px 10px 9px;
|
||||
color: var(--textColor1);
|
||||
background-color: var(--bgColor1);
|
||||
color: var(--primary-content);
|
||||
background-color: var(--background);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -45,7 +45,7 @@
|
||||
.inputField.disabled input,
|
||||
.inputField.disabled textarea,
|
||||
.inputField.disabled span {
|
||||
color: var(--textColor2);
|
||||
color: var(--quaternary-content);
|
||||
}
|
||||
|
||||
.inputField span {
|
||||
@@ -65,13 +65,13 @@
|
||||
.inputField input:placeholder-shown:focus::placeholder,
|
||||
.inputField textarea:placeholder-shown:focus::placeholder {
|
||||
transition: color 0.25s ease-in 0.1s;
|
||||
color: var(--textColor2);
|
||||
color: var(--quaternary-content);
|
||||
}
|
||||
|
||||
.inputField label {
|
||||
transition: font-size 0.25s ease-out 0.1s, color 0.25s ease-out 0.1s,
|
||||
top 0.25s ease-out 0.1s, background-color 0.25s ease-out 0.1s;
|
||||
color: var(--textColor3);
|
||||
color: var(--tertiary-content);
|
||||
background-color: transparent;
|
||||
font-size: 15px;
|
||||
position: absolute;
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
|
||||
.inputField:focus-within {
|
||||
border-color: var(--inputBorderColorFocused);
|
||||
border-color: var(--links);
|
||||
}
|
||||
|
||||
.inputField input:focus,
|
||||
@@ -101,7 +101,7 @@
|
||||
.inputField textarea:focus + label,
|
||||
.inputField textarea:not(:placeholder-shown) + label,
|
||||
.inputField.prefix textarea + label {
|
||||
background-color: var(--bgColor2);
|
||||
background-color: var(--system);
|
||||
transition: font-size 0.25s ease-out 0s, color 0.25s ease-out 0s,
|
||||
top 0.25s ease-out 0s, background-color 0.25s ease-out 0s;
|
||||
font-size: 10px;
|
||||
@@ -112,19 +112,21 @@
|
||||
|
||||
.inputField input:focus + label,
|
||||
.inputField textarea:focus + label {
|
||||
color: var(--inputBorderColorFocused);
|
||||
color: var(--links);
|
||||
}
|
||||
|
||||
.checkboxField {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checkboxField label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
font-size: 13px;
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.checkboxField input {
|
||||
@@ -154,12 +156,12 @@
|
||||
}
|
||||
|
||||
.checkbox svg * {
|
||||
stroke: #fff;
|
||||
stroke: var(--primary-content);
|
||||
}
|
||||
|
||||
.checkboxField input[type="checkbox"]:checked + label > .checkbox {
|
||||
background: var(--primaryColor);
|
||||
border-color: var(--primaryColor);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkboxField input[type="checkbox"]:checked + label > .checkbox svg {
|
||||
@@ -167,12 +169,18 @@
|
||||
}
|
||||
|
||||
.checkboxField:focus-within .checkbox {
|
||||
border: 1.5px solid var(--inputBorderColorFocused) !important;
|
||||
border: 1.5px solid var(--links) !important;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #ff5b55;
|
||||
color: var(--alert);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--secondary-content);
|
||||
margin-left: 26px;
|
||||
width: 100%; /* Ensure that it breaks onto the next row */
|
||||
}
|
||||
|
||||
@@ -14,12 +14,23 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import React, { ChangeEvent, forwardRef, ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./Input.module.css";
|
||||
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
||||
|
||||
export function FieldRow({ children, rightAlign, className, ...rest }) {
|
||||
interface FieldRowProps {
|
||||
children: ReactNode;
|
||||
rightAlign?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FieldRow({
|
||||
children,
|
||||
rightAlign,
|
||||
className,
|
||||
}: FieldRowProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -33,13 +44,55 @@ export function FieldRow({ children, rightAlign, className, ...rest }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ children, className, ...rest }) {
|
||||
interface FieldProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Field({ children, className }: FieldProps): JSX.Element {
|
||||
return <div className={classNames(styles.field, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export const InputField = forwardRef(
|
||||
interface InputFieldProps {
|
||||
label: string;
|
||||
type: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
id?: string;
|
||||
checked?: boolean;
|
||||
className?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
// this is a hack. Those variables should be part of `HTMLAttributes<HTMLInputElement> | HTMLAttributes<HTMLTextAreaElement>`
|
||||
// but extending from this union type does not work
|
||||
name?: string;
|
||||
autoComplete?: string;
|
||||
autoCorrect?: string;
|
||||
autoCapitalize?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
defaultChecked?: boolean;
|
||||
onChange?: (event: ChangeEvent) => void;
|
||||
}
|
||||
|
||||
export const InputField = forwardRef<
|
||||
HTMLInputElement | HTMLTextAreaElement,
|
||||
InputFieldProps
|
||||
>(
|
||||
(
|
||||
{ id, label, className, type, checked, prefix, suffix, disabled, ...rest },
|
||||
{
|
||||
id,
|
||||
label,
|
||||
className,
|
||||
type,
|
||||
checked,
|
||||
prefix,
|
||||
suffix,
|
||||
description,
|
||||
disabled,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
@@ -57,19 +110,18 @@ export const InputField = forwardRef(
|
||||
{type === "textarea" ? (
|
||||
<textarea
|
||||
id={id}
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type={type}
|
||||
ref={ref as React.ForwardedRef<HTMLTextAreaElement>}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={id}
|
||||
{...rest}
|
||||
ref={ref}
|
||||
ref={ref as React.ForwardedRef<HTMLInputElement>}
|
||||
type={type}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -82,11 +134,16 @@ export const InputField = forwardRef(
|
||||
{label}
|
||||
</label>
|
||||
{suffix && <span>{suffix}</span>}
|
||||
{description && <p className={styles.description}>{description}</p>}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export function ErrorMessage({ children }) {
|
||||
export function ErrorMessage({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return <p className={styles.errorMessage}>{children}</p>;
|
||||
}
|
||||
@@ -17,11 +17,11 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
background-color: var(--bgColor1);
|
||||
background-color: var(--background);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
border: 1px solid var(--quinary-content);
|
||||
font-size: 15px;
|
||||
color: var(--textColor1);
|
||||
color: var(--primary-content);
|
||||
height: 40px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -15,16 +15,21 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { HiddenSelect, useSelect } from "@react-aria/select";
|
||||
import { AriaSelectOptions, HiddenSelect, useSelect } from "@react-aria/select";
|
||||
import { useButton } from "@react-aria/button";
|
||||
import { useSelectState } from "@react-stately/select";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Popover } from "../popover/Popover";
|
||||
import { ListBox } from "../ListBox";
|
||||
import styles from "./SelectInput.module.css";
|
||||
import classNames from "classnames";
|
||||
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
|
||||
|
||||
export function SelectInput(props) {
|
||||
interface Props extends AriaSelectOptions<object> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SelectInput(props: Props): JSX.Element {
|
||||
const state = useSelectState(props);
|
||||
|
||||
const ref = useRef();
|
||||
@@ -11,7 +11,7 @@
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 21px;
|
||||
background-color: #6f7882;
|
||||
background-color: var(--quaternary-content);
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 21px;
|
||||
background-color: #15191e;
|
||||
background-color: var(--background);
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
}
|
||||
@@ -30,11 +30,11 @@
|
||||
.label {
|
||||
padding: 10px 8px;
|
||||
line-height: 24px;
|
||||
color: #6f7882;
|
||||
color: var(--quaternary-content);
|
||||
}
|
||||
|
||||
.toggle.on .button {
|
||||
background-color: #0dbd8b;
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.toggle.on .ball {
|
||||
@@ -42,5 +42,5 @@
|
||||
}
|
||||
|
||||
.toggle.on .label {
|
||||
color: #ffffff;
|
||||
color: var(--primary-content);
|
||||
}
|
||||
|
||||
@@ -15,22 +15,37 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import styles from "./Toggle.module.css";
|
||||
import { useToggleButton } from "@react-aria/button";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./Toggle.module.css";
|
||||
import { Field } from "./Input";
|
||||
|
||||
export function Toggle({ id, label, className, onChange, isSelected }) {
|
||||
const buttonRef = useRef();
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
onChange: (selected: boolean) => void;
|
||||
isSelected: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Toggle({
|
||||
id,
|
||||
label,
|
||||
className,
|
||||
onChange,
|
||||
isSelected,
|
||||
}: Props): JSX.Element {
|
||||
const buttonRef = useRef<HTMLButtonElement>();
|
||||
const toggle = useCallback(() => {
|
||||
onChange(!isSelected);
|
||||
});
|
||||
const { buttonProps } = useToggleButton(
|
||||
}, [isSelected, onChange]);
|
||||
|
||||
const buttonProps = useToggleButton(
|
||||
{ isSelected },
|
||||
{ toggle },
|
||||
{ isSelected: isSelected, setSelected: undefined, toggle },
|
||||
buttonRef
|
||||
);
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={classNames(
|
||||
75
src/main.jsx
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { createBrowserHistory } from "history";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
import { ErrorView } from "./FullScreenView";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
import { InspectorContextProvider } from "./room/GroupCallInspector";
|
||||
|
||||
initRageshake();
|
||||
|
||||
console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`);
|
||||
|
||||
if (import.meta.env.VITE_CUSTOM_THEME) {
|
||||
const style = document.documentElement.style;
|
||||
style.setProperty("--primaryColor", import.meta.env.VITE_PRIMARY_COLOR);
|
||||
style.setProperty("--bgColor1", import.meta.env.VITE_BG_COLOR_1);
|
||||
style.setProperty("--bgColor2", import.meta.env.VITE_BG_COLOR_2);
|
||||
style.setProperty("--bgColor3", import.meta.env.VITE_BG_COLOR_3);
|
||||
style.setProperty("--bgColor4", import.meta.env.VITE_BG_COLOR_4);
|
||||
style.setProperty("--bgColor5", import.meta.env.VITE_BG_COLOR_5);
|
||||
style.setProperty("--textColor1", import.meta.env.VITE_TEXT_COLOR_1);
|
||||
style.setProperty("--textColor2", import.meta.env.VITE_TEXT_COLOR_2);
|
||||
style.setProperty("--textColor4", import.meta.env.VITE_TEXT_COLOR_4);
|
||||
style.setProperty(
|
||||
"--inputBorderColor",
|
||||
import.meta.env.VITE_INPUT_BORDER_COLOR
|
||||
);
|
||||
style.setProperty(
|
||||
"--inputBorderColorFocused",
|
||||
import.meta.env.VITE_INPUT_BORDER_COLOR_FOCUSED
|
||||
);
|
||||
}
|
||||
|
||||
const history = createBrowserHistory();
|
||||
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? "production",
|
||||
integrations: [
|
||||
new Integrations.BrowserTracing({
|
||||
routingInstrumentation: Sentry.reactRouterV5Instrumentation(history),
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Sentry.ErrorBoundary fallback={ErrorView}>
|
||||
<InspectorContextProvider>
|
||||
<App history={history} />
|
||||
</InspectorContextProvider>
|
||||
</Sentry.ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
112
src/main.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Copyright 2021-2022 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// We need to import this somewhere, once, so that the correct 'request'
|
||||
// function gets set. It needs to be not in the same file as we use
|
||||
// createClient, or the typescript transpiler gets confused about
|
||||
// dependency references.
|
||||
import "matrix-js-sdk/src/browser-index";
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { createBrowserHistory } from "history";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
|
||||
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);
|
||||
style.setProperty(
|
||||
"--accent-20",
|
||||
import.meta.env.VITE_THEME_ACCENT_20 as string
|
||||
);
|
||||
style.setProperty("--alert", import.meta.env.VITE_THEME_ALERT as string);
|
||||
style.setProperty(
|
||||
"--alert-20",
|
||||
import.meta.env.VITE_THEME_ALERT_20 as string
|
||||
);
|
||||
style.setProperty("--links", import.meta.env.VITE_THEME_LINKS as string);
|
||||
style.setProperty(
|
||||
"--primary-content",
|
||||
import.meta.env.VITE_THEME_PRIMARY_CONTENT as string
|
||||
);
|
||||
style.setProperty(
|
||||
"--secondary-content",
|
||||
import.meta.env.VITE_THEME_SECONDARY_CONTENT as string
|
||||
);
|
||||
style.setProperty(
|
||||
"--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
|
||||
);
|
||||
style.setProperty(
|
||||
"--quinary-content",
|
||||
import.meta.env.VITE_THEME_QUINARY_CONTENT as string
|
||||
);
|
||||
style.setProperty("--system", import.meta.env.VITE_THEME_SYSTEM as string);
|
||||
style.setProperty(
|
||||
"--background",
|
||||
import.meta.env.VITE_THEME_BACKGROUND as string
|
||||
);
|
||||
style.setProperty(
|
||||
"--background-85",
|
||||
import.meta.env.VITE_THEME_BACKGROUND_85 as string
|
||||
);
|
||||
}
|
||||
|
||||
const history = createBrowserHistory();
|
||||
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN as string,
|
||||
environment:
|
||||
(import.meta.env.VITE_SENTRY_ENVIRONMENT as string) ?? "production",
|
||||
integrations: [
|
||||
new Integrations.BrowserTracing({
|
||||
routingInstrumentation: Sentry.reactRouterV5Instrumentation(history),
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App history={history} />
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
@@ -1,140 +0,0 @@
|
||||
import matrix from "matrix-js-sdk/src/browser-index";
|
||||
import {
|
||||
GroupCallIntent,
|
||||
GroupCallType,
|
||||
} from "matrix-js-sdk/src/browser-index";
|
||||
|
||||
export const defaultHomeserver =
|
||||
import.meta.env.VITE_DEFAULT_HOMESERVER ||
|
||||
`${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
|
||||
|
||||
function waitForSync(client) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onSync = (state, _old, data) => {
|
||||
if (state === "PREPARED") {
|
||||
resolve();
|
||||
client.removeListener("sync", onSync);
|
||||
} else if (state === "ERROR") {
|
||||
reject(data?.error);
|
||||
client.removeListener("sync", onSync);
|
||||
}
|
||||
};
|
||||
client.on("sync", onSync);
|
||||
});
|
||||
}
|
||||
|
||||
export async function initClient(clientOptions) {
|
||||
const client = matrix.createClient({
|
||||
...clientOptions,
|
||||
useAuthorizationHeader: true,
|
||||
});
|
||||
|
||||
await client.startClient({
|
||||
// dirty hack to reduce chance of gappy syncs
|
||||
// should be fixed by spotting gaps and backpaginating
|
||||
initialSyncLimit: 50,
|
||||
});
|
||||
|
||||
await waitForSync(client);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export function roomAliasFromRoomName(roomName) {
|
||||
return roomName
|
||||
.trim()
|
||||
.replace(/\s/g, "-")
|
||||
.replace(/[^\w-]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function roomNameFromRoomId(roomId) {
|
||||
return roomId
|
||||
.match(/([^:]+):.*$/)[1]
|
||||
.substring(1)
|
||||
.split("-")
|
||||
.map((part) =>
|
||||
part.length > 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part
|
||||
)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isLocalRoomId(roomId) {
|
||||
if (!roomId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = roomId.match(/[^:]+:(.*)$/);
|
||||
|
||||
if (parts.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parts[1] === defaultHomeserverHost;
|
||||
}
|
||||
|
||||
export async function createRoom(client, name, isPtt = false) {
|
||||
const { room_id, room_alias } = await client.createRoom({
|
||||
visibility: "private",
|
||||
preset: "public_chat",
|
||||
name,
|
||||
room_alias_name: roomAliasFromRoomName(name),
|
||||
power_level_content_override: {
|
||||
invite: 100,
|
||||
kick: 100,
|
||||
ban: 100,
|
||||
redact: 50,
|
||||
state_default: 0,
|
||||
events_default: 0,
|
||||
users_default: 0,
|
||||
events: {
|
||||
"m.room.power_levels": 100,
|
||||
"m.room.history_visibility": 100,
|
||||
"m.room.tombstone": 100,
|
||||
"m.room.encryption": 100,
|
||||
"m.room.name": 50,
|
||||
"m.room.message": 0,
|
||||
"m.room.encrypted": 50,
|
||||
"m.sticker": 50,
|
||||
"org.matrix.msc3401.call.member": 0,
|
||||
},
|
||||
users: {
|
||||
[client.getUserId()]: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log({ isPtt });
|
||||
|
||||
await client.createGroupCall(
|
||||
room_id,
|
||||
isPtt ? GroupCallType.Voice : GroupCallType.Video,
|
||||
isPtt,
|
||||
GroupCallIntent.Prompt
|
||||
);
|
||||
|
||||
return room_alias || room_id;
|
||||
}
|
||||
|
||||
export function getRoomUrl(roomId) {
|
||||
if (roomId.startsWith("#")) {
|
||||
const [localPart, host] = roomId.replace("#", "").split(":");
|
||||
|
||||
if (host !== defaultHomeserverHost) {
|
||||
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
|
||||
} else {
|
||||
return `${window.location.protocol}//${window.location.host}/${localPart}`;
|
||||
}
|
||||
} else {
|
||||
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAvatarUrl(client, mxcUrl, avatarSize = 96) {
|
||||
const width = Math.floor(avatarSize * window.devicePixelRatio);
|
||||
const height = Math.floor(avatarSize * window.devicePixelRatio);
|
||||
return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, "crop");
|
||||
}
|
||||
386
src/matrix-utils.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
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 { 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,
|
||||
createRoomWidgetClient,
|
||||
MatrixClient,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { ICreateClientOpts } from "matrix-js-sdk/src/matrix";
|
||||
import { ClientEvent } from "matrix-js-sdk/src/client";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
||||
import { WidgetApi } from "matrix-widget-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
GroupCallIntent,
|
||||
GroupCallType,
|
||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
||||
import { getRoomParams } from "./room/useRoomParams";
|
||||
|
||||
export const defaultHomeserver =
|
||||
(import.meta.env.VITE_DEFAULT_HOMESERVER as string) ??
|
||||
`${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
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 = (
|
||||
state: SyncState,
|
||||
_old: SyncState,
|
||||
data: ISyncStateData
|
||||
) => {
|
||||
if (state === "PREPARED") {
|
||||
resolve();
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
} else if (state === "ERROR") {
|
||||
reject(data?.error);
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
}
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises and returns a new widget-API-based Matrix Client.
|
||||
* @param widgetId The ID of the widget that the app is running inside.
|
||||
* @param parentUrl The URL of the parent client.
|
||||
* @returns The MatrixClient instance
|
||||
*/
|
||||
export async function initMatroskaClient(
|
||||
widgetId: string,
|
||||
parentUrl: string
|
||||
): Promise<MatrixClient> {
|
||||
// In this mode, we use a special client which routes all requests through
|
||||
// the host application via the widget API
|
||||
|
||||
const { roomId, userId, deviceId } = getRoomParams();
|
||||
if (!roomId) throw new Error("Room ID must be supplied");
|
||||
if (!userId) throw new Error("User ID must be supplied");
|
||||
if (!deviceId) throw new Error("Device ID must be supplied");
|
||||
|
||||
// These are all the event types the app uses
|
||||
const sendState = [
|
||||
{ eventType: EventType.GroupCallPrefix },
|
||||
{ eventType: EventType.GroupCallMemberPrefix, stateKey: userId },
|
||||
];
|
||||
const receiveState = [
|
||||
{ eventType: EventType.RoomMember },
|
||||
{ eventType: EventType.GroupCallPrefix },
|
||||
{ eventType: EventType.GroupCallMemberPrefix },
|
||||
];
|
||||
const sendRecvToDevice = [
|
||||
EventType.CallInvite,
|
||||
EventType.CallCandidates,
|
||||
EventType.CallAnswer,
|
||||
EventType.CallHangup,
|
||||
EventType.CallReject,
|
||||
EventType.CallSelectAnswer,
|
||||
EventType.CallNegotiate,
|
||||
EventType.CallSDPStreamMetadataChanged,
|
||||
EventType.CallSDPStreamMetadataChangedPrefix,
|
||||
EventType.CallReplaces,
|
||||
"org.matrix.call_duplicate_session",
|
||||
];
|
||||
|
||||
// Since all data should be coming from the host application, there's no
|
||||
// need to persist anything, and therefore we can use the default stores
|
||||
// We don't even need to set up crypto
|
||||
const client = createRoomWidgetClient(
|
||||
new WidgetApi(widgetId, new URL(parentUrl).origin),
|
||||
{
|
||||
sendState,
|
||||
receiveState,
|
||||
sendToDevice: sendRecvToDevice,
|
||||
receiveToDevice: sendRecvToDevice,
|
||||
turnServers: true,
|
||||
},
|
||||
roomId,
|
||||
{
|
||||
baseUrl: "",
|
||||
userId,
|
||||
deviceId,
|
||||
timelineSupport: true,
|
||||
}
|
||||
);
|
||||
|
||||
await client.startClient();
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises and returns a new standalone Matrix Client.
|
||||
* If true is passed for the 'restore' parameter, a check will be made
|
||||
* to ensure that corresponding crypto data is stored and recovered.
|
||||
* If the check fails, CryptoStoreIntegrityError will be thrown.
|
||||
* @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,
|
||||
restore: boolean
|
||||
): Promise<MatrixClient> {
|
||||
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
||||
window.OLM_OPTIONS = {};
|
||||
await Olm.init({ locateFile: () => olmWasmPath });
|
||||
|
||||
let indexedDB: IDBFactory;
|
||||
|
||||
try {
|
||||
indexedDB = window.indexedDB;
|
||||
} catch (e) {}
|
||||
|
||||
const storeOpts = {} as ICreateClientOpts;
|
||||
|
||||
if (indexedDB && localStorage && !import.meta.env.DEV) {
|
||||
storeOpts.store = new IndexedDBStore({
|
||||
indexedDB: window.indexedDB,
|
||||
localStorage,
|
||||
dbName: SYNC_STORE_NAME,
|
||||
workerFactory: () => new IndexedDBWorker(),
|
||||
});
|
||||
} else if (localStorage) {
|
||||
storeOpts.store = new MemoryStore({ 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,
|
||||
CRYPTO_STORE_NAME
|
||||
);
|
||||
} else if (localStorage) {
|
||||
storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
|
||||
} else {
|
||||
storeOpts.cryptoStore = new MemoryCryptoStore();
|
||||
}
|
||||
|
||||
// XXX: we read from the room params in RoomPage too:
|
||||
// it would be much better to read them in one place and pass
|
||||
// the values around, but we initialise the matrix client in
|
||||
// many different places so we'd have to pass it into all of
|
||||
// them.
|
||||
const { e2eEnabled } = getRoomParams();
|
||||
if (!e2eEnabled) {
|
||||
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
...storeOpts,
|
||||
...clientOptions,
|
||||
useAuthorizationHeader: true,
|
||||
// Use a relatively low timeout for API calls: this is a realtime app
|
||||
// so we don't want API calls taking ages, we'd rather they just fail.
|
||||
localTimeoutMs: 5000,
|
||||
useE2eForGroupCall: e2eEnabled,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.store.startup();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error starting matrix client store. Falling back to memory store.",
|
||||
error
|
||||
);
|
||||
client.store = new MemoryStore({ localStorage });
|
||||
await client.store.startup();
|
||||
}
|
||||
|
||||
if (client.initCrypto) {
|
||||
await client.initCrypto();
|
||||
}
|
||||
|
||||
await client.startClient({
|
||||
// dirty hack to reduce chance of gappy syncs
|
||||
// should be fixed by spotting gaps and backpaginating
|
||||
initialSyncLimit: 50,
|
||||
});
|
||||
|
||||
await waitForSync(client);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export function roomAliasLocalpartFromRoomName(roomName: string): string {
|
||||
return roomName
|
||||
.trim()
|
||||
.replace(/\s/g, "-")
|
||||
.replace(/[^\w-]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function fullAliasFromRoomName(
|
||||
roomName: string,
|
||||
client: MatrixClient
|
||||
): string {
|
||||
return `#${roomAliasLocalpartFromRoomName(roomName)}:${client.getDomain()}`;
|
||||
}
|
||||
|
||||
export function roomNameFromRoomId(roomId: string): string {
|
||||
return roomId
|
||||
.match(/([^:]+):.*$/)[1]
|
||||
.substring(1)
|
||||
.split("-")
|
||||
.map((part) =>
|
||||
part.length > 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part
|
||||
)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isLocalRoomId(roomId: string): boolean {
|
||||
if (!roomId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = roomId.match(/[^:]+:(.*)$/);
|
||||
|
||||
if (parts.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parts[1] === defaultHomeserverHost;
|
||||
}
|
||||
|
||||
export async function createRoom(
|
||||
client: MatrixClient,
|
||||
name: string,
|
||||
ptt: boolean
|
||||
): Promise<[string, string]> {
|
||||
const createPromise = client.createRoom({
|
||||
visibility: Visibility.Private,
|
||||
preset: Preset.PublicChat,
|
||||
name,
|
||||
room_alias_name: roomAliasLocalpartFromRoomName(name),
|
||||
power_level_content_override: {
|
||||
invite: 100,
|
||||
kick: 100,
|
||||
ban: 100,
|
||||
redact: 50,
|
||||
state_default: 0,
|
||||
events_default: 0,
|
||||
users_default: 0,
|
||||
events: {
|
||||
"m.room.power_levels": 100,
|
||||
"m.room.history_visibility": 100,
|
||||
"m.room.tombstone": 100,
|
||||
"m.room.encryption": 100,
|
||||
"m.room.name": 50,
|
||||
"m.room.message": 0,
|
||||
"m.room.encrypted": 50,
|
||||
"m.sticker": 50,
|
||||
"org.matrix.msc3401.call.member": 0,
|
||||
},
|
||||
users: {
|
||||
[client.getUserId()]: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for the room to arrive
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onRoom = async (room: Room) => {
|
||||
if (room.roomId === (await createPromise).room_id) {
|
||||
resolve();
|
||||
cleanUp();
|
||||
}
|
||||
};
|
||||
createPromise.catch((e) => {
|
||||
reject(e);
|
||||
cleanUp();
|
||||
});
|
||||
|
||||
const cleanUp = () => {
|
||||
client.off(ClientEvent.Room, onRoom);
|
||||
};
|
||||
client.on(ClientEvent.Room, onRoom);
|
||||
});
|
||||
|
||||
const result = await createPromise;
|
||||
|
||||
console.log(`Creating ${ptt ? "PTT" : "video"} group call room`);
|
||||
|
||||
await client.createGroupCall(
|
||||
result.room_id,
|
||||
ptt ? GroupCallType.Voice : GroupCallType.Video,
|
||||
ptt,
|
||||
GroupCallIntent.Room
|
||||
);
|
||||
|
||||
return [fullAliasFromRoomName(name, client), result.room_id];
|
||||
}
|
||||
|
||||
export function getRoomUrl(roomIdOrAlias: string): string {
|
||||
if (roomIdOrAlias.startsWith("#")) {
|
||||
const [localPart, host] = roomIdOrAlias.replace("#", "").split(":");
|
||||
|
||||
if (host !== defaultHomeserverHost) {
|
||||
return `${window.location.protocol}//${window.location.host}/room/${roomIdOrAlias}`;
|
||||
} else {
|
||||
return `${window.location.protocol}//${window.location.host}/${localPart}`;
|
||||
}
|
||||
} else {
|
||||
return `${window.location.protocol}//${window.location.host}/room/#?roomId=${roomIdOrAlias}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
client: MatrixClient,
|
||||
mxcUrl: string,
|
||||
avatarSize = 96
|
||||
): string {
|
||||
const width = Math.floor(avatarSize * window.devicePixelRatio);
|
||||
const height = Math.floor(avatarSize * window.devicePixelRatio);
|
||||
return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, "crop");
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 194px;
|
||||
background: var(--bgColor2);
|
||||
background: var(--system);
|
||||
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -14,14 +14,22 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import React, { forwardRef, HTMLAttributes } from "react";
|
||||
import { DismissButton, useOverlay } from "@react-aria/overlays";
|
||||
import { FocusScope } from "@react-aria/focus";
|
||||
import classNames from "classnames";
|
||||
import styles from "./Popover.module.css";
|
||||
import { useObjectRef } from "@react-aria/utils";
|
||||
|
||||
export const Popover = forwardRef(
|
||||
import styles from "./Popover.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
export const Popover = forwardRef<HTMLDivElement, Props>(
|
||||
({ isOpen = true, onClose, className, children, ...rest }, ref) => {
|
||||
const popoverRef = useObjectRef(ref);
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import styles from "./PopoverMenu.module.css";
|
||||
import { useMenuTriggerState } from "@react-stately/menu";
|
||||
import { useMenuTrigger } from "@react-aria/menu";
|
||||
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
|
||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||
import classNames from "classnames";
|
||||
import { Popover } from "./Popover";
|
||||
|
||||
export const PopoverMenuTrigger = forwardRef(
|
||||
({ children, placement, className, disableOnState, ...rest }, ref) => {
|
||||
const popoverMenuState = useMenuTriggerState(rest);
|
||||
const buttonRef = useObjectRef(ref);
|
||||
const { menuTriggerProps, menuProps } = useMenuTrigger(
|
||||
{},
|
||||
popoverMenuState,
|
||||
buttonRef
|
||||
);
|
||||
|
||||
const popoverRef = useRef();
|
||||
|
||||
const { overlayProps } = useOverlayPosition({
|
||||
targetRef: buttonRef,
|
||||
overlayRef: popoverRef,
|
||||
placement: placement || "top",
|
||||
offset: 5,
|
||||
isOpen: popoverMenuState.isOpen,
|
||||
});
|
||||
|
||||
if (
|
||||
!Array.isArray(children) ||
|
||||
children.length > 2 ||
|
||||
typeof children[1] !== "function"
|
||||
) {
|
||||
throw new Error(
|
||||
"PopoverMenu must have two props. The first being a button and the second being a render prop."
|
||||
);
|
||||
}
|
||||
|
||||
const [popoverTrigger, popoverMenu] = children;
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.popoverMenuTrigger, className)}>
|
||||
<popoverTrigger.type
|
||||
{...mergeProps(popoverTrigger.props, menuTriggerProps)}
|
||||
on={!disableOnState && popoverMenuState.isOpen}
|
||||
ref={buttonRef}
|
||||
/>
|
||||
{popoverMenuState.isOpen && (
|
||||
<OverlayContainer>
|
||||
<Popover
|
||||
{...overlayProps}
|
||||
isOpen={popoverMenuState.isOpen}
|
||||
onClose={popoverMenuState.close}
|
||||
ref={popoverRef}
|
||||
>
|
||||
{popoverMenu({
|
||||
...menuProps,
|
||||
autoFocus: popoverMenuState.focusStrategy,
|
||||
onClose: popoverMenuState.close,
|
||||
})}
|
||||
</Popover>
|
||||
</OverlayContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
96
src/popover/PopoverMenu.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import { useMenuTriggerState } from "@react-stately/menu";
|
||||
import { useMenuTrigger } from "@react-aria/menu";
|
||||
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
|
||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||
import classNames from "classnames";
|
||||
import { MenuTriggerProps } from "@react-types/menu";
|
||||
import { Placement } from "@react-types/overlays";
|
||||
|
||||
import styles from "./PopoverMenu.module.css";
|
||||
import { Popover } from "./Popover";
|
||||
|
||||
interface PopoverMenuTriggerProps extends MenuTriggerProps {
|
||||
children: JSX.Element;
|
||||
placement: Placement;
|
||||
className: string;
|
||||
disableOnState: boolean;
|
||||
[index: string]: unknown;
|
||||
}
|
||||
|
||||
export const PopoverMenuTrigger = forwardRef<
|
||||
HTMLDivElement,
|
||||
PopoverMenuTriggerProps
|
||||
>(({ children, placement, className, disableOnState, ...rest }, ref) => {
|
||||
const popoverMenuState = useMenuTriggerState(rest);
|
||||
const buttonRef = useObjectRef(ref);
|
||||
const { menuTriggerProps, menuProps } = useMenuTrigger(
|
||||
{},
|
||||
popoverMenuState,
|
||||
buttonRef
|
||||
);
|
||||
|
||||
const popoverRef = useRef();
|
||||
|
||||
const { overlayProps } = useOverlayPosition({
|
||||
targetRef: buttonRef,
|
||||
overlayRef: popoverRef,
|
||||
placement: placement || "top",
|
||||
offset: 5,
|
||||
isOpen: popoverMenuState.isOpen,
|
||||
});
|
||||
|
||||
if (
|
||||
!Array.isArray(children) ||
|
||||
children.length > 2 ||
|
||||
typeof children[1] !== "function"
|
||||
) {
|
||||
throw new Error(
|
||||
"PopoverMenu must have two props. The first being a button and the second being a render prop."
|
||||
);
|
||||
}
|
||||
|
||||
const [popoverTrigger, popoverMenu] = children;
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.popoverMenuTrigger, className)}>
|
||||
<popoverTrigger.type
|
||||
{...mergeProps(popoverTrigger.props, menuTriggerProps)}
|
||||
on={!disableOnState && popoverMenuState.isOpen}
|
||||
ref={buttonRef}
|
||||
/>
|
||||
{popoverMenuState.isOpen && (
|
||||
<OverlayContainer>
|
||||
<Popover
|
||||
{...overlayProps}
|
||||
isOpen={popoverMenuState.isOpen}
|
||||
onClose={popoverMenuState.close}
|
||||
ref={popoverRef}
|
||||
>
|
||||
{popoverMenu({
|
||||
...menuProps,
|
||||
autoFocus: popoverMenuState.focusStrategy,
|
||||
onClose: popoverMenuState.close,
|
||||
})}
|
||||
</Popover>
|
||||
</OverlayContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { ChangeEvent, useCallback, useEffect, useState } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
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: () => void;
|
||||
[rest: string]: unknown;
|
||||
}
|
||||
export function ProfileModal({ client, ...rest }: Props) {
|
||||
const { onClose } = rest;
|
||||
const {
|
||||
success,
|
||||
@@ -40,7 +47,7 @@ export function ProfileModal({ client, ...rest }) {
|
||||
}, []);
|
||||
|
||||
const onChangeDisplayName = useCallback(
|
||||
(e) => {
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setDisplayName(e.target.value);
|
||||
},
|
||||
[setDisplayName]
|
||||
@@ -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]
|
||||
@@ -14,52 +14,76 @@ 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";
|
||||
import { getAvatarUrl } from "../matrix-utils";
|
||||
|
||||
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 {
|
||||
success: false,
|
||||
loading: false,
|
||||
displayName: user?.rawDisplayName,
|
||||
avatarUrl: user && client && getAvatarUrl(client, user.avatarUrl),
|
||||
avatarUrl: user?.avatarUrl,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onChangeUser = (_event, { displayName, avatarUrl }) => {
|
||||
const onChangeUser = (
|
||||
_event: MatrixEvent,
|
||||
{ displayName, avatarUrl }: User
|
||||
) => {
|
||||
setState({
|
||||
success: false,
|
||||
loading: false,
|
||||
displayName,
|
||||
avatarUrl: getAvatarUrl(client, avatarUrl),
|
||||
avatarUrl,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
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) => ({
|
||||
@@ -72,7 +96,7 @@ export function useProfile(client) {
|
||||
try {
|
||||
await client.setDisplayName(displayName);
|
||||
|
||||
let mxcAvatarUrl;
|
||||
let mxcAvatarUrl: string;
|
||||
|
||||
if (removeAvatar) {
|
||||
await client.setAvatarUrl("");
|
||||
@@ -84,19 +108,15 @@ export function useProfile(client) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
displayName,
|
||||
avatarUrl: removeAvatar
|
||||
? null
|
||||
: mxcAvatarUrl
|
||||
? getAvatarUrl(client, mxcAvatarUrl)
|
||||
: prev.avatarUrl,
|
||||
avatarUrl: removeAvatar ? null : mxcAvatarUrl ?? prev.avatarUrl,
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -107,5 +127,12 @@ export function useProfile(client) {
|
||||
[client]
|
||||
);
|
||||
|
||||
return { loading, error, displayName, avatarUrl, saveProfile, success };
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
saveProfile,
|
||||
success,
|
||||
};
|
||||
}
|
||||
@@ -15,12 +15,24 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import styles from "./AudioPreview.module.css";
|
||||
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { SelectInput } from "../input/SelectInput";
|
||||
import { Item } from "@react-stately/collections";
|
||||
|
||||
import styles from "./AudioPreview.module.css";
|
||||
import { SelectInput } from "../input/SelectInput";
|
||||
import { Body } from "../typography/Typography";
|
||||
|
||||
interface Props {
|
||||
state: GroupCallState;
|
||||
roomName: string;
|
||||
audioInput: string;
|
||||
audioInputs: MediaDeviceInfo[];
|
||||
setAudioInput: (deviceId: string) => void;
|
||||
audioOutput: string;
|
||||
audioOutputs: MediaDeviceInfo[];
|
||||
setAudioOutput: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
export function AudioPreview({
|
||||
state,
|
||||
roomName,
|
||||
@@ -30,10 +42,10 @@ export function AudioPreview({
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
}) {
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<h1>{`${roomName} - Radio Call`}</h1>
|
||||
<h1>{`${roomName} - Walkie-talkie call`}</h1>
|
||||
<div className={styles.preview}>
|
||||
{state === GroupCallState.LocalCallFeedUninitialized && (
|
||||
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
|
||||
@@ -53,8 +65,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 +80,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>
|
||||
)}
|
||||
@@ -15,13 +15,15 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import styles from "./CallEndedView.module.css";
|
||||
import { LinkButton } from "../button";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { Subtitle, Body, Link, Headline } from "../typography/Typography";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
|
||||
export function CallEndedView({ client }) {
|
||||
export function CallEndedView({ client }: { client: MatrixClient }) {
|
||||
const { displayName } = useProfile(client);
|
||||
|
||||
return (
|
||||
@@ -15,6 +15,8 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import { Modal, ModalContent } from "../Modal";
|
||||
import { Button } from "../button";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
@@ -23,9 +25,14 @@ import {
|
||||
useRageshakeRequest,
|
||||
} from "../settings/submit-rageshake";
|
||||
import { Body } from "../typography/Typography";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
export function FeedbackModal({ inCall, roomId, ...rest }) {
|
||||
interface Props {
|
||||
inCall: boolean;
|
||||
roomId: string;
|
||||
onClose?: () => void;
|
||||
// TODO: add all props for for <Modal>
|
||||
[index: string]: unknown;
|
||||
}
|
||||
export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
|
||||
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
|
||||
const sendRageshakeRequest = useRageshakeRequest();
|
||||
|
||||
@@ -33,8 +40,10 @@ export function FeedbackModal({ inCall, roomId, ...rest }) {
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target);
|
||||
const description = data.get("description");
|
||||
const sendLogs = data.get("sendLogs");
|
||||
const descriptionData = data.get("description");
|
||||
const description =
|
||||
typeof descriptionData === "string" ? descriptionData : "";
|
||||
const sendLogs = Boolean(data.get("sendLogs"));
|
||||
const rageshakeRequestId = randomString(16);
|
||||
|
||||
submitRageshake({
|
||||
@@ -53,12 +62,12 @@ export function FeedbackModal({ inCall, roomId, ...rest }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (sent) {
|
||||
rest.onClose();
|
||||
onClose();
|
||||
}
|
||||
}, [sent, rest.onClose]);
|
||||
}, [sent, onClose]);
|
||||
|
||||
return (
|
||||
<Modal title="Submit Feedback" isDismissable {...rest}>
|
||||
<Modal title="Submit Feedback" isDismissable onClose={onClose} {...rest}>
|
||||
<ModalContent>
|
||||
<Body>Having trouble? Help us fix it.</Body>
|
||||
<form onSubmit={onSubmitFeedback}>
|
||||
@@ -1,8 +0,0 @@
|
||||
.checkIcon {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.checkIcon * {
|
||||
stroke: var(--textColor1);
|
||||
}
|
||||
|
||||
@@ -15,37 +15,44 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Item } from "@react-stately/collections";
|
||||
|
||||
import { Button } from "../button";
|
||||
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
|
||||
import { ReactComponent as SpotlightIcon } from "../icons/Spotlight.svg";
|
||||
import { ReactComponent as FreedomIcon } from "../icons/Freedom.svg";
|
||||
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
||||
import styles from "./GridLayoutMenu.module.css";
|
||||
import menuStyles from "../Menu.module.css";
|
||||
import { Menu } from "../Menu";
|
||||
import { Item } from "@react-stately/collections";
|
||||
import { Tooltip, TooltipTrigger } from "../Tooltip";
|
||||
import { TooltipTrigger } from "../Tooltip";
|
||||
|
||||
export function GridLayoutMenu({ layout, setLayout }) {
|
||||
export type Layout = "freedom" | "spotlight";
|
||||
interface Props {
|
||||
layout: Layout;
|
||||
setLayout: (layout: Layout) => void;
|
||||
}
|
||||
export function GridLayoutMenu({ layout, setLayout }: Props) {
|
||||
return (
|
||||
<PopoverMenuTrigger placement="bottom right">
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger tooltip={() => "Layout Type"}>
|
||||
<Button variant="icon">
|
||||
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
|
||||
</Button>
|
||||
{() => "Layout Type"}
|
||||
</TooltipTrigger>
|
||||
{(props) => (
|
||||
{(props: JSX.IntrinsicAttributes) => (
|
||||
<Menu {...props} label="Grid layout menu" onAction={setLayout}>
|
||||
<Item key="freedom" textValue="Freedom">
|
||||
<FreedomIcon />
|
||||
<span>Freedom</span>
|
||||
{layout === "freedom" && <CheckIcon className={styles.checkIcon} />}
|
||||
{layout === "freedom" && (
|
||||
<CheckIcon className={menuStyles.checkIcon} />
|
||||
)}
|
||||
</Item>
|
||||
<Item key="spotlight" textValue="Spotlight">
|
||||
<SpotlightIcon />
|
||||
<span>Spotlight</span>
|
||||
{layout === "spotlight" && (
|
||||
<CheckIcon className={styles.checkIcon} />
|
||||
<CheckIcon className={menuStyles.checkIcon} />
|
||||
)}
|
||||
</Item>
|
||||
</Menu>
|
||||