Compare commits
308 commits
8d2a7e02f6
...
903eefb475
| Author | SHA1 | Date | |
|---|---|---|---|
| 903eefb475 | |||
|
|
92f490e9d9 | ||
|
|
efb3e115db | ||
|
|
9554b31c7d | ||
|
|
fd37dfe3f9 | ||
|
|
1ce6ca2b07 | ||
|
|
e04aeb865f | ||
|
|
83e5125b37 | ||
|
|
ca82aa283a | ||
|
|
8ce33ee6ff | ||
|
|
5aec2732dd | ||
|
|
a6f75eb5c5 | ||
|
|
afac47d312 | ||
|
|
4f498af458 | ||
|
|
7ceba0301e | ||
|
|
9dbe53a36a | ||
|
|
47f1d1183a | ||
|
|
e01009fd07 | ||
|
|
008669efdf | ||
|
|
9562103210 | ||
|
|
40957632d5 | ||
|
|
5b3a0f1334 | ||
|
|
990a92a32c | ||
|
|
e481116b04 | ||
|
|
9e1aab2973 | ||
|
|
7bca8fb911 | ||
|
|
13dd8fcc06 | ||
|
|
205ea1655a | ||
|
|
d42bcc6e3d | ||
|
|
af9460ef8b | ||
|
|
5bc9654d32 | ||
|
|
4e7b64eb5f | ||
|
|
f9b0d8c86f | ||
|
|
458b1c0172 | ||
|
|
4c5acc1940 | ||
|
|
cfee62ffe6 | ||
|
|
79b37e177b | ||
|
|
4c515bb72e | ||
|
|
8a8443bda4 | ||
|
|
317cd366c3 | ||
|
|
aafd028af4 | ||
|
|
984803c52c | ||
|
|
07df0c2c79 | ||
|
|
c389365ea2 | ||
|
|
d4deba6074 | ||
|
|
c5b59ea122 | ||
|
|
c7f6e33a2b | ||
|
|
5c2c8984aa | ||
|
|
c3901804c0 | ||
|
|
3c1aa0e699 | ||
|
|
a299e9c4cb | ||
|
|
141f148e37 | ||
|
|
51cfd7201c | ||
|
|
528cbc5c79 | ||
|
|
e504a9ef4c | ||
|
|
fb9ca31a43 | ||
|
|
b91a9d72b0 | ||
|
|
4be70422b1 | ||
|
|
77f8a0409a | ||
|
|
235bb63c15 | ||
|
|
5a25da4415 | ||
|
|
92e24e5281 | ||
|
|
ca2c868624 | ||
|
|
79fab78c71 | ||
|
|
f407905d73 | ||
|
|
efc77ceb44 | ||
|
|
eaf70fb79a | ||
|
|
2869735e2b | ||
|
|
40a2277996 | ||
|
|
7e948f0050 | ||
|
|
6d0e8b7f79 | ||
|
|
78c38506fa | ||
|
|
12e4ba9981 | ||
|
|
20815390e6 | ||
|
|
7712e34d22 | ||
|
|
4403eacdf1 | ||
|
|
9be2a945da | ||
|
|
a2c8097c01 | ||
|
|
c675131802 | ||
|
|
6b3c9dfddc | ||
|
|
e6e751d305 | ||
|
|
dd158fa652 | ||
|
|
b46c9ed023 | ||
|
|
aca2a40309 | ||
|
|
f74feec123 | ||
|
|
7a169fcc0e | ||
|
|
b08fa60a93 | ||
|
|
9f749d1a9d | ||
|
|
3f2c2af351 | ||
|
|
4d14eb9fb7 | ||
|
|
8e4f310aa3 | ||
|
|
5d22b287db | ||
|
|
2575d77b37 | ||
|
|
0d2f76e11d | ||
|
|
8dfc3aa36f | ||
|
|
3b767870c5 | ||
|
|
a5a9e72c5f | ||
|
|
e1146b1c06 | ||
|
|
daadbe4358 | ||
|
|
02ac70affc | ||
|
|
7228c2e35f | ||
|
|
e78d1dab14 | ||
|
|
e42a617fc2 | ||
|
|
73c17d3558 | ||
|
|
153d7d12fc | ||
|
|
6edee72a15 | ||
|
|
e3f1697367 | ||
|
|
42252829c6 | ||
|
|
39b20c7cc7 | ||
|
|
78dcdfda95 | ||
|
|
3e6d55f03b | ||
|
|
c99112b78b | ||
|
|
0e332d6616 | ||
|
|
923982ef30 | ||
|
|
0bd42a3994 | ||
|
|
9b98083d4a | ||
|
|
18ea2d2063 | ||
|
|
335df8d6ba | ||
|
|
df84eb1d71 | ||
|
|
bf131f76dc | ||
|
|
1a821961f3 | ||
|
|
108eb60b4a | ||
|
|
22903c9340 | ||
|
|
f3c0aebda2 | ||
|
|
99576a2432 | ||
|
|
7d26601bfc | ||
|
|
9499289fb3 | ||
|
|
f842356438 | ||
|
|
0ef9c56a25 | ||
|
|
f2f98a6973 | ||
|
|
3818671446 | ||
|
|
8b22573e43 | ||
|
|
0b6009aaee | ||
|
|
872e9a257f | ||
|
|
e220387b3f | ||
|
|
03cbecc3f9 | ||
|
|
b6afe3b968 | ||
|
|
65ff5e3943 | ||
|
|
b18f6366c7 | ||
|
|
9302003c30 | ||
|
|
1871f70704 | ||
|
|
27196cbad2 | ||
|
|
9b3c6e7c92 | ||
|
|
d54bc2c110 | ||
|
|
c108295e5a | ||
|
|
d3ec9facf6 | ||
|
|
4c8ab4e79d | ||
|
|
ff02c461e7 | ||
|
|
e4ce4da8ee | ||
|
|
f262f54728 | ||
|
|
395a24f1a7 | ||
|
|
cd0d4c9704 | ||
|
|
0db52c2d37 | ||
|
|
023a23d87c | ||
|
|
a81492c5b2 | ||
|
|
67fbf949b0 | ||
|
|
07a980a0c7 | ||
|
|
19f1df798f | ||
|
|
c6ceb3f977 | ||
|
|
5481595a67 | ||
|
|
3bd7588497 | ||
|
|
e5505cd5c2 | ||
|
|
7dcd43c64f | ||
|
|
0b70ce7591 | ||
|
|
7ef33400c1 | ||
|
|
6e33c8e8da | ||
|
|
d9c0c85483 | ||
|
|
dabe7f7849 | ||
|
|
e688c19350 | ||
|
|
1e44557406 | ||
|
|
3e3d68602f | ||
|
|
af455a5fe3 | ||
|
|
7043376383 | ||
|
|
74c883dfa8 | ||
|
|
59a936b094 | ||
|
|
4486ef1e72 | ||
|
|
f3612e23c6 | ||
|
|
5ab0b39152 | ||
|
|
d488c24974 | ||
|
|
f28a3a1c7d | ||
|
|
6601c47abc | ||
|
|
1b89831c10 | ||
|
|
cde0d5fa64 | ||
|
|
da3d20d0fe | ||
|
|
a1c0b79cbd | ||
|
|
cd233053bc | ||
|
|
9ae7c318ab | ||
|
|
1cdc0680e8 | ||
|
|
ae9cc7a548 | ||
|
|
00ac8f654a | ||
|
|
4293538dc3 | ||
|
|
807c90e2f5 | ||
|
|
eea8ffea05 | ||
|
|
6714300779 | ||
|
|
a690dbd2ca | ||
|
|
0be5fb9732 | ||
|
|
8b2fa10679 | ||
|
|
43ce6f0210 | ||
|
|
8b50ac150b | ||
|
|
1bd593b530 | ||
|
|
c05421efb7 | ||
|
|
abe79ceb66 | ||
|
|
3fcf2fef59 | ||
|
|
9e919ea761 | ||
|
|
824be5bdc2 | ||
|
|
a5a8f2814e | ||
|
|
d11bdb2f85 | ||
|
|
4083bbb31e | ||
|
|
5ee3897fde | ||
|
|
9cb705149a | ||
|
|
b40ddf0c61 | ||
|
|
2841386972 | ||
|
|
2e0218c456 | ||
|
|
3dcfde4461 | ||
|
|
79647c5b50 | ||
|
|
7808adbbe1 | ||
|
|
c64dbb0563 | ||
|
|
7f8aeb335f | ||
|
|
a2a83fc316 | ||
|
|
de1a629b79 | ||
|
|
93fbbecfdd | ||
|
|
7c6c1f53c0 | ||
|
|
8038c2ac8b | ||
|
|
96be8e1b8a | ||
|
|
8c6a6265eb | ||
|
|
7ce3b40037 | ||
|
|
a5551909c1 | ||
|
|
ec741423c7 | ||
|
|
b4d9828017 | ||
|
|
4779b09879 | ||
|
|
8700fe84ab | ||
|
|
e76664083d | ||
|
|
1e8a69ff23 | ||
|
|
b5ca54d6fd | ||
|
|
d6ffac74a7 | ||
|
|
39d4eedb75 | ||
|
|
6691467545 | ||
|
|
c8790a5284 | ||
|
|
f7fb4bcc11 | ||
|
|
a4af96b530 | ||
|
|
c7ea3f7e57 | ||
|
|
2f980a727a | ||
|
|
1573353550 | ||
|
|
6d4b37f96d | ||
|
|
5677042157 | ||
|
|
7beb8411a9 | ||
|
|
02cb7ea9a7 | ||
|
|
5ae806444e | ||
|
|
39e7de3d21 | ||
|
|
0fb5ffc68a | ||
|
|
40a25aa03e | ||
|
|
79b4303154 | ||
|
|
7ebf26e34b | ||
|
|
6ad8b477d1 | ||
|
|
7a03b91c93 | ||
|
|
0fe90e5e6d | ||
|
|
9a3fc59ef0 | ||
|
|
1a3b10e32d | ||
|
|
1ac5b3d8fd | ||
|
|
8f87690b00 | ||
|
|
589b318b08 | ||
|
|
fd0fa3c921 | ||
|
|
d602631986 | ||
|
|
5a5f144e20 | ||
|
|
fd1ccc7281 | ||
|
|
f76641b538 | ||
|
|
14206dbb96 | ||
|
|
cb9e4ffe86 | ||
|
|
8d285d45f7 | ||
|
|
232fbc5c62 | ||
|
|
9b6bfea359 | ||
|
|
1629341c08 | ||
|
|
9d352de995 | ||
|
|
0b6a7c752d | ||
|
|
28f5d8afe8 | ||
|
|
2c8ae9693b | ||
|
|
0106fab00b | ||
|
|
38ad5e3f7e | ||
|
|
eba9670664 | ||
|
|
9c5fde3258 | ||
|
|
99e55b36c6 | ||
|
|
cc88733204 | ||
|
|
5f0ca6a794 | ||
|
|
b88da572a4 | ||
|
|
c367c90a96 | ||
|
|
d82e49aab8 | ||
|
|
cccf9630a0 | ||
|
|
a540c86b08 | ||
|
|
3403adeb61 | ||
|
|
f39a262eb5 | ||
|
|
ca7691ddc5 | ||
|
|
b4f67ce0ec | ||
|
|
e2c97f81a7 | ||
|
|
c413396562 | ||
|
|
8bd812b789 | ||
|
|
9b23c757fe | ||
|
|
e1a080e50c | ||
|
|
8baa3a2cf9 | ||
|
|
b59a5ac307 | ||
|
|
93a1401b7c | ||
|
|
ba75abd8dd | ||
|
|
2fa1e1a187 | ||
|
|
7dedb12138 | ||
|
|
aa65dd57ba | ||
|
|
ac0ac4ff35 | ||
|
|
389fc17e45 | ||
|
|
7e74fb1462 | ||
|
|
c53d14d91e |
63 changed files with 4562 additions and 1194 deletions
10
config.json
10
config.json
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"defaultHomeserver": 2,
|
||||
"defaultHomeserver": 1,
|
||||
"homeserverList": [
|
||||
"converser.eu",
|
||||
"envs.net",
|
||||
"matrix.org",
|
||||
"monero.social",
|
||||
"mozilla.org",
|
||||
"unredacted.org",
|
||||
"xmr.se"
|
||||
],
|
||||
"allowCustomHomeservers": true,
|
||||
"elementCallUrl": null,
|
||||
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [
|
||||
"#cinny-space:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:envs.net",
|
||||
"#space:unredacted.org",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org"
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"#PrivSec.dev:arcticfoxes.net",
|
||||
"#disroot:aria-net.org"
|
||||
],
|
||||
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
|
||||
"servers": [ "matrix.org", "mozilla.org", "unredacted.org" ]
|
||||
},
|
||||
|
||||
"hashRouter": {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ server {
|
|||
rewrite ^/public/(.*)$ /public/$1 break;
|
||||
rewrite ^/assets/(.*)$ /assets/$1 break;
|
||||
|
||||
rewrite ^/element-call/dist/(.*)$ /element-call/dist/$1 break;
|
||||
|
||||
rewrite ^(.+)$ /index.html break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@matrix-org/react-sdk-module-api": "2.5.0",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
|
|
@ -32,7 +33,7 @@
|
|||
"emojibase-data": "15.3.2",
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.4.0",
|
||||
"folds": "2.5.0",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
|
|
@ -44,6 +45,7 @@
|
|||
"linkify-react": "4.1.3",
|
||||
"linkifyjs": "4.1.3",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"matrix-widget-api": "1.11.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
|
|
@ -62,9 +64,11 @@
|
|||
"slate-dom": "0.112.2",
|
||||
"slate-history": "0.110.3",
|
||||
"slate-react": "0.112.1",
|
||||
"ua-parser-js": "1.0.35"
|
||||
"ua-parser-js": "1.0.35",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.16.3",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
|
|
@ -1649,6 +1653,12 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@element-hq/element-call-embedded": {
|
||||
"version": "0.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.3.tgz",
|
||||
"integrity": "sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
|
|
@ -2264,6 +2274,18 @@
|
|||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@matrix-org/react-sdk-module-api": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.5.0.tgz",
|
||||
"integrity": "sha512-l/SmiO47gPIRd6YJJGj+B6qbxyypJF6SEsfYr7j9rSW6E85ZYCqf+TpMM2LmfwZRADyKfCVkaJbbBZYpoD02VA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
|
@ -7157,9 +7179,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/folds": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.4.0.tgz",
|
||||
"integrity": "sha512-Q5xCmvU3SIM8etQ9qLF6Y5Jtv01c9JpG3QcnF+Z3nlbMvtktfE13Pj7p0XgSPBcA3OuoU0zXiRwiTlMcbU7KhA==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz",
|
||||
"integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@vanilla-extract/css": "1.9.2",
|
||||
|
|
@ -8663,9 +8685,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/matrix-widget-api": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
|
||||
"integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.11.0.tgz",
|
||||
"integrity": "sha512-ED/9hrJqDWVLeED0g1uJnYRhINh3ZTquwurdM+Hc8wLVJIQ8G/r7A7z74NC+8bBIHQ1Jo7i1Uq5CoJp/TzFYrA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/events": "^3.0.0",
|
||||
|
|
@ -10904,6 +10926,7 @@
|
|||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
|
||||
"integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -12112,6 +12135,15 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
|
||||
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -10,6 +10,7 @@
|
|||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "yarn check:eslint && yarn check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
"check:prettier": "prettier --check .",
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@matrix-org/react-sdk-module-api": "2.5.0",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
|
|
@ -43,7 +45,7 @@
|
|||
"emojibase-data": "15.3.2",
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.4.0",
|
||||
"folds": "2.5.0",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
|
|
@ -54,6 +56,7 @@
|
|||
"jotai": "2.6.0",
|
||||
"linkify-react": "4.1.3",
|
||||
"linkifyjs": "4.1.3",
|
||||
"matrix-widget-api": "1.11.0",
|
||||
"matrix-js-sdk": "38.2.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
|
|
@ -73,9 +76,11 @@
|
|||
"slate-dom": "0.112.2",
|
||||
"slate-history": "0.110.3",
|
||||
"slate-react": "0.112.1",
|
||||
"ua-parser-js": "1.0.35"
|
||||
"ua-parser-js": "1.0.35",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.16.3",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
|
|
@ -107,4 +112,4 @@
|
|||
"vite-plugin-static-copy": "1.0.4",
|
||||
"vite-plugin-top-level-await": "1.4.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
|
|||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||
|
||||
export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
|
||||
export type AccountDataDeleteCallback = (type: string) => Promise<void>;
|
||||
|
||||
type AccountDataInfo = {
|
||||
type: string;
|
||||
|
|
@ -83,8 +84,7 @@ function AccountDataEdit({
|
|||
|
||||
if (
|
||||
!typeStr ||
|
||||
parsedContent === null ||
|
||||
defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
|
||||
parsedContent === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -121,7 +121,7 @@ function AccountDataEdit({
|
|||
aria-disabled={submitting}
|
||||
>
|
||||
<Box shrink="No" direction="Column" gap="100">
|
||||
<Text size="L400">Account Data</Text>
|
||||
<Text size="L400">Field Name</Text>
|
||||
<Box gap="300">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
|
|
@ -195,9 +195,22 @@ function AccountDataEdit({
|
|||
type AccountDataViewProps = {
|
||||
type: string;
|
||||
defaultContent: string;
|
||||
onEdit: () => void;
|
||||
requestClose: () => void;
|
||||
onEdit?: () => void;
|
||||
submitDelete?: AccountDataDeleteCallback;
|
||||
};
|
||||
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
|
||||
function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDelete }: AccountDataViewProps) {
|
||||
const [deleteState, deleteCallback] = useAsyncCallback<void, MatrixError, []>(useCallback(
|
||||
async () => {
|
||||
if (submitDelete !== undefined) {
|
||||
await submitDelete(type);
|
||||
requestClose();
|
||||
}
|
||||
},
|
||||
[type, submitDelete, requestClose],
|
||||
));
|
||||
const deleting = deleteState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
|
|
@ -208,7 +221,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
|
|||
>
|
||||
<Box shrink="No" gap="300" alignItems="End">
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="L400">Account Data</Text>
|
||||
<Text size="L400">Field Name</Text>
|
||||
<Input
|
||||
variant="SurfaceVariant"
|
||||
size="400"
|
||||
|
|
@ -218,9 +231,23 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
|
|||
required
|
||||
/>
|
||||
</Box>
|
||||
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
|
||||
<Text size="B400">Edit</Text>
|
||||
</Button>
|
||||
{onEdit && (
|
||||
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
|
||||
<Text size="B400">Edit</Text>
|
||||
</Button>
|
||||
)}
|
||||
{submitDelete && (
|
||||
<Button
|
||||
variant="Critical"
|
||||
size="400"
|
||||
radii="300"
|
||||
disabled={deleting}
|
||||
before={deleting && <Spinner variant="Critical" fill="Solid" size="300" />}
|
||||
onClick={deleteCallback}
|
||||
>
|
||||
<Text size="B400">Delete</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="L400">JSON Content</Text>
|
||||
|
|
@ -243,8 +270,9 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
|
|||
|
||||
export type AccountDataEditorProps = {
|
||||
type?: string;
|
||||
content?: object;
|
||||
submitChange: AccountDataSubmitCallback;
|
||||
content?: unknown;
|
||||
submitChange?: AccountDataSubmitCallback;
|
||||
submitDelete?: AccountDataDeleteCallback;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
|
|
@ -252,6 +280,7 @@ export function AccountDataEditor({
|
|||
type,
|
||||
content,
|
||||
submitChange,
|
||||
submitDelete,
|
||||
requestClose,
|
||||
}: AccountDataEditorProps) {
|
||||
const [data, setData] = useState<AccountDataInfo>({
|
||||
|
|
@ -301,7 +330,7 @@ export function AccountDataEditor({
|
|||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{edit ? (
|
||||
{(edit && submitChange) ? (
|
||||
<AccountDataEdit
|
||||
type={data.type}
|
||||
defaultContent={contentJSONStr}
|
||||
|
|
@ -313,7 +342,9 @@ export function AccountDataEditor({
|
|||
<AccountDataView
|
||||
type={data.type}
|
||||
defaultContent={contentJSONStr}
|
||||
onEdit={() => setEdit(true)}
|
||||
requestClose={requestClose}
|
||||
onEdit={submitChange ? () => setEdit(true) : undefined}
|
||||
submitDelete={submitDelete}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
54
src/app/components/CollapsibleCard.tsx
Normal file
54
src/app/components/CollapsibleCard.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Button, Icon, Icons, Text } from 'folds';
|
||||
import { SequenceCard } from './sequence-card';
|
||||
import { SequenceCardStyle } from '../features/settings/styles.css';
|
||||
import { SettingTile } from './setting-tile';
|
||||
|
||||
type CollapsibleCardProps = {
|
||||
expand: boolean;
|
||||
setExpand: (expand: boolean) => void;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
before?: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function CollapsibleCard({
|
||||
expand,
|
||||
setExpand,
|
||||
title,
|
||||
description,
|
||||
before,
|
||||
children,
|
||||
}: CollapsibleCardProps) {
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={title}
|
||||
description={description}
|
||||
before={before}
|
||||
after={
|
||||
<Button
|
||||
onClick={() => setExpand(!expand)}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
before={
|
||||
<Icon src={expand ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
|
||||
}
|
||||
>
|
||||
<Text size="B300">{expand ? 'Collapse' : 'Expand'}</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{expand && children}
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { CreateRoomKind } from './CreateRoomKindSelector';
|
|||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { getMxIdServer } from '../../utils/matrix';
|
||||
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
||||
|
||||
export const createRoomCreationContent = (
|
||||
type: RoomType | undefined,
|
||||
|
|
@ -82,6 +83,44 @@ export const createRoomEncryptionState = () => ({
|
|||
},
|
||||
});
|
||||
|
||||
export const createRoomCallState = () => ({
|
||||
type: 'org.matrix.msc3401.call',
|
||||
state_key: '',
|
||||
content: {},
|
||||
});
|
||||
|
||||
export const createPowerLevelContentOverrides = (
|
||||
base: IPowerLevels,
|
||||
overrides: Partial<IPowerLevels>
|
||||
): IPowerLevels => ({
|
||||
...base,
|
||||
...overrides,
|
||||
...(base.events || overrides.events
|
||||
? {
|
||||
events: {
|
||||
...base.events,
|
||||
...overrides.events,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(base.users || overrides.users
|
||||
? {
|
||||
users: {
|
||||
...base.users,
|
||||
...overrides.users,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(base.notifications || overrides.notifications
|
||||
? {
|
||||
notifications: {
|
||||
...base.notifications,
|
||||
...overrides.notifications,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
export type CreateRoomData = {
|
||||
version: string;
|
||||
type?: RoomType;
|
||||
|
|
@ -94,6 +133,7 @@ export type CreateRoomData = {
|
|||
knock: boolean;
|
||||
allowFederation: boolean;
|
||||
additionalCreators?: string[];
|
||||
powerLevelContentOverrides?: IPowerLevels;
|
||||
};
|
||||
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
|
||||
const initialState: ICreateRoomStateEvent[] = [];
|
||||
|
|
@ -106,6 +146,10 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||
initialState.push(createRoomParentState(data.parent));
|
||||
}
|
||||
|
||||
if (data.type === RoomType.Call) {
|
||||
initialState.push(createRoomCallState());
|
||||
}
|
||||
|
||||
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
|
||||
|
||||
const options: ICreateRoomOpts = {
|
||||
|
|
@ -136,5 +180,15 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||
);
|
||||
}
|
||||
|
||||
if (data.powerLevelContentOverrides) {
|
||||
const roomPowers = await mx.getStateEvent(result.room_id, StateEvent.RoomPowerLevels, '');
|
||||
const updatedPowers = createPowerLevelContentOverrides(
|
||||
roomPowers,
|
||||
data.powerLevelContentOverrides
|
||||
);
|
||||
|
||||
await mx.sendStateEvent(result.room_id, StateEvent.RoomPowerLevels as any, updatedPowers, '');
|
||||
}
|
||||
|
||||
return result.room_id;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ export function RoomMentionAutocomplete({
|
|||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
|
||||
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { JoinRule } from 'matrix-js-sdk';
|
|||
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
|
||||
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
|
||||
import * as css from './RoomAvatar.css';
|
||||
import { joinRuleToIconSrc } from '../../utils/room';
|
||||
import { getRoomIconSrc } from '../../utils/room';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
type RoomAvatarProps = {
|
||||
|
|
@ -44,13 +44,10 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||
export const RoomIcon = forwardRef<
|
||||
SVGSVGElement,
|
||||
Omit<ComponentProps<typeof Icon>, 'src'> & {
|
||||
joinRule: JoinRule;
|
||||
space?: boolean;
|
||||
joinRule?: JoinRule;
|
||||
roomType?: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
>(({ joinRule, space, ...props }, ref) => (
|
||||
<Icon
|
||||
src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
>(({ joinRule, roomType, locked, ...props }, ref) => (
|
||||
<Icon src={getRoomIconSrc(Icons, roomType, joinRule, locked)} {...props} ref={ref} />
|
||||
));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
|
|
@ -19,6 +19,13 @@ import {
|
|||
Box,
|
||||
Scroll,
|
||||
Avatar,
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Modal,
|
||||
} from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdServer } from '../../utils/matrix';
|
||||
|
|
@ -41,6 +48,11 @@ import { useTimeoutToggle } from '../../hooks/useTimeoutToggle';
|
|||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { CutoutCard } from '../cutout-card';
|
||||
import { SettingTile } from '../setting-tile';
|
||||
import { useInterval } from '../../hooks/useInterval';
|
||||
import { TextViewer } from '../text-viewer';
|
||||
import { ExtendedProfile } from '../../hooks/useExtendedProfile';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
|
||||
export function ServerChip({ server }: { server: string }) {
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -436,15 +448,24 @@ export function IgnoredUserAlert() {
|
|||
);
|
||||
}
|
||||
|
||||
export function OptionsChip({ userId }: { userId: string }) {
|
||||
export function OptionsChip({
|
||||
userId,
|
||||
extendedProfile,
|
||||
}: {
|
||||
userId: string;
|
||||
extendedProfile: ExtendedProfile | null;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
const [developerToolsEnabled] = useSetting(settingsAtom, 'developerTools');
|
||||
|
||||
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
const [profileFieldsOpen, setProfileFieldsOpen] = useState(false);
|
||||
const [menuCoords, setMenuCoords] = useState<RectCords>();
|
||||
|
||||
const openMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCoords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const close = () => setCords(undefined);
|
||||
const closeMenu = () => setMenuCoords(undefined);
|
||||
|
||||
const ignoredUsers = useIgnoredUsers();
|
||||
const ignored = ignoredUsers.includes(userId);
|
||||
|
|
@ -459,56 +480,163 @@ export function OptionsChip({ userId }: { userId: string }) {
|
|||
const ignoring = ignoreState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Bottom"
|
||||
align="Start"
|
||||
offset={4}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: close,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<div style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
toggleIgnore();
|
||||
close();
|
||||
}}
|
||||
before={
|
||||
ignoring ? (
|
||||
<Spinner variant="Critical" size="50" />
|
||||
) : (
|
||||
<Icon size="50" src={Icons.Prohibited} />
|
||||
)
|
||||
}
|
||||
disabled={ignoring}
|
||||
>
|
||||
<Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={open} aria-pressed={!!cords}>
|
||||
{ignoring ? (
|
||||
<Spinner variant="Secondary" size="50" />
|
||||
) : (
|
||||
<Icon size="50" src={Icons.HorizontalDots} />
|
||||
)}
|
||||
</Chip>
|
||||
</PopOut>
|
||||
<>
|
||||
{extendedProfile && (
|
||||
<Overlay open={profileFieldsOpen} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: () => setProfileFieldsOpen(false),
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="500">
|
||||
<TextViewer
|
||||
name="Profile Fields"
|
||||
langName="json"
|
||||
text={JSON.stringify(extendedProfile, null, 2)}
|
||||
requestClose={() => setProfileFieldsOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
<PopOut
|
||||
anchor={menuCoords}
|
||||
position="Bottom"
|
||||
align="Start"
|
||||
offset={4}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: closeMenu,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<div style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
toggleIgnore();
|
||||
closeMenu();
|
||||
}}
|
||||
before={
|
||||
ignoring ? (
|
||||
<Spinner variant="Critical" size="50" />
|
||||
) : (
|
||||
<Icon size="50" src={Icons.Prohibited} />
|
||||
)
|
||||
}
|
||||
disabled={ignoring}
|
||||
>
|
||||
<Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
|
||||
</MenuItem>
|
||||
{extendedProfile && developerToolsEnabled && (
|
||||
<MenuItem
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setProfileFieldsOpen(true);
|
||||
closeMenu();
|
||||
}}
|
||||
before={<Icon size="50" src={Icons.BlockCode} />}
|
||||
>
|
||||
<Text size="B300">View Profile Fields</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
</div>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={openMenu} aria-pressed={!!menuCoords}>
|
||||
{ignoring ? (
|
||||
<Spinner variant="Secondary" size="50" />
|
||||
) : (
|
||||
<Icon size="50" src={Icons.HorizontalDots} />
|
||||
)}
|
||||
</Chip>
|
||||
</PopOut>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimezoneChip({ timezone }: { timezone: string }) {
|
||||
const shortFormat = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: undefined,
|
||||
timeStyle: 'short',
|
||||
timeZone: timezone,
|
||||
}),
|
||||
[timezone]
|
||||
);
|
||||
const longFormat = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
timeZone: timezone,
|
||||
}),
|
||||
[timezone]
|
||||
);
|
||||
const [shortTime, setShortTime] = useState(shortFormat.format());
|
||||
const [longTime, setLongTime] = useState(longFormat.format());
|
||||
const updateTime = useCallback(() => {
|
||||
setShortTime(shortFormat.format());
|
||||
setLongTime(longFormat.format());
|
||||
}, [setShortTime, setLongTime, shortFormat, longFormat]);
|
||||
|
||||
useEffect(() => {
|
||||
updateTime();
|
||||
}, [timezone, updateTime]);
|
||||
|
||||
useInterval(updateTime, 1000);
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={5}
|
||||
align="Center"
|
||||
tooltip={
|
||||
<Tooltip variant="SurfaceVariant" style={{ maxWidth: toRem(280) }}>
|
||||
<Box direction="Column" alignItems="Start" gap="100">
|
||||
<Box gap="100">
|
||||
<Text size="L400">Timezone:</Text>
|
||||
<Badge size="400" variant="Primary">
|
||||
<Text size="T200">{timezone}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
<Text size="T200">{longTime}</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Chip
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
style={{ cursor: 'initial' }}
|
||||
before={<Icon size="50" src={Icons.RecentClock} />}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{shortTime}
|
||||
</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { UserPresence } from '../../hooks/useUserPresence';
|
|||
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||
import { ImageViewer } from '../image-viewer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { ExtendedProfile } from '../../hooks/useExtendedProfile';
|
||||
|
||||
type UserHeroProps = {
|
||||
userId: string;
|
||||
|
|
@ -95,9 +96,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
|||
type UserHeroNameProps = {
|
||||
displayName?: string;
|
||||
userId: string;
|
||||
extendedProfile?: ExtendedProfile;
|
||||
};
|
||||
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
||||
export function UserHeroName({ displayName, userId, extendedProfile }: UserHeroNameProps) {
|
||||
const username = getMxIdLocalPart(userId);
|
||||
const pronouns = extendedProfile?.["io.fsky.nyx.pronouns"];
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column" gap="0">
|
||||
|
|
@ -110,9 +113,10 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
|||
{displayName ?? username ?? userId}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="100" wrap="Wrap">
|
||||
<Box alignItems="Start" gap="100" wrap="Wrap" direction='Column'>
|
||||
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
|
||||
@{username}
|
||||
{pronouns && <span> · {pronouns.map(({ summary }) => summary).join(", ")}</span>}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Box, Button, config, Icon, Icons, Text } from 'folds';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserHero, UserHeroName } from './UserHero';
|
||||
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||
|
|
@ -9,7 +9,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
|
||||
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip, TimezoneChip } from './UserChips';
|
||||
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { PowerChip } from './PowerChip';
|
||||
import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
|
||||
|
|
@ -22,6 +22,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
|||
import { CreatorChip } from './CreatorChip';
|
||||
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { DirectCreateSearchParams } from '../../pages/paths';
|
||||
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
|
||||
|
||||
type UserRoomProfileProps = {
|
||||
userId: string;
|
||||
|
|
@ -56,9 +57,24 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||
const displayName = getMemberDisplayName(room, userId);
|
||||
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
||||
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
|
||||
const timezone = useMemo(() => {
|
||||
// @ts-expect-error Intl.supportedValuesOf isn't in the types yet
|
||||
const supportedTimezones = Intl.supportedValuesOf('timeZone') as string[];
|
||||
const profileTimezone = extendedProfile?.['us.cloke.msc4175.tz'];
|
||||
if (profileTimezone && supportedTimezones.includes(profileTimezone)) {
|
||||
return profileTimezone;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
}, [extendedProfile]);
|
||||
|
||||
const presence = useUserPresence(userId);
|
||||
|
||||
useEffect(() => {
|
||||
refreshExtendedProfile();
|
||||
}, [refreshExtendedProfile]);
|
||||
|
||||
const handleMessage = () => {
|
||||
closeUserRoomProfile();
|
||||
const directSearchParam: DirectCreateSearchParams = {
|
||||
|
|
@ -77,7 +93,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
|
||||
<Box direction="Column" gap="400">
|
||||
<Box gap="400" alignItems="Start">
|
||||
<UserHeroName displayName={displayName} userId={userId} />
|
||||
<UserHeroName displayName={displayName} userId={userId} extendedProfile={extendedProfile ?? undefined} />
|
||||
{userId !== myUserId && (
|
||||
<Box shrink="No">
|
||||
<Button
|
||||
|
|
@ -96,9 +112,10 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||
<Box alignItems="Center" gap="200" wrap="Wrap">
|
||||
{server && <ServerChip server={server} />}
|
||||
<ShareChip userId={userId} />
|
||||
{timezone && <TimezoneChip timezone={timezone} />}
|
||||
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
|
||||
{userId !== myUserId && <MutualRoomsChip userId={userId} />}
|
||||
{userId !== myUserId && <OptionsChip userId={userId} />}
|
||||
{userId !== myUserId && <OptionsChip userId={userId} extendedProfile={extendedProfile ?? null} />}
|
||||
</Box>
|
||||
</Box>
|
||||
{ignored && <IgnoredUserAlert />}
|
||||
|
|
|
|||
37
src/app/features/call/CallView.css.ts
Normal file
37
src/app/features/call/CallView.css.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config } from 'folds';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
|
||||
export const CallViewUserGrid = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginInline: '20px',
|
||||
gap: config.space.S400,
|
||||
});
|
||||
|
||||
export const CallViewUser = style([
|
||||
DefaultReset,
|
||||
ContainerColor({ variant: 'SurfaceVariant' }),
|
||||
{
|
||||
height: '90px',
|
||||
width: '150px',
|
||||
borderRadius: config.radii.R500,
|
||||
},
|
||||
]);
|
||||
|
||||
export const UserLink = style({
|
||||
color: 'inherit',
|
||||
minWidth: 0,
|
||||
cursor: 'pointer',
|
||||
flexGrow: 0,
|
||||
transition: 'all ease-out 200ms',
|
||||
':hover': {
|
||||
transform: 'translateY(-3px)',
|
||||
textDecoration: 'unset',
|
||||
},
|
||||
':focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
});
|
||||
267
src/app/features/call/CallView.tsx
Normal file
267
src/app/features/call/CallView.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { EventType, Room } from 'matrix-js-sdk';
|
||||
import React, {
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
MouseEventHandler,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { Box, Button, config, Spinner, Text } from 'folds';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { useCallMembers } from '../../hooks/useCallMemberships';
|
||||
|
||||
import { CallRefContext } from '../../pages/client/call/PersistentCallContainer';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { CallViewUser } from './CallViewUser';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import * as css from './CallView.css';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
|
||||
type OriginalStyles = {
|
||||
position?: string;
|
||||
top?: string;
|
||||
left?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
zIndex?: string;
|
||||
display?: string;
|
||||
visibility?: string;
|
||||
pointerEvents?: string;
|
||||
border?: string;
|
||||
};
|
||||
|
||||
export function CallViewUserGrid({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Box
|
||||
className={css.CallViewUserGrid}
|
||||
style={{
|
||||
maxWidth: React.Children.count(children) === 4 ? '336px' : '503px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallView({ room }: { room: Room }) {
|
||||
const callIframeRef = useContext(CallRefContext);
|
||||
const iframeHostRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const originalIframeStylesRef = useRef<OriginalStyles | null>(null);
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [visibleCallNames, setVisibleCallNames] = useState('');
|
||||
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canJoin = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId());
|
||||
|
||||
const {
|
||||
isActiveCallReady,
|
||||
activeCallRoomId,
|
||||
isChatOpen,
|
||||
setActiveCallRoomId,
|
||||
hangUp,
|
||||
setViewedCallRoomId,
|
||||
} = useCallState();
|
||||
|
||||
const isActiveCallRoom = activeCallRoomId === room.roomId;
|
||||
const callIsCurrentAndReady = isActiveCallRoom && isActiveCallReady;
|
||||
const callMembers = useCallMembers(mx, room.roomId);
|
||||
|
||||
const getName = (userId: string) =>
|
||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
|
||||
|
||||
const memberDisplayNames = callMembers.map((callMembership) =>
|
||||
getName(callMembership.sender ?? '')
|
||||
);
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
|
||||
const activeIframeDisplayRef = callIframeRef;
|
||||
|
||||
const applyFixedPositioningToIframe = useCallback(() => {
|
||||
const iframeElement = activeIframeDisplayRef?.current;
|
||||
const hostElement = iframeHostRef?.current;
|
||||
|
||||
if (iframeElement && hostElement) {
|
||||
if (!originalIframeStylesRef.current) {
|
||||
const computed = window.getComputedStyle(iframeElement);
|
||||
originalIframeStylesRef.current = {
|
||||
position: iframeElement.style.position || computed.position,
|
||||
top: iframeElement.style.top || computed.top,
|
||||
left: iframeElement.style.left || computed.left,
|
||||
width: iframeElement.style.width || computed.width,
|
||||
height: iframeElement.style.height || computed.height,
|
||||
zIndex: iframeElement.style.zIndex || computed.zIndex,
|
||||
display: iframeElement.style.display || computed.display,
|
||||
visibility: iframeElement.style.visibility || computed.visibility,
|
||||
pointerEvents: iframeElement.style.pointerEvents || computed.pointerEvents,
|
||||
border: iframeElement.style.border || computed.border,
|
||||
};
|
||||
}
|
||||
|
||||
const hostRect = hostElement.getBoundingClientRect();
|
||||
|
||||
iframeElement.style.position = 'fixed';
|
||||
iframeElement.style.top = `${hostRect.top}px`;
|
||||
iframeElement.style.left = `${hostRect.left}px`;
|
||||
iframeElement.style.width = `${hostRect.width}px`;
|
||||
iframeElement.style.height = `${hostRect.height}px`;
|
||||
iframeElement.style.border = 'none';
|
||||
iframeElement.style.zIndex = '1000';
|
||||
iframeElement.style.display = room.isCallRoom() ? 'block' : 'none';
|
||||
iframeElement.style.visibility = 'visible';
|
||||
iframeElement.style.pointerEvents = 'auto';
|
||||
}
|
||||
}, [activeIframeDisplayRef, room]);
|
||||
|
||||
const debouncedApplyFixedPositioning = useDebounce(applyFixedPositioningToIframe, {
|
||||
wait: 50,
|
||||
immediate: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
const iframeElement = activeIframeDisplayRef?.current;
|
||||
const hostElement = iframeHostRef?.current;
|
||||
|
||||
if (room.isCallRoom() || (callIsCurrentAndReady && iframeElement && hostElement)) {
|
||||
applyFixedPositioningToIframe();
|
||||
|
||||
const resizeObserver = new ResizeObserver(debouncedApplyFixedPositioning);
|
||||
if (hostElement) resizeObserver.observe(hostElement);
|
||||
window.addEventListener('scroll', debouncedApplyFixedPositioning, true);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('scroll', debouncedApplyFixedPositioning, true);
|
||||
|
||||
if (iframeElement && originalIframeStylesRef.current) {
|
||||
const originalStyles = originalIframeStylesRef.current;
|
||||
(Object.keys(originalStyles) as Array<keyof OriginalStyles>).forEach((key) => {
|
||||
if (key in iframeElement.style) {
|
||||
iframeElement.style[key as any] = originalStyles[key] || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
originalIframeStylesRef.current = null;
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
activeIframeDisplayRef,
|
||||
applyFixedPositioningToIframe,
|
||||
debouncedApplyFixedPositioning,
|
||||
callIsCurrentAndReady,
|
||||
room,
|
||||
]);
|
||||
|
||||
const handleJoinVCClick: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
if (!canJoin) return;
|
||||
|
||||
if (isMobile) {
|
||||
evt.stopPropagation();
|
||||
setViewedCallRoomId(room.roomId);
|
||||
navigateRoom(room.roomId);
|
||||
}
|
||||
if (!callIsCurrentAndReady) {
|
||||
hangUp();
|
||||
setActiveCallRoomId(room.roomId);
|
||||
}
|
||||
};
|
||||
|
||||
const isCallViewVisible = room.isCallRoom() && (screenSize === ScreenSize.Desktop || !isChatOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (memberDisplayNames.length <= 2) {
|
||||
setVisibleCallNames(memberDisplayNames.join(' and '));
|
||||
} else {
|
||||
const visible = memberDisplayNames.slice(0, 2);
|
||||
const remaining = memberDisplayNames.length - 2;
|
||||
|
||||
setVisibleCallNames(
|
||||
`${visible.join(', ')}, and ${remaining} other${remaining > 1 ? 's' : ''}`
|
||||
);
|
||||
}
|
||||
}, [memberDisplayNames]);
|
||||
|
||||
return (
|
||||
<Box grow="Yes" direction="Column" style={{ display: isCallViewVisible ? 'flex' : 'none' }}>
|
||||
<div
|
||||
ref={iframeHostRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
display: callIsCurrentAndReady ? 'flex' : 'none',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
grow="Yes"
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
style={{
|
||||
display: callIsCurrentAndReady ? 'none' : 'flex',
|
||||
}}
|
||||
>
|
||||
<CallViewUserGrid>
|
||||
{callMembers.slice(0, 6).map((callMember) => (
|
||||
<CallViewUser key={callMember.membershipID} room={room} callMembership={callMember} />
|
||||
))}
|
||||
</CallViewUserGrid>
|
||||
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
style={{
|
||||
paddingBlock: config.space.S200,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="H1"
|
||||
style={{
|
||||
paddingBottom: config.space.S300,
|
||||
}}
|
||||
>
|
||||
{roomName}
|
||||
</Text>
|
||||
<Text size="T200">
|
||||
{visibleCallNames !== '' ? visibleCallNames : 'No one'}{' '}
|
||||
{memberDisplayNames.length > 1 ? 'are' : 'is'} currently in voice
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
variant="Secondary"
|
||||
disabled={!canJoin || isActiveCallRoom}
|
||||
onClick={handleJoinVCClick}
|
||||
>
|
||||
{isActiveCallRoom ? (
|
||||
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||
<Spinner />
|
||||
<Text size="B500">{activeCallRoomId === room.roomId ? `Joining` : 'Join Voice'}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="B500">{canJoin ? 'Join Voice' : 'Channel Locked'}</Text>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
71
src/app/features/call/CallViewUser.tsx
Normal file
71
src/app/features/call/CallViewUser.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import * as css from './CallView.css';
|
||||
|
||||
type CallViewUserProps = {
|
||||
room: Room;
|
||||
callMembership: CallMembership;
|
||||
};
|
||||
|
||||
export const UserProfileButton = as<'button'>(
|
||||
({ as: AsUserProfileButton = 'button', className, ...props }, ref) => (
|
||||
<AsUserProfileButton className={classNames(css.UserLink, className)} {...props} ref={ref} />
|
||||
)
|
||||
);
|
||||
|
||||
export const CallViewUserBase = as<'div'>(({ className, ...props }, ref) => (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="300"
|
||||
className={classNames(css.CallViewUser, className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
export function CallViewUser({ room, callMembership }: CallViewUserProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const userId = callMembership.sender ?? '';
|
||||
const avatarMxcUrl = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
|
||||
: undefined;
|
||||
const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
|
||||
|
||||
const handleUserClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<UserProfileButton onClick={handleUserClick} aria-label={getName}>
|
||||
<CallViewUserBase>
|
||||
<Box direction="Column" grow="Yes" alignItems="Center" gap="200" justifyContent="Center">
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={getName}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="B400" priority="300" truncate>
|
||||
{getName}
|
||||
</Text>
|
||||
</Box>
|
||||
</CallViewUserBase>
|
||||
</UserProfileButton>
|
||||
);
|
||||
}
|
||||
9
src/app/features/call/CinnyWidget.ts
Normal file
9
src/app/features/call/CinnyWidget.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Widget } from 'matrix-widget-api';
|
||||
import { IApp } from './SmallWidget';
|
||||
|
||||
// Wrapper class for the widget definition
|
||||
export class CinnyWidget extends Widget {
|
||||
public constructor(private rawDefinition: IApp) {
|
||||
super(rawDefinition);
|
||||
}
|
||||
}
|
||||
397
src/app/features/call/SmallWidget.ts
Normal file
397
src/app/features/call/SmallWidget.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
* Copyright 2020-2023 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EventEmitter from 'events';
|
||||
import {
|
||||
ClientEvent,
|
||||
Direction,
|
||||
IEvent,
|
||||
KnownMembership,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import {
|
||||
ClientWidgetApi,
|
||||
IRoomEvent,
|
||||
IStickyActionRequest,
|
||||
IWidget,
|
||||
IWidgetData,
|
||||
MatrixCapabilities,
|
||||
WidgetApiFromWidgetAction,
|
||||
WidgetKind,
|
||||
} from 'matrix-widget-api';
|
||||
import { CinnyWidget } from './CinnyWidget';
|
||||
import { SmallWidgetDriver } from './SmallWidgetDriver';
|
||||
|
||||
/**
|
||||
* Generates the URL for the Element Call widget.
|
||||
* @param mx - The MatrixClient instance.
|
||||
* @param roomId - The ID of the room.
|
||||
* @returns The generated URL object.
|
||||
*/
|
||||
export const getWidgetUrl = (
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
elementCallUrl: string,
|
||||
widgetId: string,
|
||||
setParams: any
|
||||
): URL => {
|
||||
const baseUrl = window.location.origin;
|
||||
const url = elementCallUrl
|
||||
? new URL(`${elementCallUrl}/room`)
|
||||
: new URL('/public/element-call/index.html#', baseUrl);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
embed: 'true',
|
||||
widgetId,
|
||||
appPrompt: 'false',
|
||||
skipLobby: setParams.skipLobby ?? 'true', // TODO: skipLobby is deprecated, use intent instead (intent doesn't produce the same effect?)
|
||||
returnToLobby: setParams.returnToLobby ?? 'true',
|
||||
perParticipantE2EE: setParams.perParticipantE2EE ?? 'true',
|
||||
header: 'none',
|
||||
confineToRoom: 'true',
|
||||
theme: setParams.theme ?? 'dark',
|
||||
userId: mx.getUserId()!,
|
||||
deviceId: mx.getDeviceId()!,
|
||||
roomId,
|
||||
baseUrl: mx.baseUrl!,
|
||||
parentUrl: window.location.origin,
|
||||
});
|
||||
|
||||
const replacedParams = params.toString().replace(/%24/g, '$');
|
||||
url.search = `?${replacedParams}`;
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export interface IApp extends IWidget {
|
||||
client: MatrixClient;
|
||||
roomId: string;
|
||||
eventId?: string;
|
||||
avatar_url?: string;
|
||||
sender: string;
|
||||
'io.element.managed_hybrid'?: boolean;
|
||||
}
|
||||
|
||||
export class SmallWidget extends EventEmitter {
|
||||
private client: MatrixClient;
|
||||
|
||||
private messaging: ClientWidgetApi | null = null;
|
||||
|
||||
private mockWidget: CinnyWidget;
|
||||
|
||||
public roomId?: string;
|
||||
|
||||
public url?: string;
|
||||
|
||||
public iframe: HTMLIFrameElement | null = null;
|
||||
|
||||
private type: string; // Type of the widget (e.g., 'm.call')
|
||||
|
||||
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
|
||||
|
||||
private readonly eventsToFeed = new WeakSet<MatrixEvent>();
|
||||
|
||||
private stickyPromise?: () => Promise<void>;
|
||||
|
||||
constructor(private iapp: IApp) {
|
||||
super();
|
||||
this.client = iapp.client;
|
||||
this.roomId = iapp.roomId;
|
||||
this.url = iapp.url;
|
||||
this.type = iapp.type;
|
||||
this.mockWidget = new CinnyWidget(iapp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the widget messaging API.
|
||||
* @param iframe - The HTMLIFrameElement to bind to.
|
||||
* @returns The initialized ClientWidgetApi instance.
|
||||
*/
|
||||
startMessaging(iframe: HTMLIFrameElement): ClientWidgetApi {
|
||||
// Ensure the driver is correctly instantiated
|
||||
// The capabilities array might need adjustment based on required permissions
|
||||
const driver = new SmallWidgetDriver(
|
||||
this.client,
|
||||
[],
|
||||
this.mockWidget,
|
||||
WidgetKind.Room,
|
||||
true,
|
||||
this.roomId
|
||||
);
|
||||
this.iframe = iframe;
|
||||
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
|
||||
|
||||
// Emit events during the widget lifecycle
|
||||
this.messaging.on('preparing', () => this.emit('preparing'));
|
||||
this.messaging.on('error:preparing', (err: unknown) => this.emit('error:preparing', err));
|
||||
this.messaging.once('ready', () => this.emit('ready'));
|
||||
// this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); // Uncomment if needed
|
||||
|
||||
// Populate the map of "read up to" events for this widget with the current event in every room.
|
||||
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
|
||||
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const room of this.client.getRooms()) {
|
||||
// Timelines are most recent last
|
||||
const events = room.getLiveTimeline()?.getEvents() || [];
|
||||
const roomEvent = events[events.length - 1];
|
||||
// force later code to think the room is fresh
|
||||
if (roomEvent) {
|
||||
const eventId = roomEvent.getId();
|
||||
if (eventId) this.readUpToMap[room.roomId] = eventId;
|
||||
}
|
||||
}
|
||||
|
||||
this.messaging.on('action:org.matrix.msc2876.read_events', (ev: CustomEvent) => {
|
||||
const room = this.client.getRoom(this.roomId);
|
||||
const events: Partial<IEvent>[] = [];
|
||||
const { type } = ev.detail.data;
|
||||
|
||||
ev.preventDefault();
|
||||
if (room === null) {
|
||||
return this.messaging?.transport.reply(ev.detail, { events });
|
||||
}
|
||||
const state = room.getLiveTimeline().getState(Direction.Forward);
|
||||
if (state === undefined) {
|
||||
return this.messaging?.transport.reply(ev.detail, { events });
|
||||
}
|
||||
|
||||
const stateEvents = state.events?.get(type);
|
||||
|
||||
Array.from(stateEvents?.values() ?? []).forEach((eventObject) => {
|
||||
events.push(eventObject.event);
|
||||
});
|
||||
|
||||
return this.messaging?.transport.reply(ev.detail, { events });
|
||||
});
|
||||
|
||||
this.client.on(ClientEvent.Event, this.onEvent);
|
||||
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
this.messaging.on(
|
||||
`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
|
||||
async (ev: CustomEvent<IStickyActionRequest>) => {
|
||||
if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
|
||||
ev.preventDefault();
|
||||
if (ev.detail.data.value) {
|
||||
// If the widget wants to become sticky we wait for the stickyPromise to resolve
|
||||
if (this.stickyPromise) await this.stickyPromise();
|
||||
this.messaging.transport.reply(ev.detail, {});
|
||||
}
|
||||
// Stop being persistent can be done instantly
|
||||
// MAKE PERSISTENT HERE
|
||||
// Send the ack after the widget actually has become sticky.
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return this.messaging;
|
||||
}
|
||||
|
||||
private onEvent = (ev: MatrixEvent): void => {
|
||||
this.client.decryptEventIfNeeded(ev);
|
||||
this.feedEvent(ev);
|
||||
};
|
||||
|
||||
private onEventDecrypted = (ev: MatrixEvent): void => {
|
||||
this.feedEvent(ev);
|
||||
};
|
||||
|
||||
private onReadEvent = (ev: MatrixEvent): void => {
|
||||
this.feedEvent(ev);
|
||||
};
|
||||
|
||||
private onToDeviceEvent = async (ev: MatrixEvent): Promise<void> => {
|
||||
await this.client.decryptEventIfNeeded(ev);
|
||||
if (ev.isDecryptionFailure()) return;
|
||||
await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the event comes from a room that we've been invited to
|
||||
* (in which case we likely don't have the full timeline).
|
||||
*/
|
||||
private isFromInvite(ev: MatrixEvent): boolean {
|
||||
const room = this.client.getRoom(ev.getRoomId());
|
||||
return room?.getMyMembership() === KnownMembership.Invite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the event has a relation to an unknown parent.
|
||||
*/
|
||||
private relatesToUnknown(ev: MatrixEvent): boolean {
|
||||
// Replies to unknown events don't count
|
||||
if (!ev.relationEventId || ev.replyEventId) return false;
|
||||
const room = this.client.getRoom(ev.getRoomId());
|
||||
return room === null || !room.findEventById(ev.relationEventId);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
private arrayFastClone<T>(a: T[]): T[] {
|
||||
return a.slice(0, a.length);
|
||||
}
|
||||
|
||||
private advanceReadUpToMarker(ev: MatrixEvent): boolean {
|
||||
const evId = ev.getId();
|
||||
if (evId === undefined) return false;
|
||||
const roomId = ev.getRoomId();
|
||||
if (roomId === undefined) return false;
|
||||
const room = this.client.getRoom(roomId);
|
||||
if (room === null) return false;
|
||||
|
||||
const upToEventId = this.readUpToMap[ev.getRoomId()!];
|
||||
if (!upToEventId) {
|
||||
// There's no marker yet; start it at this event
|
||||
this.readUpToMap[roomId] = evId;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Small optimization for exact match (skip the search)
|
||||
if (upToEventId === evId) return false;
|
||||
|
||||
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
|
||||
// to avoid overusing the CPU.
|
||||
const timeline = room.getLiveTimeline();
|
||||
const events = this.arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);
|
||||
|
||||
let advanced = false;
|
||||
|
||||
events.some((timelineEvent) => {
|
||||
const id = timelineEvent.getId();
|
||||
|
||||
if (id === upToEventId) {
|
||||
// The event must be somewhere before the "read up to" marker
|
||||
return true;
|
||||
}
|
||||
|
||||
if (id === evId) {
|
||||
// The event is after the marker; advance it
|
||||
this.readUpToMap[roomId] = evId;
|
||||
advanced = true;
|
||||
return true;
|
||||
}
|
||||
// We can't say for sure whether the widget has seen the event; let's
|
||||
// just assume that it has
|
||||
return false;
|
||||
});
|
||||
|
||||
return advanced;
|
||||
}
|
||||
|
||||
private feedEvent(ev: MatrixEvent): void {
|
||||
if (this.messaging === null) return;
|
||||
|
||||
if (
|
||||
// If we had decided earlier to feed this event to the widget, but
|
||||
// it just wasn't ready, give it another try
|
||||
this.eventsToFeed.delete(ev) ||
|
||||
// Skip marker timeline check for events with relations to unknown parent because these
|
||||
// events are not added to the timeline here and will be ignored otherwise:
|
||||
// https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
|
||||
this.relatesToUnknown(ev) ||
|
||||
// Skip marker timeline check for rooms where membership is
|
||||
// 'invite', otherwise the membership event from the invitation room
|
||||
// will advance the marker and new state events will not be
|
||||
// forwarded to the widget.
|
||||
this.isFromInvite(ev) ||
|
||||
// Check whether this event would be before or after our "read up to" marker. If it's
|
||||
// before, or we can't decide, then we assume the widget will have already seen the event.
|
||||
// If the event is after, or we don't have a marker for the room, then the marker will advance and we'll
|
||||
// send it through.
|
||||
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
|
||||
// receiving ancient events from backfill and such.
|
||||
this.advanceReadUpToMarker(ev)
|
||||
) {
|
||||
// If the event is still being decrypted, remember that we want to
|
||||
// feed it to the widget (even if not strictly in the order given by
|
||||
// the timeline) and get back to it later
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
|
||||
this.eventsToFeed.add(ev);
|
||||
} else {
|
||||
const raw = ev.getEffectiveEvent();
|
||||
this.messaging.feedEvent(raw as IRoomEvent, this.roomId ?? '').catch(() => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the widget messaging and cleans up resources.
|
||||
*/
|
||||
stopMessaging() {
|
||||
if (this.messaging) {
|
||||
this.messaging.stop(); // Example if a stop method exists
|
||||
this.messaging.removeAllListeners(); // Remove listeners attached by SmallWidget
|
||||
this.messaging = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the data object for the widget.
|
||||
* @param client - The MatrixClient instance.
|
||||
* @param roomId - The ID of the room.
|
||||
* @param currentData - Existing widget data.
|
||||
* @param overwriteData - Data to merge or overwrite.
|
||||
* @returns The final widget data object.
|
||||
*/
|
||||
export const getWidgetData = (
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
currentData: object,
|
||||
overwriteData: object
|
||||
): IWidgetData => {
|
||||
// Example: Determine E2EE based on room state if needed
|
||||
const perParticipantE2EE = true; // Default or based on logic
|
||||
// const roomEncryption = client.getRoom(roomId)?.currentState.getStateEvents(EventType.RoomEncryption, "");
|
||||
// if (roomEncryption) perParticipantE2EE = true; // Simplified example
|
||||
|
||||
return {
|
||||
...currentData,
|
||||
...overwriteData,
|
||||
perParticipantE2EE,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a virtual widget definition (IApp).
|
||||
* @param client - MatrixClient instance.
|
||||
* @param id - Widget ID.
|
||||
* @param creatorUserId - User ID of the creator.
|
||||
* @param name - Widget display name.
|
||||
* @param type - Widget type (e.g., 'm.call').
|
||||
* @param url - Widget URL.
|
||||
* @param waitForIframeLoad - Whether to wait for iframe load signal.
|
||||
* @param data - Widget data.
|
||||
* @param roomId - Room ID.
|
||||
* @returns The IApp widget definition.
|
||||
*/
|
||||
export const createVirtualWidget = (
|
||||
client: MatrixClient,
|
||||
id: string,
|
||||
creatorUserId: string,
|
||||
name: string,
|
||||
type: string,
|
||||
url: URL,
|
||||
waitForIframeLoad: boolean,
|
||||
data: IWidgetData,
|
||||
roomId: string
|
||||
): IApp => ({
|
||||
client,
|
||||
id,
|
||||
creatorUserId,
|
||||
name,
|
||||
type,
|
||||
url: url.toString(), // Store URL as string in the definition
|
||||
waitForIframeLoad,
|
||||
data,
|
||||
roomId,
|
||||
// Add other required fields from IWidget if necessary
|
||||
sender: creatorUserId, // Example: Assuming sender is the creator
|
||||
});
|
||||
551
src/app/features/call/SmallWidgetDriver.ts
Normal file
551
src/app/features/call/SmallWidgetDriver.ts
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
/* eslint-disable no-return-await */
|
||||
/* eslint-disable no-param-reassign */
|
||||
/* eslint-disable no-continue */
|
||||
/* eslint-disable class-methods-use-this */
|
||||
/* eslint-disable no-dupe-class-members */
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
* Copyright 2020-2023 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import {
|
||||
type Capability,
|
||||
EventDirection,
|
||||
type ISendDelayedEventDetails,
|
||||
type ISendEventDetails,
|
||||
type IReadEventRelationsResult,
|
||||
type IRoomEvent,
|
||||
MatrixCapabilities,
|
||||
type Widget,
|
||||
WidgetDriver,
|
||||
WidgetEventCapability,
|
||||
WidgetKind,
|
||||
type IWidgetApiErrorResponseDataDetails,
|
||||
type ISearchUserDirectoryResult,
|
||||
type IGetMediaConfigResult,
|
||||
type UpdateDelayedEventAction,
|
||||
OpenIDRequestState,
|
||||
SimpleObservable,
|
||||
IOpenIDUpdate,
|
||||
} from 'matrix-widget-api';
|
||||
import {
|
||||
EventType,
|
||||
type IContent,
|
||||
MatrixError,
|
||||
type MatrixEvent,
|
||||
Direction,
|
||||
type SendDelayedEventResponse,
|
||||
type StateEvents,
|
||||
type TimelineEvents,
|
||||
MatrixClient,
|
||||
} from 'matrix-js-sdk';
|
||||
|
||||
export class SmallWidgetDriver extends WidgetDriver {
|
||||
private allowedCapabilities: Set<Capability>;
|
||||
|
||||
private readonly mxClient: MatrixClient; // Store the client instance
|
||||
|
||||
public constructor(
|
||||
mx: MatrixClient,
|
||||
allowedCapabilities: Capability[],
|
||||
private forWidget: Widget,
|
||||
private forWidgetKind: WidgetKind,
|
||||
virtual: boolean, // Assuming 'virtual' might be needed later, kept for consistency
|
||||
private inRoomId?: string
|
||||
) {
|
||||
super();
|
||||
this.mxClient = mx; // Store the passed instance
|
||||
|
||||
this.allowedCapabilities = new Set([
|
||||
...allowedCapabilities,
|
||||
MatrixCapabilities.Screenshots,
|
||||
// Add other base capabilities as needed, e.g., ElementWidgetCapabilities.RequiresClient
|
||||
]);
|
||||
|
||||
// --- Capabilities specific to Element Call (or similar trusted widgets) ---
|
||||
// This is a trusted Element Call widget that we control (adjust if not Element Call)
|
||||
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
|
||||
this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers);
|
||||
this.allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
|
||||
this.allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
|
||||
// Capability to access the room timeline (MSC2762)
|
||||
this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
|
||||
// Capability to read room state (MSC2762)
|
||||
this.allowedCapabilities.add(`org.matrix.msc2762.state:${inRoomId}`);
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw
|
||||
);
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call').raw
|
||||
);
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomEncryption).raw
|
||||
);
|
||||
const clientUserId = this.mxClient.getSafeUserId();
|
||||
// For the legacy membership type
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(
|
||||
EventDirection.Send,
|
||||
'org.matrix.msc3401.call.member',
|
||||
clientUserId
|
||||
).raw
|
||||
);
|
||||
const clientDeviceId = this.mxClient.getDeviceId();
|
||||
if (clientDeviceId !== null) {
|
||||
// For the session membership type compliant with MSC4143
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(
|
||||
EventDirection.Send,
|
||||
'org.matrix.msc3401.call.member',
|
||||
`_${clientUserId}_${clientDeviceId}`
|
||||
).raw
|
||||
);
|
||||
// Version with no leading underscore, for room versions whose auth rules allow it
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(
|
||||
EventDirection.Send,
|
||||
'org.matrix.msc3401.call.member',
|
||||
`${clientUserId}_${clientDeviceId}`
|
||||
).raw
|
||||
);
|
||||
}
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call.member')
|
||||
.raw
|
||||
);
|
||||
// for determining auth rules specific to the room version
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw
|
||||
);
|
||||
|
||||
const sendRecvRoomEvents = [
|
||||
'io.element.call.encryption_keys',
|
||||
'org.matrix.rageshake_request',
|
||||
EventType.Reaction,
|
||||
EventType.RoomRedaction,
|
||||
'io.element.call.reaction',
|
||||
];
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const eventType of sendRecvRoomEvents) {
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forRoomEvent(EventDirection.Send, eventType).raw
|
||||
);
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forRoomEvent(EventDirection.Receive, eventType).raw
|
||||
);
|
||||
}
|
||||
|
||||
const sendRecvToDevice = [
|
||||
EventType.CallInvite,
|
||||
EventType.CallCandidates,
|
||||
EventType.CallAnswer,
|
||||
EventType.CallHangup,
|
||||
EventType.CallReject,
|
||||
EventType.CallSelectAnswer,
|
||||
EventType.CallNegotiate,
|
||||
EventType.CallSDPStreamMetadataChanged,
|
||||
EventType.CallSDPStreamMetadataChangedPrefix,
|
||||
EventType.CallReplaces,
|
||||
EventType.CallEncryptionKeysPrefix,
|
||||
];
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const eventType of sendRecvToDevice) {
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw
|
||||
);
|
||||
this.allowedCapabilities.add(
|
||||
WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
|
||||
// Stubbed under the assumption voice calls will be valid thru element-call
|
||||
return requested;
|
||||
}
|
||||
|
||||
public async sendEvent<K extends keyof StateEvents>(
|
||||
eventType: K,
|
||||
content: StateEvents[K],
|
||||
stateKey: string | null,
|
||||
targetRoomId: string | null
|
||||
): Promise<ISendEventDetails>;
|
||||
|
||||
public async sendEvent<K extends keyof TimelineEvents>(
|
||||
eventType: K,
|
||||
content: TimelineEvents[K],
|
||||
stateKey: null,
|
||||
targetRoomId: string | null
|
||||
): Promise<ISendEventDetails>;
|
||||
|
||||
public async sendEvent(
|
||||
eventType: string,
|
||||
content: IContent,
|
||||
stateKey: string | null = null,
|
||||
targetRoomId: string | null = null
|
||||
): Promise<ISendEventDetails> {
|
||||
const client = this.mxClient;
|
||||
const roomId = targetRoomId || this.inRoomId;
|
||||
|
||||
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
|
||||
|
||||
let r: { event_id: string } | null;
|
||||
if (stateKey !== null) {
|
||||
// state event
|
||||
r = await client.sendStateEvent(
|
||||
roomId,
|
||||
eventType as keyof StateEvents,
|
||||
content as StateEvents[keyof StateEvents],
|
||||
stateKey
|
||||
);
|
||||
} else if (eventType === EventType.RoomRedaction) {
|
||||
// special case: extract the `redacts` property and call redact
|
||||
r = await client.redactEvent(roomId, content.redacts);
|
||||
} else {
|
||||
// message event
|
||||
r = await client.sendEvent(
|
||||
roomId,
|
||||
eventType as keyof TimelineEvents,
|
||||
content as TimelineEvents[keyof TimelineEvents]
|
||||
);
|
||||
}
|
||||
|
||||
return { roomId, eventId: r.event_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental Part of MSC4140 & MSC4157
|
||||
* @see {@link WidgetDriver#sendDelayedEvent}
|
||||
*/
|
||||
public async sendDelayedEvent<K extends keyof StateEvents>(
|
||||
delay: number | null,
|
||||
parentDelayId: string | null,
|
||||
eventType: K,
|
||||
content: StateEvents[K],
|
||||
stateKey: string | null,
|
||||
targetRoomId: string | null
|
||||
): Promise<ISendDelayedEventDetails>;
|
||||
|
||||
/**
|
||||
* @experimental Part of MSC4140 & MSC4157
|
||||
*/
|
||||
public async sendDelayedEvent<K extends keyof TimelineEvents>(
|
||||
delay: number | null,
|
||||
parentDelayId: string | null,
|
||||
eventType: K,
|
||||
content: TimelineEvents[K],
|
||||
stateKey: null,
|
||||
targetRoomId: string | null
|
||||
): Promise<ISendDelayedEventDetails>;
|
||||
|
||||
public async sendDelayedEvent(
|
||||
delay: number | null,
|
||||
parentDelayId: string | null,
|
||||
eventType: string,
|
||||
content: IContent,
|
||||
stateKey: string | null = null,
|
||||
targetRoomId: string | null = null
|
||||
): Promise<ISendDelayedEventDetails> {
|
||||
const client = this.mxClient;
|
||||
const roomId = targetRoomId || this.inRoomId;
|
||||
|
||||
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
|
||||
|
||||
let delayOpts;
|
||||
if (delay !== null) {
|
||||
delayOpts = {
|
||||
delay,
|
||||
...(parentDelayId !== null && { parent_delay_id: parentDelayId }),
|
||||
};
|
||||
} else if (parentDelayId !== null) {
|
||||
delayOpts = {
|
||||
parent_delay_id: parentDelayId,
|
||||
};
|
||||
} else {
|
||||
throw new Error('Must provide at least one of delay or parentDelayId');
|
||||
}
|
||||
|
||||
let r: SendDelayedEventResponse | null;
|
||||
if (stateKey !== null) {
|
||||
// state event
|
||||
r = await client._unstable_sendDelayedStateEvent(
|
||||
roomId,
|
||||
delayOpts,
|
||||
eventType as keyof StateEvents,
|
||||
content as StateEvents[keyof StateEvents],
|
||||
stateKey
|
||||
);
|
||||
} else {
|
||||
// message event
|
||||
r = await client._unstable_sendDelayedEvent(
|
||||
roomId,
|
||||
delayOpts,
|
||||
null,
|
||||
eventType as keyof TimelineEvents,
|
||||
content as TimelineEvents[keyof TimelineEvents]
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
roomId,
|
||||
delayId: r.delay_id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental Part of MSC4140 & MSC4157
|
||||
*/
|
||||
public async updateDelayedEvent(
|
||||
delayId: string,
|
||||
action: UpdateDelayedEventAction
|
||||
): Promise<void> {
|
||||
const client = this.mxClient;
|
||||
|
||||
if (!client) throw new Error('Not in a room or not attached to a client');
|
||||
|
||||
await client._unstable_updateDelayedEvent(delayId, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@link WidgetDriver#sendToDevice}
|
||||
*/
|
||||
public async sendToDevice(
|
||||
eventType: string,
|
||||
encrypted: boolean,
|
||||
contentMap: { [userId: string]: { [deviceId: string]: object } }
|
||||
): Promise<void> {
|
||||
const client = this.mxClient;
|
||||
|
||||
if (encrypted) {
|
||||
const crypto = client.getCrypto();
|
||||
if (!crypto) throw new Error('E2EE not enabled');
|
||||
|
||||
// attempt to re-batch these up into a single request
|
||||
const invertedContentMap: { [content: string]: { userId: string; deviceId: string }[] } = {};
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const userId of Object.keys(contentMap)) {
|
||||
const userContentMap = contentMap[userId];
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const deviceId of Object.keys(userContentMap)) {
|
||||
const content = userContentMap[deviceId];
|
||||
const stringifiedContent = JSON.stringify(content);
|
||||
invertedContentMap[stringifiedContent] = invertedContentMap[stringifiedContent] || [];
|
||||
invertedContentMap[stringifiedContent].push({ userId, deviceId });
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(invertedContentMap).map(async ([stringifiedContent, recipients]) => {
|
||||
const batch = await crypto.encryptToDeviceMessages(
|
||||
eventType,
|
||||
recipients,
|
||||
JSON.parse(stringifiedContent)
|
||||
);
|
||||
|
||||
await client.queueToDevice(batch);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
await client.queueToDevice({
|
||||
eventType,
|
||||
batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
|
||||
Object.entries(userContentMap).map(([deviceId, content]) => ({
|
||||
userId,
|
||||
deviceId,
|
||||
payload: content,
|
||||
}))
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all events of the given type, and optionally `msgtype` (if applicable/defined),
|
||||
* the user has access to. The widget API will have already verified that the widget is
|
||||
* capable of receiving the events. Less events than the limit are allowed to be returned,
|
||||
* but not more.
|
||||
* @param roomId The ID of the room to look within.
|
||||
* @param eventType The event type to be read.
|
||||
* @param msgtype The msgtype of the events to be read, if applicable/defined.
|
||||
* @param stateKey The state key of the events to be read, if applicable/defined.
|
||||
* @param limit The maximum number of events to retrieve. Will be zero to denote "as many as
|
||||
* possible".
|
||||
* @param since When null, retrieves the number of events specified by the "limit" parameter.
|
||||
* Otherwise, the event ID at which only subsequent events will be returned, as many as specified
|
||||
* in "limit".
|
||||
* @returns {Promise<IRoomEvent[]>} Resolves to the room events, or an empty array.
|
||||
*/
|
||||
public async readRoomTimeline(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
msgtype: string | undefined,
|
||||
stateKey: string | undefined,
|
||||
limit: number,
|
||||
since: string | undefined
|
||||
): Promise<IRoomEvent[]> {
|
||||
limit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
|
||||
|
||||
const room = this.mxClient.getRoom(roomId);
|
||||
if (room === null) return [];
|
||||
const results: MatrixEvent[] = [];
|
||||
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i];
|
||||
if (results.length >= limit) break;
|
||||
if (since !== undefined && ev.getId() === since) break;
|
||||
|
||||
if (ev.getType() !== eventType || ev.isState()) continue;
|
||||
if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent().msgtype)
|
||||
continue;
|
||||
if (ev.getStateKey() !== undefined && stateKey !== undefined && ev.getStateKey() !== stateKey)
|
||||
continue;
|
||||
results.push(ev);
|
||||
}
|
||||
|
||||
return results.map((e) => e.getEffectiveEvent() as IRoomEvent);
|
||||
}
|
||||
|
||||
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
|
||||
return observer.update({
|
||||
state: OpenIDRequestState.Allowed,
|
||||
token: await this.mxClient.getOpenIdToken(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current values of all matching room state entries.
|
||||
* @param roomId The ID of the room.
|
||||
* @param eventType The event type of the entries to be read.
|
||||
* @param stateKey The state key of the entry to be read. If undefined,
|
||||
* all room state entries with a matching event type should be returned.
|
||||
* @returns {Promise<IRoomEvent[]>} Resolves to the events representing the
|
||||
* current values of the room state entries.
|
||||
*/
|
||||
public async readRoomState(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
stateKey: string | undefined
|
||||
): Promise<IRoomEvent[]> {
|
||||
const room = this.mxClient.getRoom(roomId);
|
||||
if (room === null) return [];
|
||||
const state = room.getLiveTimeline().getState(Direction.Forward);
|
||||
if (state === undefined) return [];
|
||||
|
||||
if (stateKey === undefined)
|
||||
return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent);
|
||||
const event = state.getStateEvents(eventType, stateKey);
|
||||
return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent];
|
||||
}
|
||||
|
||||
/*
|
||||
public async navigate(uri: string): Promise<void> {
|
||||
navigateToPermalink(uri);
|
||||
}
|
||||
*/
|
||||
|
||||
public async readEventRelations(
|
||||
eventId: string,
|
||||
roomId?: string,
|
||||
relationType?: string,
|
||||
eventType?: string,
|
||||
from?: string,
|
||||
to?: string,
|
||||
limit?: number,
|
||||
direction?: 'f' | 'b'
|
||||
): Promise<IReadEventRelationsResult> {
|
||||
const client = this.mxClient;
|
||||
const dir = direction as Direction;
|
||||
roomId = roomId ?? this.inRoomId ?? undefined;
|
||||
|
||||
if (typeof roomId !== 'string') {
|
||||
throw new Error('Error while reading the current room');
|
||||
}
|
||||
|
||||
const { events, nextBatch, prevBatch } = await client.relations(
|
||||
roomId,
|
||||
eventId,
|
||||
relationType ?? null,
|
||||
eventType ?? null,
|
||||
{ from, to, limit, dir }
|
||||
);
|
||||
|
||||
return {
|
||||
chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent),
|
||||
nextBatch: nextBatch ?? undefined,
|
||||
prevBatch: prevBatch ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
public async searchUserDirectory(
|
||||
searchTerm: string,
|
||||
limit?: number
|
||||
): Promise<ISearchUserDirectoryResult> {
|
||||
const client = this.mxClient;
|
||||
|
||||
const { limited, results } = await client.searchUserDirectory({ term: searchTerm, limit });
|
||||
|
||||
return {
|
||||
limited,
|
||||
results: results.map((r) => ({
|
||||
userId: r.user_id,
|
||||
displayName: r.display_name,
|
||||
avatarUrl: r.avatar_url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
public async getMediaConfig(): Promise<IGetMediaConfigResult> {
|
||||
const client = this.mxClient;
|
||||
|
||||
return await client.getMediaConfig();
|
||||
}
|
||||
|
||||
public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> {
|
||||
const client = this.mxClient;
|
||||
|
||||
const uploadResult = await client.uploadContent(file);
|
||||
|
||||
return { contentUri: uploadResult.content_uri };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from the media repository on the homeserver.
|
||||
*
|
||||
* @param contentUri - the MXC URI of the file to download
|
||||
* @returns an object with: file - response contents as Blob
|
||||
*/
|
||||
/*
|
||||
public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> {
|
||||
const client = this.mxClient;
|
||||
const media = mediaFromMxc(contentUri, client);
|
||||
const response = await media.downloadSource();
|
||||
const blob = await response.blob();
|
||||
return { file: blob };
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the IDs of all joined or invited rooms currently known to the
|
||||
* client.
|
||||
* @returns The room IDs.
|
||||
*/
|
||||
public getKnownRooms(): string[] {
|
||||
return this.mxClient.getVisibleRooms().map((r) => r.roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expresses a {@link MatrixError} as a JSON payload
|
||||
* for use by Widget API error responses.
|
||||
* @param error The error to handle.
|
||||
* @returns The error expressed as a JSON payload,
|
||||
* or undefined if it is not a {@link MatrixError}.
|
||||
*/
|
||||
public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined {
|
||||
return error instanceof MatrixError
|
||||
? { matrix_api_error: error.asWidgetApiErrorData() }
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
AccountDataSubmitCallback,
|
||||
} from '../../../components/AccountDataEditor';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { CollapsibleCard } from '../../../components/CollapsibleCard';
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -175,216 +176,166 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
<CollapsibleCard
|
||||
expand={expandState}
|
||||
setExpand={setExpandState}
|
||||
title="Room State"
|
||||
description="State events of the room."
|
||||
>
|
||||
<SettingTile
|
||||
title="Room State"
|
||||
description="State events of the room."
|
||||
after={
|
||||
<Button
|
||||
onClick={() => setExpandState(!expandState)}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
<Box direction="Column" gap="100">
|
||||
<Box justifyContent="SpaceBetween">
|
||||
<Text size="L400">Events</Text>
|
||||
<Text size="L400">Total: {roomState.size}</Text>
|
||||
</Box>
|
||||
<CutoutCard>
|
||||
<MenuItem
|
||||
onClick={() => setComposeEvent({ stateKey: '' })}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
before={
|
||||
<Icon
|
||||
src={expandState ? Icons.ChevronTop : Icons.ChevronBottom}
|
||||
size="100"
|
||||
filled
|
||||
/>
|
||||
}
|
||||
radii="0"
|
||||
before={<Icon size="50" src={Icons.Plus} />}
|
||||
>
|
||||
<Text size="B300">{expandState ? 'Collapse' : 'Expand'}</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{expandState && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box justifyContent="SpaceBetween">
|
||||
<Text size="L400">Events</Text>
|
||||
<Text size="L400">Total: {roomState.size}</Text>
|
||||
</Box>
|
||||
<CutoutCard>
|
||||
<MenuItem
|
||||
onClick={() => setComposeEvent({ stateKey: '' })}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
before={<Icon size="50" src={Icons.Plus} />}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
Add New
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{Array.from(roomState.keys())
|
||||
.sort()
|
||||
.map((eventType) => {
|
||||
const expanded = eventType === expandStateType;
|
||||
const stateKeyToEvents = roomState.get(eventType);
|
||||
if (!stateKeyToEvents) return null;
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
Add New
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{Array.from(roomState.keys())
|
||||
.sort()
|
||||
.map((eventType) => {
|
||||
const expanded = eventType === expandStateType;
|
||||
const stateKeyToEvents = roomState.get(eventType);
|
||||
if (!stateKeyToEvents) return null;
|
||||
|
||||
return (
|
||||
<Box id={eventType} key={eventType} direction="Column" gap="100">
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
setExpandStateType(expanded ? undefined : eventType)
|
||||
}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
before={
|
||||
<Icon
|
||||
size="50"
|
||||
src={expanded ? Icons.ChevronBottom : Icons.ChevronRight}
|
||||
/>
|
||||
}
|
||||
after={<Text size="L400">{stateKeyToEvents.size}</Text>}
|
||||
return (
|
||||
<Box id={eventType} key={eventType} direction="Column" gap="100">
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
setExpandStateType(expanded ? undefined : eventType)
|
||||
}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
before={
|
||||
<Icon
|
||||
size="50"
|
||||
src={expanded ? Icons.ChevronBottom : Icons.ChevronRight}
|
||||
/>
|
||||
}
|
||||
after={<Text size="L400">{stateKeyToEvents.size}</Text>}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
{eventType}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: config.space.S400,
|
||||
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
{eventType}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: config.space.S400,
|
||||
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
setComposeEvent({ type: eventType, stateKey: '' })
|
||||
}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
before={<Icon size="50" src={Icons.Plus} />}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
setComposeEvent({ type: eventType, stateKey: '' })
|
||||
}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
before={<Icon size="50" src={Icons.Plus} />}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
Add New
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{Array.from(stateKeyToEvents.keys())
|
||||
.sort()
|
||||
.map((stateKey) => (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setOpenStateEvent({
|
||||
type: eventType,
|
||||
stateKey,
|
||||
});
|
||||
}}
|
||||
key={stateKey}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
{stateKey ? `"${stateKey}"` : 'Default'}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</CutoutCard>
|
||||
</Box>
|
||||
)}
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
Add New
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{Array.from(stateKeyToEvents.keys())
|
||||
.sort()
|
||||
.map((stateKey) => (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setOpenStateEvent({
|
||||
type: eventType,
|
||||
stateKey,
|
||||
});
|
||||
}}
|
||||
key={stateKey}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
{stateKey ? `"${stateKey}"` : 'Default'}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</CutoutCard>
|
||||
</Box>
|
||||
</CollapsibleCard>
|
||||
<CollapsibleCard
|
||||
expand={expandAccountData}
|
||||
setExpand={setExpandAccountData}
|
||||
title="Account Data"
|
||||
description="Private personalization data stored within room"
|
||||
>
|
||||
<SettingTile
|
||||
title="Account Data"
|
||||
description="Private personalization data stored within room."
|
||||
after={
|
||||
<Button
|
||||
onClick={() => setExpandAccountData(!expandAccountData)}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
before={
|
||||
<Icon
|
||||
src={expandAccountData ? Icons.ChevronTop : Icons.ChevronBottom}
|
||||
size="100"
|
||||
filled
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text size="B300">{expandAccountData ? 'Collapse' : 'Expand'}</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{expandAccountData && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box justifyContent="SpaceBetween">
|
||||
<Text size="L400">Events</Text>
|
||||
<Text size="L400">Total: {accountData.size}</Text>
|
||||
</Box>
|
||||
<CutoutCard>
|
||||
<MenuItem
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
before={<Icon size="50" src={Icons.Plus} />}
|
||||
onClick={() => setAccountDataType(null)}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
Add New
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{Array.from(accountData.keys())
|
||||
.sort()
|
||||
.map((type) => (
|
||||
<MenuItem
|
||||
key={type}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||
onClick={() => setAccountDataType(type)}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
{type}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</CutoutCard>
|
||||
<Box direction="Column" gap="100">
|
||||
<Box justifyContent="SpaceBetween">
|
||||
<Text size="L400">Events</Text>
|
||||
<Text size="L400">Total: {accountData.size}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</SequenceCard>
|
||||
<CutoutCard>
|
||||
<MenuItem
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
before={<Icon size="50" src={Icons.Plus} />}
|
||||
onClick={() => setAccountDataType(null)}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
Add New
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{Array.from(accountData.keys())
|
||||
.sort()
|
||||
.map((type) => (
|
||||
<MenuItem
|
||||
key={type}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||
onClick={() => setAccountDataType(type)}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
{type}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</CutoutCard>
|
||||
</Box>
|
||||
</CollapsibleCard>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import {
|
|||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Spinner,
|
||||
Text,
|
||||
|
|
@ -33,6 +31,7 @@ import { useAlive } from '../../../hooks/useAlive';
|
|||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
import { getMxIdServer } from '../../../utils/matrix';
|
||||
import { CollapsibleCard } from '../../../components/CollapsibleCard';
|
||||
|
||||
type RoomPublishedAddressesProps = {
|
||||
permissions: RoomPermissionsAPI;
|
||||
|
|
@ -373,64 +372,40 @@ export function RoomLocalAddresses({ permissions }: { permissions: RoomPermissio
|
|||
const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId);
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
<CollapsibleCard
|
||||
expand={expand}
|
||||
setExpand={setExpand}
|
||||
title="Local Addresses"
|
||||
description="Set local address so users can join through your homeserver."
|
||||
>
|
||||
<SettingTile
|
||||
title="Local Addresses"
|
||||
description="Set local address so users can join through your homeserver."
|
||||
after={
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setExpand(!expand)}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
before={
|
||||
<Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
|
||||
}
|
||||
>
|
||||
<Text as="span" size="B300" truncate>
|
||||
{expand ? 'Collapse' : 'Expand'}
|
||||
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
|
||||
{localAliasesState.status === AsyncStatus.Loading && (
|
||||
<Box gap="100">
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
<Text size="T200">Loading...</Text>
|
||||
</Box>
|
||||
)}
|
||||
{localAliasesState.status === AsyncStatus.Success &&
|
||||
(localAliasesState.data.length === 0 ? (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">No Addresses</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<LocalAddressesList
|
||||
localAliases={localAliasesState.data}
|
||||
removeLocalAlias={removeLocalAlias}
|
||||
canEditCanonical={canEditCanonical}
|
||||
/>
|
||||
))}
|
||||
{localAliasesState.status === AsyncStatus.Error && (
|
||||
<Box gap="100">
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{localAliasesState.error.message}
|
||||
</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{expand && (
|
||||
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
|
||||
{localAliasesState.status === AsyncStatus.Loading && (
|
||||
<Box gap="100">
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
<Text size="T200">Loading...</Text>
|
||||
</Box>
|
||||
)}
|
||||
{localAliasesState.status === AsyncStatus.Success &&
|
||||
(localAliasesState.data.length === 0 ? (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">No Addresses</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<LocalAddressesList
|
||||
localAliases={localAliasesState.data}
|
||||
removeLocalAlias={removeLocalAlias}
|
||||
canEditCanonical={canEditCanonical}
|
||||
/>
|
||||
))}
|
||||
{localAliasesState.status === AsyncStatus.Error && (
|
||||
<Box gap="100">
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{localAliasesState.error.message}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</CutoutCard>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CutoutCard>
|
||||
{expand && <LocalAddressInput addLocalAlias={addLocalAlias} />}
|
||||
</SequenceCard>
|
||||
</CollapsibleCard>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ export function RoomProfileEdit({
|
|||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
space={room.isSpaceRoom()}
|
||||
roomType={room.getType()}
|
||||
size="400"
|
||||
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
|
||||
filled
|
||||
|
|
@ -342,7 +342,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
|||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
space={room.isSpaceRoom()}
|
||||
roomType={room.getType()}
|
||||
size="400"
|
||||
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
|
||||
filled
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import {
|
|||
RoomVersionSelector,
|
||||
useAdditionalCreators,
|
||||
} from '../../components/create-room';
|
||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
||||
|
||||
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
|
||||
if (kind === CreateRoomKind.Private) return Icons.HashLock;
|
||||
|
|
@ -72,6 +74,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
useAdditionalCreators();
|
||||
const [federation, setFederation] = useState(true);
|
||||
const [encryption, setEncryption] = useState(false);
|
||||
const [callRoom, setCallRoom] = useState(false);
|
||||
const [knock, setKnock] = useState(false);
|
||||
const [advance, setAdvance] = useState(false);
|
||||
|
||||
|
|
@ -116,8 +119,18 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
roomKnock = knock;
|
||||
}
|
||||
|
||||
let roomType;
|
||||
const powerOverrides: IPowerLevels = {
|
||||
events: {},
|
||||
};
|
||||
if (callRoom) {
|
||||
roomType = RoomType.Call;
|
||||
powerOverrides.events![StateEvent.GroupCallMemberPrefix] = 0;
|
||||
}
|
||||
|
||||
create({
|
||||
version: selectedRoomVersion,
|
||||
type: roomType,
|
||||
parent: space,
|
||||
kind,
|
||||
name: roomName,
|
||||
|
|
@ -127,6 +140,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
knock: roomKnock,
|
||||
allowFederation: federation,
|
||||
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
|
||||
powerLevelContentOverrides: powerOverrides,
|
||||
}).then((roomId) => {
|
||||
if (alive()) {
|
||||
onCreate?.(roomId);
|
||||
|
|
@ -170,6 +184,20 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
|
|||
disabled={disabled}
|
||||
/>
|
||||
</Box>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="500"
|
||||
>
|
||||
<SettingTile
|
||||
title="Call Room"
|
||||
description="Enable this to create a room optimized for voice calls."
|
||||
after={
|
||||
<Switch variant="Primary" value={callRoom} onChange={setCallRoom} disabled={disabled} />
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||
<Box shrink="No">
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton onClick={onBack}>
|
||||
<IconButton fill="None" onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -218,7 +218,11 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={() => setPeopleDrawer((drawer) => !drawer)}
|
||||
>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -235,7 +239,12 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
||||
<IconButton
|
||||
fill="None"
|
||||
onClick={handleOpenMenu}
|
||||
ref={triggerRef}
|
||||
aria-pressed={!!menuAnchor}
|
||||
>
|
||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
|
|||
|
||||
type RoomProfileProps = {
|
||||
roomId: string;
|
||||
roomType?: string;
|
||||
name: string;
|
||||
topic?: string;
|
||||
avatarUrl?: string;
|
||||
|
|
@ -185,6 +186,7 @@ type RoomProfileProps = {
|
|||
};
|
||||
function RoomProfile({
|
||||
roomId,
|
||||
roomType,
|
||||
name,
|
||||
topic,
|
||||
avatarUrl,
|
||||
|
|
@ -200,9 +202,7 @@ function RoomProfile({
|
|||
roomId={roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
|
||||
)}
|
||||
renderFallback={() => <RoomIcon size="300" joinRule={joinRule} roomType={roomType} />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -338,6 +338,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
|
|||
{(localSummary) => (
|
||||
<RoomProfile
|
||||
roomId={roomId}
|
||||
roomType={localSummary.roomType}
|
||||
name={localSummary.name}
|
||||
topic={localSummary.topic}
|
||||
avatarUrl={
|
||||
|
|
@ -396,6 +397,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
|
|||
{summary && (
|
||||
<RoomProfile
|
||||
roomId={roomId}
|
||||
roomType={summary.room_type}
|
||||
name={summary.name || summary.canonical_alias || roomId}
|
||||
topic={summary.topic}
|
||||
avatarUrl={
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk';
|
|||
import FocusTrap from 'focus-trap-react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { joinRuleToIconSrc } from '../../utils/room';
|
||||
import { getRoomIconSrc } from '../../utils/room';
|
||||
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||
import {
|
||||
SearchItemStrGetter,
|
||||
|
|
@ -274,9 +274,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
|||
before={
|
||||
<Icon
|
||||
size="50"
|
||||
src={
|
||||
joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash
|
||||
}
|
||||
src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
@ -392,10 +390,7 @@ export function SearchFilters({
|
|||
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
|
||||
radii="Pill"
|
||||
before={
|
||||
<Icon
|
||||
size="50"
|
||||
src={joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash}
|
||||
/>
|
||||
<Icon size="50" src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())} />
|
||||
}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
|
|
|
|||
21
src/app/features/room-nav/RoomCallNavStatus.css.ts
Normal file
21
src/app/features/room-nav/RoomCallNavStatus.css.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const Actions = style({
|
||||
padding: config.space.S200,
|
||||
});
|
||||
|
||||
export const RoomButtonWrap = style({
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const RoomButton = style({
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
padding: `0 ${config.space.S200}`,
|
||||
});
|
||||
|
||||
export const RoomName = style({
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
});
|
||||
129
src/app/features/room-nav/RoomCallNavStatus.tsx
Normal file
129
src/app/features/room-nav/RoomCallNavStatus.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
color,
|
||||
} from 'folds';
|
||||
import React from 'react';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import * as css from './RoomCallNavStatus.css';
|
||||
|
||||
export function CallNavStatus() {
|
||||
const {
|
||||
activeCallRoomId,
|
||||
isActiveCallReady,
|
||||
isAudioEnabled,
|
||||
isVideoEnabled,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
hangUp,
|
||||
} = useCallState();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const hasActiveCall = Boolean(activeCallRoomId);
|
||||
const isConnected = hasActiveCall && isActiveCallReady;
|
||||
const handleGoToCallRoom = () => {
|
||||
if (activeCallRoomId) {
|
||||
navigateRoom(activeCallRoomId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box className={css.Actions} direction="Row" alignItems="Center" gap="100">
|
||||
<Box className={css.RoomButtonWrap} grow="Yes">
|
||||
{hasActiveCall && (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Go to Room</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Chip
|
||||
size="500"
|
||||
fill="Soft"
|
||||
as="button"
|
||||
onClick={handleGoToCallRoom}
|
||||
ref={triggerRef}
|
||||
className={css.RoomButton}
|
||||
>
|
||||
{isConnected ? (
|
||||
<Icon size="300" src={Icons.VolumeHigh} style={{ color: color.Success.Main }} />
|
||||
) : (
|
||||
<Spinner size="300" variant="Secondary" />
|
||||
)}
|
||||
<Text
|
||||
as="span"
|
||||
size="L400"
|
||||
style={{ color: isConnected ? color.Success.Main : color.Warning.Main }}
|
||||
>
|
||||
{isConnected ? 'Connected' : 'Connecting'}
|
||||
</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Box>
|
||||
{hasActiveCall && (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Hang Up</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" size="300" ref={triggerRef} onClick={hangUp}>
|
||||
<Icon src={Icons.Phone} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{!isAudioEnabled ? 'Unmute' : 'Mute'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" size="300" ref={triggerRef} onClick={toggleAudio}>
|
||||
<Icon src={!isAudioEnabled ? Icons.MicMute : Icons.Mic} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{!isVideoEnabled ? 'Video On' : 'Video Off'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" size="300" ref={triggerRef} onClick={toggleVideo}>
|
||||
<Icon src={!isVideoEnabled ? Icons.VideoCameraMute : Icons.VideoCamera} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import React, { MouseEventHandler, forwardRef, useState, MouseEvent } from 'react';
|
||||
import { EventType, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
|
|
@ -16,10 +16,13 @@ import {
|
|||
RectCords,
|
||||
Badge,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
} from 'folds';
|
||||
import { useFocusWithin, useHover } from 'react-aria';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { NavButton, NavItem, NavItemContent, NavItemOptions } from '../../components/nav';
|
||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
||||
|
|
@ -51,6 +54,12 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { useCallMembers } from '../../hooks/useCallMemberships';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { RoomNavUser } from './RoomNavUser';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
|
||||
type RoomNavItemMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -208,6 +217,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||
);
|
||||
}
|
||||
);
|
||||
RoomNavItemMenu.displayName = 'RoomNavItemMenu';
|
||||
|
||||
type RoomNavItemProps = {
|
||||
room: Room;
|
||||
|
|
@ -236,6 +246,32 @@ export function RoomNavItem({
|
|||
(receipt) => receipt.userId !== mx.getUserId()
|
||||
);
|
||||
|
||||
const {
|
||||
isActiveCallReady,
|
||||
activeCallRoomId,
|
||||
setActiveCallRoomId,
|
||||
setViewedCallRoomId,
|
||||
isChatOpen,
|
||||
toggleChat,
|
||||
hangUp,
|
||||
} = useCallState();
|
||||
|
||||
const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId;
|
||||
const callMemberships = useCallMembers(mx, room.roomId);
|
||||
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const roomName = useRoomName(room);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canJoinCall = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId());
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const screenSize = useScreenSizeContext();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
setMenuAnchor({
|
||||
|
|
@ -250,109 +286,207 @@ export function RoomNavItem({
|
|||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleNavItemClick: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
if (room.isCallRoom()) {
|
||||
if (!isMobile) {
|
||||
if (!isActiveCall && canJoinCall) {
|
||||
hangUp();
|
||||
setActiveCallRoomId(room.roomId);
|
||||
} else {
|
||||
navigateRoom(room.roomId);
|
||||
}
|
||||
} else {
|
||||
evt.stopPropagation();
|
||||
if (isChatOpen) toggleChat();
|
||||
setViewedCallRoomId(room.roomId);
|
||||
navigateRoom(room.roomId);
|
||||
}
|
||||
} else {
|
||||
navigateRoom(room.roomId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatButtonClick = (evt: MouseEvent<HTMLButtonElement>) => {
|
||||
evt.stopPropagation();
|
||||
if (!isChatOpen) toggleChat();
|
||||
setViewedCallRoomId(room.roomId);
|
||||
navigate(linkPath);
|
||||
};
|
||||
|
||||
const optionsVisible = hover || !!menuAnchor;
|
||||
const ariaLabel = [
|
||||
roomName,
|
||||
room.isCallRoom()
|
||||
? [
|
||||
'Call Room',
|
||||
isActiveCall && 'Currently in Call',
|
||||
callMemberships.length && `${callMemberships.length} in Call`,
|
||||
]
|
||||
: 'Text Room',
|
||||
unread?.total && `${unread.total} Messages`,
|
||||
]
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
variant="Background"
|
||||
radii="400"
|
||||
highlight={unread !== undefined}
|
||||
aria-selected={selected}
|
||||
data-hover={!!menuAnchor}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...hoverProps}
|
||||
{...focusWithinProps}
|
||||
>
|
||||
<NavLink to={linkPath}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
{showAvatar ? (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={
|
||||
direct
|
||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(room.name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon
|
||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||
filled={selected}
|
||||
size="100"
|
||||
joinRule={room.getJoinRule()}
|
||||
<Box direction="Column" grow="Yes">
|
||||
<NavItem
|
||||
variant="Background"
|
||||
radii="400"
|
||||
highlight={unread !== undefined}
|
||||
aria-selected={selected}
|
||||
data-hover={!!menuAnchor}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...hoverProps}
|
||||
{...focusWithinProps}
|
||||
>
|
||||
<NavButton onClick={handleNavItemClick} aria-label={ariaLabel}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
{showAvatar ? (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={
|
||||
direct
|
||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(roomName)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon
|
||||
style={{
|
||||
opacity: unread || isActiveCall ? config.opacity.P500 : config.opacity.P300,
|
||||
}}
|
||||
filled={selected || isActiveCall}
|
||||
size="100"
|
||||
joinRule={room.getJoinRule()}
|
||||
roomType={room.getType()}
|
||||
locked={room.isCallRoom() && !canJoinCall}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text
|
||||
priority={unread || isActiveCall ? '500' : '300'}
|
||||
as="span"
|
||||
size="Inherit"
|
||||
truncate
|
||||
>
|
||||
{roomName}
|
||||
</Text>
|
||||
</Box>
|
||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<TypingIndicator size="300" disableAnimation />
|
||||
</Badge>
|
||||
)}
|
||||
{!optionsVisible && unread && (
|
||||
<UnreadBadgeCenter>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</UnreadBadgeCenter>
|
||||
)}
|
||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||
<Icon
|
||||
size="50"
|
||||
src={getRoomNotificationModeIcon(notificationMode)}
|
||||
aria-label={notificationMode}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||
<TypingIndicator size="300" disableAnimation />
|
||||
</Badge>
|
||||
)}
|
||||
{!optionsVisible && unread && (
|
||||
<UnreadBadgeCenter>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</UnreadBadgeCenter>
|
||||
)}
|
||||
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
|
||||
<Icon size="50" src={getRoomNotificationModeIcon(notificationMode)} />
|
||||
)}
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
{optionsVisible && (
|
||||
<NavItemOptions>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
||||
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
|
||||
position="Bottom"
|
||||
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomNavItemMenu
|
||||
room={room}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
notificationMode={notificationMode}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
{optionsVisible && (
|
||||
<NavItemOptions>
|
||||
<PopOut
|
||||
id={`menu-${room.roomId}`}
|
||||
aria-expanded={!!menuAnchor}
|
||||
anchor={menuAnchor}
|
||||
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
||||
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
|
||||
position="Bottom"
|
||||
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomNavItemMenu
|
||||
room={room}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
notificationMode={notificationMode}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Icon size="50" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
</NavItemOptions>
|
||||
{room.isCallRoom() && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Open Chat</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
data-testid="chat-button"
|
||||
onClick={handleChatButtonClick}
|
||||
aria-pressed={isChatOpen && selected}
|
||||
aria-label="Open Chat"
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="50" src={Icons.Message} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
aria-controls={`menu-${room.roomId}`}
|
||||
aria-label="More Options"
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="50" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
</NavItemOptions>
|
||||
)}
|
||||
</NavItem>
|
||||
{room.isCallRoom() && (
|
||||
<Box direction="Column" style={{ paddingLeft: config.space.S200 }}>
|
||||
{callMemberships.map((callMembership) => (
|
||||
<RoomNavUser
|
||||
key={callMembership.membershipID}
|
||||
room={room}
|
||||
callMembership={callMembership}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</NavItem>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
63
src/app/features/room-nav/RoomNavUser.tsx
Normal file
63
src/app/features/room-nav/RoomNavUser.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||
import React from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import { NavButton, NavItem, NavItemContent } from '../../components/nav';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
|
||||
type RoomNavUserProps = {
|
||||
room: Room;
|
||||
callMembership: CallMembership;
|
||||
};
|
||||
export function RoomNavUser({ room, callMembership }: RoomNavUserProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const { isActiveCallReady, activeCallRoomId } = useCallState();
|
||||
const isActiveCall = isActiveCallReady && activeCallRoomId === room.roomId;
|
||||
const userId = callMembership.sender ?? '';
|
||||
const avatarMxcUrl = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
|
||||
: undefined;
|
||||
const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId);
|
||||
const isCallParticipant = isActiveCall && userId !== mx.getUserId();
|
||||
|
||||
const handleNavUserClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const ariaLabel = isCallParticipant ? `Call Participant: ${getName}` : getName;
|
||||
|
||||
return (
|
||||
<NavItem variant="Background" radii="400">
|
||||
<NavButton onClick={handleNavUserClick} aria-label={ariaLabel}>
|
||||
<NavItemContent as="div">
|
||||
<Box direction="Column" grow="Yes" gap="200" justifyContent="Stretch">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={getName}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text as="span" size="B400" priority="300" truncate>
|
||||
{getName}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,6 +46,19 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
|||
],
|
||||
};
|
||||
|
||||
const callSettingsGroup: PermissionGroup = {
|
||||
name: 'Calls',
|
||||
items: [
|
||||
{
|
||||
location: {
|
||||
state: true,
|
||||
key: StateEvent.GroupCallMemberPrefix,
|
||||
},
|
||||
name: 'Join Call',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const moderationGroup: PermissionGroup = {
|
||||
name: 'Moderation',
|
||||
items: [
|
||||
|
|
@ -196,6 +209,7 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
|||
|
||||
return [
|
||||
messagesGroup,
|
||||
callSettingsGroup,
|
||||
moderationGroup,
|
||||
roomOverviewGroup,
|
||||
roomSettingsGroup,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { useKeyDown } from '../../hooks/useKeyDown';
|
|||
import { markAsRead } from '../../utils/notifications';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { CallView } from '../call/CallView';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
|
|
@ -23,7 +25,7 @@ export function Room() {
|
|||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
const members = useRoomMembers(mx, room?.roomId);
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
|
|
@ -40,8 +42,17 @@ export function Room() {
|
|||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<Box grow="Yes">
|
||||
<RoomView room={room} eventId={eventId} />
|
||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes">
|
||||
<CallView room={room} />
|
||||
{room.isCallRoom() && screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
)}
|
||||
<RoomView room={room} eventId={eventId} />
|
||||
</Box>
|
||||
</Box>
|
||||
{!room.isCallRoom() && screenSize === ScreenSize.Desktop && isDrawer && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||
|
|
|
|||
|
|
@ -471,6 +471,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
|
||||
const canRedact = permissions.action('redact', mx.getSafeUserId());
|
||||
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
||||
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||
const [editId, setEditId] = useState<string>();
|
||||
|
|
@ -1047,7 +1048,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
edit={editId === mEventId}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||
canSendReaction={canSendReaction}
|
||||
canPinEvent={canPinEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
|
|
@ -1129,7 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
edit={editId === mEventId}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||
canSendReaction={canSendReaction}
|
||||
canPinEvent={canPinEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
|
|
@ -1247,7 +1248,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
messageLayout={messageLayout}
|
||||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||
canSendReaction={canSendReaction}
|
||||
canPinEvent={canPinEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { Box, Text, config, toRem } from 'folds';
|
||||
import { EventType, Room } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
|
|
@ -15,13 +15,14 @@ import { RoomTombstone } from './RoomTombstone';
|
|||
import { RoomInput } from './RoomInput';
|
||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||
import { Page } from '../../components/page';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { editableActiveElement } from '../../utils/dom';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
|
||||
const FN_KEYS_REGEX = /^F\d+$/;
|
||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
|
|
@ -30,10 +31,8 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
|||
return false;
|
||||
}
|
||||
|
||||
// do not focus on F keys
|
||||
if (FN_KEYS_REGEX.test(code)) return false;
|
||||
|
||||
// do not focus on numlock/scroll lock
|
||||
if (
|
||||
code.startsWith('OS') ||
|
||||
code.startsWith('Meta') ||
|
||||
|
|
@ -61,6 +60,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const { isChatOpen } = useCallState();
|
||||
|
||||
const { roomId } = room;
|
||||
const editor = useEditor();
|
||||
|
|
@ -92,51 +93,59 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||
);
|
||||
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
{tombstoneEvent ? (
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
body={tombstoneEvent.getContent().body}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">You do not have permission to post in this room</Text>
|
||||
</RoomInputPlaceholder>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||
</Box>
|
||||
</Page>
|
||||
(!room.isCallRoom() || isChatOpen) && (
|
||||
<Page
|
||||
ref={roomViewRef}
|
||||
style={
|
||||
room.isCallRoom() && screenSize === ScreenSize.Desktop
|
||||
? { maxWidth: toRem(399), minWidth: toRem(399) }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
{tombstoneEvent ? (
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
body={tombstoneEvent.getContent().body}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">You do not have permission to post in this room</Text>
|
||||
</RoomInputPlaceholder>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||
</Box>
|
||||
</Page>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ import {
|
|||
Spinner,
|
||||
} from 'folds';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { PageHeader } from '../../components/page';
|
||||
|
|
@ -33,7 +32,7 @@ import { UseStateProvider } from '../../components/UseStateProvider';
|
|||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
|
|
@ -48,7 +47,6 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
|||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||
|
|
@ -69,6 +67,8 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { useCallState } from '../../pages/client/call/CallProvider';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -263,12 +263,13 @@ export function RoomViewHeader() {
|
|||
const space = useSpaceOptionally();
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const direct = useIsDirectRoom();
|
||||
|
||||
const { isChatOpen, toggleChat } = useCallState();
|
||||
const pinnedEvents = useRoomPinnedEvents(room);
|
||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||
const ecryptedRoom = !!encryptionEvent;
|
||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
||||
const avatarMxc = useRoomAvatar(room, direct);
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const avatarUrl = avatarMxc
|
||||
|
|
@ -296,13 +297,16 @@ export function RoomViewHeader() {
|
|||
};
|
||||
|
||||
return (
|
||||
<PageHeader balance={screenSize === ScreenSize.Mobile}>
|
||||
<PageHeader
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
balance={screenSize === ScreenSize.Mobile}
|
||||
>
|
||||
<Box grow="Yes" gap="300">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<IconButton onClick={onBack}>
|
||||
<IconButton fill="None" onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
@ -317,11 +321,7 @@ export function RoomViewHeader() {
|
|||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
size="200"
|
||||
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
|
||||
filled
|
||||
/>
|
||||
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
|
|
@ -369,8 +369,9 @@ export function RoomViewHeader() {
|
|||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box shrink="No">
|
||||
{!ecryptedRoom && (
|
||||
{!ecryptedRoom && (!room.isCallRoom() || isChatOpen) && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
|
|
@ -381,69 +382,75 @@ export function RoomViewHeader() {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={handleSearchClick}>
|
||||
<IconButton fill="None" ref={triggerRef} onClick={handleSearchClick}>
|
||||
<Icon size="400" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Pinned Messages</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
style={{ position: 'relative' }}
|
||||
onClick={handleOpenPinMenu}
|
||||
ref={triggerRef}
|
||||
aria-pressed={!!pinMenuAnchor}
|
||||
>
|
||||
{pinnedEvents.length > 0 && (
|
||||
<Badge
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: toRem(3),
|
||||
top: toRem(3),
|
||||
}}
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
{pinnedEvents.length}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<PopOut
|
||||
anchor={pinMenuAnchor}
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setPinMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
{(!room.isCallRoom() || isChatOpen) && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Pinned Messages</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
style={{ position: 'relative' }}
|
||||
onClick={handleOpenPinMenu}
|
||||
ref={triggerRef}
|
||||
aria-pressed={!!pinMenuAnchor}
|
||||
>
|
||||
{pinnedEvents.length > 0 && (
|
||||
<Badge
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: toRem(3),
|
||||
top: toRem(3),
|
||||
}}
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
{pinnedEvents.length}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{(!room.isCallRoom() || isChatOpen) && (
|
||||
<PopOut
|
||||
anchor={pinMenuAnchor}
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setPinMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!room.isCallRoom() && screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
|
|
@ -454,12 +461,35 @@ export function RoomViewHeader() {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={() => setPeopleDrawer((drawer) => !drawer)}
|
||||
>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{room.isCallRoom() && !direct && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Chat</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" ref={triggerRef} onClick={toggleChat}>
|
||||
<Icon size="400" src={Icons.Message} filled={isChatOpen} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
|
|
@ -471,7 +501,12 @@ export function RoomViewHeader() {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
|
||||
<IconButton
|
||||
fill="None"
|
||||
onClick={handleOpenMenu}
|
||||
ref={triggerRef}
|
||||
aria-pressed={!!menuAnchor}
|
||||
>
|
||||
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -373,7 +373,7 @@ export function Search({ requestClose }: SearchProps) {
|
|||
<RoomIcon
|
||||
size="100"
|
||||
joinRule={room.getJoinRule()}
|
||||
space={room.isSpaceRoom()}
|
||||
roomType={room.getType()}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
|
|
|
|||
|
|
@ -21,10 +21,9 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
|||
import { Account } from './account';
|
||||
import { useUserProfile } from '../../hooks/useUserProfile';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { Notifications } from './notifications';
|
||||
import { Devices } from './devices';
|
||||
import { EmojisStickers } from './emojis-stickers';
|
||||
|
|
@ -99,9 +98,8 @@ type SettingsProps = {
|
|||
export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const userId = mx.getUserId()!;
|
||||
const userId = mx.getUserId() as string;
|
||||
const profile = useUserProfile(userId);
|
||||
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarUrl = profile.avatarUrl
|
||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
|
@ -132,7 +130,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
|
||||
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
|
|
|
|||
|
|
@ -1,324 +1,283 @@
|
|||
import React, {
|
||||
ChangeEventHandler,
|
||||
FormEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Avatar,
|
||||
Button,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Modal,
|
||||
Dialog,
|
||||
Header,
|
||||
config,
|
||||
Spinner,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { Box, Text, Button, config, Spinner, Line } from 'folds';
|
||||
import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
|
||||
import {
|
||||
ExtendedProfile,
|
||||
profileEditsAllowed,
|
||||
useExtendedProfile,
|
||||
} from '../../../hooks/useExtendedProfile';
|
||||
import { ProfileFieldContext, ProfileFieldElementProps } from './fields/ProfileFieldContext';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { ImageEditor } from '../../../components/image-editor';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { CutoutCard } from '../../../components/cutout-card';
|
||||
import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||
import { withSearchParam } from '../../../pages/pathUtils';
|
||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
import { ProfileAvatar } from './fields/ProfileAvatar';
|
||||
import { ProfileTextField } from './fields/ProfileTextField';
|
||||
import { ProfilePronouns } from './fields/ProfilePronouns';
|
||||
import { ProfileTimezone } from './fields/ProfileTimezone';
|
||||
|
||||
type ProfileProps = {
|
||||
profile: UserProfile;
|
||||
userId: string;
|
||||
};
|
||||
function ProfileAvatar({ profile, userId }: ProfileProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const capabilities = useCapabilities();
|
||||
const [alertRemove, setAlertRemove] = useState(false);
|
||||
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
|
||||
function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) {
|
||||
const accountManagementActions = useAccountManagementActions();
|
||||
|
||||
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarUrl = profile.avatarUrl
|
||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
const openProviderProfileSettings = useCallback(() => {
|
||||
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
||||
if (!authUrl) return;
|
||||
|
||||
const [imageFile, setImageFile] = useState<File>();
|
||||
const imageFileURL = useObjectURL(imageFile);
|
||||
const uploadAtom = useMemo(() => {
|
||||
if (imageFile) return createUploadAtom(imageFile);
|
||||
return undefined;
|
||||
}, [imageFile]);
|
||||
|
||||
const pickFile = useFilePicker(setImageFile, false);
|
||||
|
||||
const handleRemoveUpload = useCallback(() => {
|
||||
setImageFile(undefined);
|
||||
}, []);
|
||||
|
||||
const handleUploaded = useCallback(
|
||||
(upload: UploadSuccess) => {
|
||||
const { mxc } = upload;
|
||||
mx.setAvatarUrl(mxc);
|
||||
handleRemoveUpload();
|
||||
},
|
||||
[mx, handleRemoveUpload]
|
||||
);
|
||||
|
||||
const handleRemoveAvatar = () => {
|
||||
mx.setAvatarUrl('');
|
||||
setAlertRemove(false);
|
||||
};
|
||||
window.open(
|
||||
withSearchParam(authUrl, {
|
||||
action: accountManagementActions.profile,
|
||||
}),
|
||||
'_blank'
|
||||
);
|
||||
}, [authMetadata, accountManagementActions]);
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Avatar
|
||||
</Text>
|
||||
}
|
||||
after={
|
||||
<Avatar size="500" radii="300">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
{uploadAtom ? (
|
||||
<Box gap="200" direction="Column">
|
||||
<CompactUploadCardRenderer
|
||||
uploadAtom={uploadAtom}
|
||||
onRemove={handleRemoveUpload}
|
||||
onComplete={handleUploaded}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box gap="200">
|
||||
<CutoutCard style={{ padding: config.space.S200 }} variant="Surface">
|
||||
<SettingTile
|
||||
after={
|
||||
<Button
|
||||
onClick={() => pickFile('image/*')}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={disableSetAvatar}
|
||||
outlined
|
||||
onClick={openProviderProfileSettings}
|
||||
>
|
||||
<Text size="B300">Upload</Text>
|
||||
<Text size="B300">Open</Text>
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={disableSetAvatar}
|
||||
onClick={() => setAlertRemove(true)}
|
||||
>
|
||||
<Text size="B300">Remove</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{imageFileURL && (
|
||||
<Overlay open={false} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: handleRemoveUpload,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal className={ModalWide} variant="Surface" size="500">
|
||||
<ImageEditor
|
||||
name={imageFile?.name ?? 'Unnamed'}
|
||||
url={imageFileURL}
|
||||
requestClose={handleRemoveUpload}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
|
||||
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setAlertRemove(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface">
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4">Remove Avatar</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
|
||||
</Box>
|
||||
<Button variant="Critical" onClick={handleRemoveAvatar}>
|
||||
<Text size="B400">Remove</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
</SettingTile>
|
||||
}
|
||||
>
|
||||
<Text size="T200">Change profile settings in your homeserver's account dashboard.</Text>
|
||||
</SettingTile>
|
||||
</CutoutCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
const mx = useMatrixClient();
|
||||
const capabilities = useCapabilities();
|
||||
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
|
||||
/// Context props which are passed to every field element.
|
||||
/// Right now this is only a flag for if the profile is being saved.
|
||||
export type FieldContext = { busy: boolean };
|
||||
|
||||
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
|
||||
/// Field editor elements for the pre-MSC4133 profile fields. This should only
|
||||
/// ever contain keys for `displayname` and `avatar_url`.
|
||||
const LEGACY_FIELD_ELEMENTS = {
|
||||
avatar_url: ProfileAvatar,
|
||||
displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => (
|
||||
<ProfileTextField label="Display Name" {...props} />
|
||||
),
|
||||
};
|
||||
|
||||
const [changeState, changeDisplayName] = useAsyncCallback(
|
||||
useCallback((name: string) => mx.setDisplayName(name), [mx])
|
||||
);
|
||||
const changingDisplayName = changeState.status === AsyncStatus.Loading;
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(defaultDisplayName);
|
||||
}, [defaultDisplayName]);
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
const name = evt.currentTarget.value;
|
||||
setDisplayName(name);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setDisplayName(defaultDisplayName);
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (changingDisplayName) return;
|
||||
|
||||
const target = evt.target as HTMLFormElement | undefined;
|
||||
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
|
||||
const name = displayNameInput?.value;
|
||||
if (!name) return;
|
||||
|
||||
changeDisplayName(name);
|
||||
};
|
||||
|
||||
const hasChanges = displayName !== defaultDisplayName;
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Display Name
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" grow="Yes" gap="100">
|
||||
<Box
|
||||
as="form"
|
||||
onSubmit={handleSubmit}
|
||||
gap="200"
|
||||
aria-disabled={changingDisplayName || disableSetDisplayname}
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
required
|
||||
name="displayNameInput"
|
||||
value={displayName}
|
||||
onChange={handleChange}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
style={{ paddingRight: config.space.S200 }}
|
||||
readOnly={changingDisplayName || disableSetDisplayname}
|
||||
after={
|
||||
hasChanges &&
|
||||
!changingDisplayName && (
|
||||
<IconButton
|
||||
type="reset"
|
||||
onClick={handleReset}
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="400"
|
||||
variant={hasChanges ? 'Success' : 'Secondary'}
|
||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || changingDisplayName}
|
||||
type="submit"
|
||||
>
|
||||
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
/// Field editor elements for MSC4133 extended profile fields.
|
||||
/// These will appear in the UI in the order they are defined in this map.
|
||||
const EXTENDED_FIELD_ELEMENTS = {
|
||||
'io.fsky.nyx.pronouns': ProfilePronouns,
|
||||
'us.cloke.msc4175.tz': ProfileTimezone,
|
||||
};
|
||||
|
||||
export function Profile() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const profile = useUserProfile(userId);
|
||||
const userId = mx.getUserId() as string;
|
||||
const server = getMxIdServer(userId);
|
||||
const authMetadata = useAuthMetadata();
|
||||
const accountManagementActions = useAccountManagementActions();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const capabilities = useCapabilities();
|
||||
|
||||
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
|
||||
const extendedProfileSupported = extendedProfile !== null;
|
||||
const legacyProfile = useUserProfile(userId);
|
||||
|
||||
// next-gen auth identity providers may provide profile settings if they want
|
||||
const profileEditableThroughIDP =
|
||||
authMetadata !== undefined &&
|
||||
authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile);
|
||||
|
||||
const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => {
|
||||
const entries = Object.entries({
|
||||
...LEGACY_FIELD_ELEMENTS,
|
||||
// don't show the MSC4133 elements if the HS doesn't support them
|
||||
...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}),
|
||||
}).filter(([key]) =>
|
||||
// don't show fields if the HS blocks them with capabilities
|
||||
profileEditsAllowed(key, capabilities, extendedProfileSupported)
|
||||
);
|
||||
return [Object.fromEntries(entries), entries.length > 0];
|
||||
}, [capabilities, extendedProfileSupported]);
|
||||
|
||||
const [fieldDefaults, setFieldDefaults] = useState<ExtendedProfile>({
|
||||
displayname: legacyProfile.displayName,
|
||||
avatar_url: legacyProfile.avatarUrl,
|
||||
});
|
||||
|
||||
// this updates the field defaults when the extended profile data is (re)loaded.
|
||||
// it has to be a layout effect to prevent flickering on saves.
|
||||
// if MSC4133 isn't supported by the HS this does nothing
|
||||
useLayoutEffect(() => {
|
||||
// `extendedProfile` includes the old dn/av fields, so
|
||||
// we don't have to add those here
|
||||
if (extendedProfile) {
|
||||
setFieldDefaults(extendedProfile);
|
||||
}
|
||||
}, [setFieldDefaults, extendedProfile]);
|
||||
|
||||
const [saveState, handleSave] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (fields: ExtendedProfile) => {
|
||||
if (extendedProfileSupported) {
|
||||
await Promise.all(
|
||||
Object.entries(fields).map(async ([key, value]) => {
|
||||
if (value === undefined) {
|
||||
await mx.deleteExtendedProfileProperty(key);
|
||||
} else {
|
||||
await mx.setExtendedProfileProperty(key, value);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// calling this will trigger the layout effect to update the defaults
|
||||
// once the profile request completes
|
||||
await refreshExtendedProfile();
|
||||
|
||||
// synthesize a profile update for ourselves to update our name and avatar in the rest
|
||||
// of the UI. code copied from matrix-js-sdk
|
||||
const user = mx.getUser(userId);
|
||||
if (user) {
|
||||
user.displayName = fields.displayname;
|
||||
user.avatarUrl = fields.avatar_url;
|
||||
user.emit(UserEvent.DisplayName, user.events.presence, user);
|
||||
user.emit(UserEvent.AvatarUrl, user.events.presence, user);
|
||||
}
|
||||
} else {
|
||||
await mx.setDisplayName(fields.displayname ?? '');
|
||||
await mx.setAvatarUrl(fields.avatar_url ?? '');
|
||||
// layout effect does nothing because `extendedProfile` is undefined
|
||||
// so we have to update the defaults explicitly here
|
||||
setFieldDefaults(fields);
|
||||
}
|
||||
},
|
||||
[mx, userId, refreshExtendedProfile, extendedProfileSupported, setFieldDefaults]
|
||||
)
|
||||
);
|
||||
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
const loadingExtendedProfile = extendedProfile === undefined;
|
||||
const busy = saving || loadingExtendedProfile;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Profile</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
variant="Surface"
|
||||
outlined
|
||||
direction="Column"
|
||||
gap="400"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ProfileAvatar userId={userId} profile={profile} />
|
||||
<ProfileDisplayName userId={userId} profile={profile} />
|
||||
<ProfileFieldContext
|
||||
fieldDefaults={fieldDefaults}
|
||||
fieldElements={fieldElementConstructors}
|
||||
context={{ busy }}
|
||||
>
|
||||
{(reset, hasChanges, fields, fieldElements) => {
|
||||
const heroAvatarUrl =
|
||||
(fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ??
|
||||
undefined;
|
||||
return (
|
||||
<>
|
||||
<UserHero userId={userId} avatarUrl={heroAvatarUrl} />
|
||||
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||
<Box gap="400" alignItems="Start">
|
||||
<UserHeroName
|
||||
userId={userId}
|
||||
displayName={fields.displayname as string}
|
||||
extendedProfile={fields}
|
||||
/>
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap">
|
||||
{server && <ServerChip server={server} />}
|
||||
<ShareChip userId={userId} />
|
||||
{fields['us.cloke.msc4175.tz'] && (
|
||||
<TimezoneChip timezone={fields['us.cloke.msc4175.tz']} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Line />
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
radii="0"
|
||||
>
|
||||
{profileEditableThroughIDP && (
|
||||
<IdentityProviderSettings authMetadata={authMetadata} />
|
||||
)}
|
||||
{profileEditableThroughClient && (
|
||||
<>
|
||||
<Box gap="300" direction="Column">
|
||||
{fieldElements}
|
||||
</Box>
|
||||
<Box gap="300" alignItems="Center">
|
||||
<Button
|
||||
type="submit"
|
||||
size="300"
|
||||
variant={!busy && hasChanges ? 'Success' : 'Secondary'}
|
||||
fill={!busy && hasChanges ? 'Solid' : 'Soft'}
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={!hasChanges || busy}
|
||||
onClick={() => handleSave(fields)}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Button>
|
||||
<Button
|
||||
type="reset"
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
onClick={reset}
|
||||
disabled={!hasChanges || busy}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
{saving && <Spinner size="300" />}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{!(profileEditableThroughClient || profileEditableThroughIDP) && (
|
||||
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||
<SettingTile>
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Profile Editing Disabled</Text>
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
<Text size="T200">
|
||||
Your homeserver does not allow you to edit your profile.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
</CutoutCard>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</ProfileFieldContext>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
118
src/app/features/settings/account/fields/ProfileAvatar.tsx
Normal file
118
src/app/features/settings/account/fields/ProfileAvatar.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import FocusTrap from 'focus-trap-react';
|
||||
import { Text, Box, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal } from 'folds';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { ImageEditor } from '../../../../components/image-editor';
|
||||
import { SettingTile } from '../../../../components/setting-tile';
|
||||
import { CompactUploadCardRenderer } from '../../../../components/upload-card';
|
||||
import { useFilePicker } from '../../../../hooks/useFilePicker';
|
||||
import { useMatrixClient } from '../../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../../hooks/useMediaAuthentication';
|
||||
import { useObjectURL } from '../../../../hooks/useObjectURL';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../../state/upload';
|
||||
import { stopPropagation } from '../../../../utils/keyboard';
|
||||
import { mxcUrlToHttp } from '../../../../utils/matrix';
|
||||
import { FieldContext } from '../Profile';
|
||||
import { ProfileFieldElementProps } from './ProfileFieldContext';
|
||||
import { ModalWide } from '../../../../styles/Modal.css';
|
||||
|
||||
export function ProfileAvatar({
|
||||
busy, value, setValue,
|
||||
}: ProfileFieldElementProps<'avatar_url', FieldContext>) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const avatarUrl = value
|
||||
? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
const disabled = busy;
|
||||
|
||||
const [imageFile, setImageFile] = useState<File>();
|
||||
const imageFileURL = useObjectURL(imageFile);
|
||||
const uploadAtom = useMemo(() => {
|
||||
if (imageFile) return createUploadAtom(imageFile);
|
||||
return undefined;
|
||||
}, [imageFile]);
|
||||
|
||||
const pickFile = useFilePicker(setImageFile, false);
|
||||
|
||||
const handleRemoveUpload = useCallback(() => {
|
||||
setImageFile(undefined);
|
||||
}, []);
|
||||
|
||||
const handleUploaded = useCallback(
|
||||
(upload: UploadSuccess) => {
|
||||
const { mxc } = upload;
|
||||
setValue(mxc);
|
||||
handleRemoveUpload();
|
||||
},
|
||||
[setValue, handleRemoveUpload]
|
||||
);
|
||||
|
||||
const handleRemoveAvatar = () => {
|
||||
setValue('');
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={<Text as="span" size="L400">
|
||||
Avatar
|
||||
</Text>}
|
||||
>
|
||||
{uploadAtom ? (
|
||||
<Box gap="200" direction="Column">
|
||||
<CompactUploadCardRenderer
|
||||
uploadAtom={uploadAtom}
|
||||
onRemove={handleRemoveUpload}
|
||||
onComplete={handleUploaded} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box gap="200">
|
||||
<Button
|
||||
onClick={() => pickFile('image/*')}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text size="B300">Upload Avatar</Text>
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={disabled}
|
||||
onClick={handleRemoveAvatar}
|
||||
>
|
||||
<Text size="B300">Remove Avatar</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{imageFileURL && (
|
||||
<Overlay open={false} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: handleRemoveUpload,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal className={ModalWide} variant="Surface" size="500">
|
||||
<ImageEditor
|
||||
name={imageFile?.name ?? 'Unnamed'}
|
||||
url={imageFileURL}
|
||||
requestClose={handleRemoveUpload} />
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
127
src/app/features/settings/account/fields/ProfileFieldContext.tsx
Normal file
127
src/app/features/settings/account/fields/ProfileFieldContext.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import React, {
|
||||
FunctionComponent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { deepCompare } from 'matrix-js-sdk/lib/utils';
|
||||
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
|
||||
|
||||
/// These types ensure the element functions are actually able to manipulate
|
||||
/// the profile fields they're mapped to. The <C> generic parameter represents
|
||||
/// extra "context" props which are passed to every element.
|
||||
|
||||
// strip the index signature from ExtendedProfile using mapped type magic.
|
||||
// keeping the index signature causes weird typechecking issues further down the line
|
||||
// plus there should never be field elements passed with keys which don't exist in ExtendedProfile.
|
||||
type ExtendedProfileKeys = keyof {
|
||||
[Property in keyof ExtendedProfile as string extends Property
|
||||
? never
|
||||
: Property]: ExtendedProfile[Property];
|
||||
};
|
||||
|
||||
// these are the props which all field elements must accept.
|
||||
// this is split into `RawProps` and `Props` so we can type `V` instead of
|
||||
// spraying `ExtendedProfile[K]` all over the place.
|
||||
// don't use this directly, use the `ProfileFieldElementProps` type instead
|
||||
type ProfileFieldElementRawProps<V, C> = {
|
||||
defaultValue: V;
|
||||
value: V;
|
||||
setValue: (value: V) => void;
|
||||
} & C;
|
||||
|
||||
export type ProfileFieldElementProps<
|
||||
K extends ExtendedProfileKeys,
|
||||
C
|
||||
> = ProfileFieldElementRawProps<ExtendedProfile[K], C>;
|
||||
|
||||
// the map of extended profile keys to field element functions
|
||||
type ProfileFieldElements<C> = {
|
||||
[Property in ExtendedProfileKeys]?: FunctionComponent<ProfileFieldElementProps<Property, C>>;
|
||||
};
|
||||
|
||||
type ProfileFieldContextProps<C> = {
|
||||
fieldDefaults: ExtendedProfile;
|
||||
fieldElements: ProfileFieldElements<C>;
|
||||
children: (
|
||||
reset: () => void,
|
||||
hasChanges: boolean,
|
||||
fields: ExtendedProfile,
|
||||
fieldElements: ReactNode
|
||||
) => ReactNode;
|
||||
context: C;
|
||||
};
|
||||
|
||||
/// This element manages the pending state of the profile field widgets.
|
||||
/// It takes the default values of each field, as well as a map associating a profile field key
|
||||
/// with an element _function_ (not a rendered element!) that will be used to edit that field.
|
||||
/// It renders the editor elements internally using React.createElement and passes the rendered
|
||||
/// elements into the child UI. This allows it to handle the pending state entirely by itself,
|
||||
/// and provides strong typechecking.
|
||||
export function ProfileFieldContext<C>({
|
||||
fieldDefaults,
|
||||
fieldElements: fieldElementConstructors,
|
||||
children,
|
||||
context,
|
||||
}: ProfileFieldContextProps<C>): ReactNode {
|
||||
const [fields, setFields] = useState<ExtendedProfile>(fieldDefaults);
|
||||
|
||||
// this callback also runs when fieldDefaults changes,
|
||||
// which happens when the profile is saved and the pending fields become the new defaults
|
||||
const reset = useCallback(() => {
|
||||
setFields(fieldDefaults);
|
||||
}, [fieldDefaults]);
|
||||
|
||||
// set the pending values to the defaults on the first render
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const setField = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
setFields({
|
||||
...fields,
|
||||
[key]: value,
|
||||
});
|
||||
},
|
||||
[fields]
|
||||
);
|
||||
|
||||
const hasChanges = useMemo(
|
||||
() =>
|
||||
Object.entries(fields).find(
|
||||
([key, value]) =>
|
||||
// deep comparison is necessary here because field values can be any JSON type
|
||||
!deepCompare(fieldDefaults[key as keyof ExtendedProfile], value)
|
||||
) !== undefined,
|
||||
[fields, fieldDefaults]
|
||||
);
|
||||
|
||||
const createElement = useCallback(
|
||||
<K extends ExtendedProfileKeys>(key: K, element: ProfileFieldElements<C>[K]) => {
|
||||
const props: ProfileFieldElementRawProps<ExtendedProfile[K], C> = {
|
||||
...context,
|
||||
defaultValue: fieldDefaults[key],
|
||||
value: fields[key],
|
||||
setValue: (value) => setField(key, value),
|
||||
key,
|
||||
};
|
||||
// element can be undefined if the field defaults didn't include its key,
|
||||
// which means the HS doesn't support setting that field
|
||||
if (element !== undefined) {
|
||||
return React.createElement(element, props);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[context, fieldDefaults, fields, setField]
|
||||
);
|
||||
|
||||
const fieldElements = Object.entries(fieldElementConstructors).map(([key, element]) =>
|
||||
// @ts-expect-error TypeScript doesn't quite understand the magic going on here
|
||||
createElement(key, element)
|
||||
);
|
||||
|
||||
return children(reset, hasChanges, fields, fieldElements);
|
||||
}
|
||||
125
src/app/features/settings/account/fields/ProfilePronouns.tsx
Normal file
125
src/app/features/settings/account/fields/ProfilePronouns.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import FocusTrap from 'focus-trap-react';
|
||||
import { RectCords, Text, Box, Chip, Icon, Icons, PopOut, Menu, config, Input, Button } from 'folds';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import React, { useState, FormEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react';
|
||||
import { SettingTile } from '../../../../components/setting-tile';
|
||||
import { stopPropagation } from '../../../../utils/keyboard';
|
||||
import { FieldContext } from '../Profile';
|
||||
import { ProfileFieldElementProps } from './ProfileFieldContext';
|
||||
|
||||
export function ProfilePronouns({
|
||||
value, setValue, busy,
|
||||
}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) {
|
||||
const disabled = busy;
|
||||
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const [pendingPronoun, setPendingPronoun] = useState('');
|
||||
|
||||
const handleRemovePronoun = (index: number) => {
|
||||
const newPronouns = [...(value ?? [])];
|
||||
newPronouns.splice(index, 1);
|
||||
if (newPronouns.length > 0) {
|
||||
setValue(newPronouns);
|
||||
} else {
|
||||
setValue(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
setMenuCords(undefined);
|
||||
if (pendingPronoun.length > 0) {
|
||||
setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.stopPropagation();
|
||||
setMenuCords(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLSpanElement> = (evt) => {
|
||||
setPendingPronoun('');
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={<Text as="span" size="L400">
|
||||
Pronouns
|
||||
</Text>}
|
||||
>
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap">
|
||||
{value?.map(({ summary }, index) => (
|
||||
<Chip
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
after={<Icon src={Icons.Cross} size="100" />}
|
||||
onClick={() => handleRemovePronoun(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text size="T200" truncate>
|
||||
{summary}
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
disabled={disabled}
|
||||
after={<Icon src={menuCords ? Icons.ChevronRight : Icons.Plus} size="100" />}
|
||||
onClick={handleOpenMenu}
|
||||
>
|
||||
<Text size="T200">Add</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Right"
|
||||
align="Center"
|
||||
content={<FocusTrap
|
||||
focusTrapOptions={{
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
variant="SurfaceVariant"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
}}
|
||||
>
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Row" gap="200">
|
||||
<Input
|
||||
variant="Secondary"
|
||||
placeholder="they/them"
|
||||
inputSize={10}
|
||||
radii="300"
|
||||
size="300"
|
||||
outlined
|
||||
value={pendingPronoun}
|
||||
onChange={(evt) => setPendingPronoun(evt.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.Plus} />}
|
||||
>
|
||||
<Text size="B300">Add</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>} />
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { Text, Box, Input, IconButton, Icon, Icons } from 'folds';
|
||||
import React, { ChangeEventHandler } from 'react';
|
||||
import { FilterByValues } from '../../../../../types/utils';
|
||||
import { SettingTile } from '../../../../components/setting-tile';
|
||||
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
|
||||
import { FieldContext } from '../Profile';
|
||||
import { ProfileFieldElementProps } from './ProfileFieldContext';
|
||||
|
||||
export function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
|
||||
label, defaultValue, value, setValue, busy,
|
||||
}: ProfileFieldElementProps<K, FieldContext> & { label: string; }) {
|
||||
const disabled = busy;
|
||||
const hasChanges = defaultValue !== value;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
const content = evt.currentTarget.value;
|
||||
if (content.length > 0) {
|
||||
setValue(evt.currentTarget.value);
|
||||
} else {
|
||||
setValue(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setValue(defaultValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={<Text as="span" size="L400">
|
||||
{label}
|
||||
</Text>}
|
||||
>
|
||||
<Box direction="Column" grow="Yes" gap="100">
|
||||
<Box gap="200" aria-disabled={disabled}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
required
|
||||
name="displayNameInput"
|
||||
value={value ?? ''}
|
||||
onChange={handleChange}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
disabled={disabled}
|
||||
readOnly={disabled}
|
||||
after={hasChanges &&
|
||||
!busy && (
|
||||
<IconButton
|
||||
type="reset"
|
||||
onClick={handleReset}
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
160
src/app/features/settings/account/fields/ProfileTimezone.tsx
Normal file
160
src/app/features/settings/account/fields/ProfileTimezone.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import FocusTrap from 'focus-trap-react';
|
||||
import { Text, Overlay, OverlayBackdrop, OverlayCenter, Dialog, Header, config, Box, IconButton, Icon, Icons, Input, toRem, MenuItem, Button } from 'folds';
|
||||
import React, { useRef, useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { CutoutCard } from '../../../../components/cutout-card';
|
||||
import { SettingTile } from '../../../../components/setting-tile';
|
||||
import { FieldContext } from '../Profile';
|
||||
import { ProfileFieldElementProps } from './ProfileFieldContext';
|
||||
|
||||
export function ProfileTimezone({
|
||||
value, setValue, busy,
|
||||
}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) {
|
||||
const disabled = busy;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
// @ts-expect-error Intl.supportedValuesOf isn't in the types yet
|
||||
const timezones = useMemo(() => Intl.supportedValuesOf('timeZone') as string[], []);
|
||||
const filteredTimezones = timezones.filter(
|
||||
(timezone) => query.length === 0 || timezone.toLowerCase().replace('_', ' ').includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(timezone: string) => {
|
||||
setOverlayOpen(false);
|
||||
setValue(timezone);
|
||||
},
|
||||
[setOverlayOpen, setValue]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (overlayOpen) {
|
||||
const scrollView = scrollRef.current;
|
||||
const focusedItem = scrollView?.querySelector(`[data-tz="${value}"]`);
|
||||
|
||||
if (value && focusedItem && scrollView) {
|
||||
focusedItem.scrollIntoView({
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [scrollRef, value, overlayOpen]);
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={<Text as="span" size="L400">
|
||||
Timezone
|
||||
</Text>}
|
||||
>
|
||||
<Overlay open={overlayOpen} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: () => inputRef.current,
|
||||
allowOutsideClick: true,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: () => setOverlayOpen(false),
|
||||
escapeDeactivates: (evt) => {
|
||||
evt.stopPropagation();
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface">
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4">Choose a Timezone</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={() => setOverlayOpen(false)} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="500"
|
||||
variant="Background"
|
||||
radii="400"
|
||||
outlined
|
||||
placeholder="Search"
|
||||
before={<Icon size="200" src={Icons.Search} />}
|
||||
value={query}
|
||||
onChange={(evt) => setQuery(evt.currentTarget.value)} />
|
||||
<CutoutCard ref={scrollRef} style={{ overflowY: 'scroll', height: toRem(300) }}>
|
||||
{filteredTimezones.length === 0 && (
|
||||
<Box
|
||||
style={{ paddingTop: config.space.S700 }}
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
>
|
||||
<Text size="H6" align="Center">
|
||||
No Results
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{filteredTimezones.map((timezone) => (
|
||||
<MenuItem
|
||||
key={timezone}
|
||||
data-tz={timezone}
|
||||
variant={timezone === value ? 'Success' : 'Surface'}
|
||||
fill={timezone === value ? 'Soft' : 'None'}
|
||||
size="300"
|
||||
radii="0"
|
||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||
onClick={() => handleSelect(timezone)}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
{timezone}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</CutoutCard>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
<Box gap="200">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
disabled={disabled}
|
||||
onClick={() => setOverlayOpen(true)}
|
||||
after={<Icon size="100" src={Icons.ChevronRight} />}
|
||||
>
|
||||
<Text size="B300">{value ?? 'Set Timezone'}</Text>
|
||||
</Button>
|
||||
{value && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={disabled}
|
||||
onClick={() => setValue(undefined)}
|
||||
>
|
||||
<Text size="B300">Remove Timezone</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
|
||||
import { CutoutCard } from '../../../components/cutout-card';
|
||||
|
||||
type AccountDataProps = {
|
||||
expand: boolean;
|
||||
onExpandToggle: (expand: boolean) => void;
|
||||
onSelect: (type: string | null) => void;
|
||||
};
|
||||
export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [accountDataTypes, setAccountDataKeys] = useState(() =>
|
||||
Array.from(mx.store.accountData.keys())
|
||||
);
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(() => {
|
||||
setAccountDataKeys(Array.from(mx.store.accountData.keys()));
|
||||
}, [mx])
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Account Data</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Global"
|
||||
description="Data stored in your global account data."
|
||||
after={
|
||||
<Button
|
||||
onClick={() => onExpandToggle(!expand)}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
before={
|
||||
<Icon src={expand ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
|
||||
}
|
||||
>
|
||||
<Text size="B300">{expand ? 'Collapse' : 'Expand'}</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{expand && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box justifyContent="SpaceBetween">
|
||||
<Text size="L400">Events</Text>
|
||||
<Text size="L400">Total: {accountDataTypes.length}</Text>
|
||||
</Box>
|
||||
<CutoutCard>
|
||||
<MenuItem
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
before={<Icon size="50" src={Icons.Plus} />}
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
Add New
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{accountDataTypes.sort().map((type) => (
|
||||
<MenuItem
|
||||
key={type}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||
onClick={() => onSelect(type)}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
{type}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</CutoutCard>
|
||||
</Box>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import { Box, Text, Icon, Icons, MenuItem } from 'folds';
|
||||
import { CutoutCard } from '../../../components/cutout-card';
|
||||
|
||||
type AccountDataListProps = {
|
||||
types: string[];
|
||||
onSelect: (type: string | null) => void;
|
||||
};
|
||||
export function AccountDataList({
|
||||
types,
|
||||
onSelect,
|
||||
}: AccountDataListProps) {
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Box justifyContent="SpaceBetween">
|
||||
<Text size="L400">Fields</Text>
|
||||
<Text size="L400">Total: {types.length}</Text>
|
||||
</Box>
|
||||
<CutoutCard>
|
||||
<MenuItem
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
before={<Icon size="50" src={Icons.Plus} />}
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
Add New
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{types.sort().map((type) => (
|
||||
<MenuItem
|
||||
key={type}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="0"
|
||||
after={<Icon size="50" src={Icons.ChevronRight} />}
|
||||
onClick={() => onSelect(type)}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="T200" truncate>
|
||||
{type}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</CutoutCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
|
||||
import { AccountDataEvents } from 'matrix-js-sdk';
|
||||
import { Feature, ServerSupport } from 'matrix-js-sdk/lib/feature';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
|
|
@ -8,117 +10,209 @@ import { useSetting } from '../../../state/hooks/settings';
|
|||
import { settingsAtom } from '../../../state/settings';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import {
|
||||
AccountDataDeleteCallback,
|
||||
AccountDataEditor,
|
||||
AccountDataSubmitCallback,
|
||||
} from '../../../components/AccountDataEditor';
|
||||
import { copyToClipboard } from '../../../utils/dom';
|
||||
import { AccountData } from './AccountData';
|
||||
import { AccountDataList } from './AccountDataList';
|
||||
import { useExtendedProfile } from '../../../hooks/useExtendedProfile';
|
||||
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
|
||||
import { CollapsibleCard } from '../../../components/CollapsibleCard';
|
||||
|
||||
type DeveloperToolsPage =
|
||||
| { name: 'index' }
|
||||
| { name: 'account-data'; type: string | null }
|
||||
| { name: 'profile-field'; type: string | null };
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId() as string;
|
||||
|
||||
const [accountDataTypes, setAccountDataKeys] = useState(() =>
|
||||
Array.from(mx.store.accountData.keys())
|
||||
);
|
||||
const accountDataDeletionSupported =
|
||||
(mx.canSupport.get(Feature.AccountDataDeletion) ?? ServerSupport.Unsupported) !==
|
||||
ServerSupport.Unsupported;
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(() => {
|
||||
setAccountDataKeys(Array.from(mx.store.accountData.keys()));
|
||||
}, [mx])
|
||||
);
|
||||
|
||||
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
|
||||
|
||||
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const [expand, setExpend] = useState(false);
|
||||
const [accountDataType, setAccountDataType] = useState<string | null>();
|
||||
const [page, setPage] = useState<DeveloperToolsPage>({ name: 'index' });
|
||||
const [globalExpand, setGlobalExpand] = useState(false);
|
||||
const [profileExpand, setProfileExpand] = useState(false);
|
||||
|
||||
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
||||
async (type, content) => {
|
||||
await mx.setAccountData(type, content);
|
||||
await mx.setAccountData(type as keyof AccountDataEvents, content);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
if (accountDataType !== undefined) {
|
||||
return (
|
||||
<AccountDataEditor
|
||||
type={accountDataType ?? undefined}
|
||||
content={accountDataType ? mx.getAccountData(accountDataType)?.getContent() : undefined}
|
||||
submitChange={submitAccountData}
|
||||
requestClose={() => setAccountDataType(undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Developer Tools
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Options</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Enable Developer Tools"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={developerTools}
|
||||
onChange={setDeveloperTools}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{developerTools && (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Access Token"
|
||||
description="Copy access token to clipboard."
|
||||
after={
|
||||
<Button
|
||||
onClick={() =>
|
||||
copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
|
||||
}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
>
|
||||
<Text size="B300">Copy</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
)}
|
||||
</Box>
|
||||
{developerTools && (
|
||||
<AccountData
|
||||
expand={expand}
|
||||
onExpandToggle={setExpend}
|
||||
onSelect={setAccountDataType}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
const deleteAccountData: AccountDataDeleteCallback = useCallback(
|
||||
async (type) => {
|
||||
await mx.deleteAccountData(type as keyof AccountDataEvents);
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
const submitProfileField: AccountDataSubmitCallback = useCallback(
|
||||
async (type, content) => {
|
||||
await mx.setExtendedProfileProperty(type, content);
|
||||
await refreshExtendedProfile();
|
||||
},
|
||||
[mx, refreshExtendedProfile]
|
||||
);
|
||||
|
||||
const deleteProfileField: AccountDataDeleteCallback = useCallback(
|
||||
async (type) => {
|
||||
await mx.deleteExtendedProfileProperty(type);
|
||||
await refreshExtendedProfile();
|
||||
},
|
||||
[mx, refreshExtendedProfile]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => setPage({ name: 'index' }), [setPage]);
|
||||
|
||||
switch (page.name) {
|
||||
case 'account-data':
|
||||
return (
|
||||
<AccountDataEditor
|
||||
type={page.type ?? undefined}
|
||||
content={
|
||||
page.type
|
||||
? mx.getAccountData(page.type as keyof AccountDataEvents)?.getContent()
|
||||
: undefined
|
||||
}
|
||||
submitChange={submitAccountData}
|
||||
submitDelete={accountDataDeletionSupported ? deleteAccountData : undefined}
|
||||
requestClose={handleClose}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'profile-field':
|
||||
return (
|
||||
<AccountDataEditor
|
||||
type={page.type ?? undefined}
|
||||
content={page.type ? extendedProfile?.[page.type] : undefined}
|
||||
submitChange={submitProfileField}
|
||||
submitDelete={deleteProfileField}
|
||||
requestClose={handleClose}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
Developer Tools
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Options</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Enable Developer Tools"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={developerTools}
|
||||
onChange={setDeveloperTools}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{developerTools && (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Access Token"
|
||||
description="Copy access token to clipboard."
|
||||
after={
|
||||
<Button
|
||||
onClick={() =>
|
||||
copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
|
||||
}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
>
|
||||
<Text size="B300">Copy</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
)}
|
||||
</Box>
|
||||
{developerTools && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Account Data</Text>
|
||||
<CollapsibleCard
|
||||
expand={globalExpand}
|
||||
setExpand={setGlobalExpand}
|
||||
title="Account"
|
||||
description="Private data stored in your account."
|
||||
>
|
||||
<AccountDataList
|
||||
types={accountDataTypes}
|
||||
onSelect={(type) => setPage({ name: 'account-data', type })}
|
||||
/>
|
||||
</CollapsibleCard>
|
||||
{extendedProfile && (
|
||||
<CollapsibleCard
|
||||
expand={profileExpand}
|
||||
setExpand={setProfileExpand}
|
||||
title="Profile"
|
||||
description="Public data attached to your Matrix profile."
|
||||
>
|
||||
<AccountDataList
|
||||
types={Object.keys(extendedProfile)}
|
||||
onSelect={(type) => setPage({ name: 'profile-field', type })}
|
||||
/>
|
||||
</CollapsibleCard>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|||
import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
import { CollapsibleCard } from '../../../components/CollapsibleCard';
|
||||
|
||||
function ExportKeys() {
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -121,37 +122,18 @@ function ExportKeys() {
|
|||
);
|
||||
}
|
||||
|
||||
function ExportKeysTile() {
|
||||
function ExportKeysCard() {
|
||||
const [expand, setExpand] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
title="Export Messages Data"
|
||||
description="Save password protected copy of encryption data on your device to decrypt messages later."
|
||||
after={
|
||||
<Box>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setExpand(!expand)}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
before={
|
||||
<Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
|
||||
}
|
||||
>
|
||||
<Text as="span" size="B300" truncate>
|
||||
{expand ? 'Collapse' : 'Expand'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{expand && <ExportKeys />}
|
||||
</>
|
||||
<CollapsibleCard
|
||||
expand={expand}
|
||||
setExpand={setExpand}
|
||||
title="Export Messages Data"
|
||||
description="Save password protected copy of encryption data on your device to decrypt messages later."
|
||||
>
|
||||
<ExportKeys />
|
||||
</CollapsibleCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -304,14 +286,7 @@ export function LocalBackup() {
|
|||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Local Backup</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<ExportKeysTile />
|
||||
</SequenceCard>
|
||||
<ExportKeysCard />
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
|||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<RoomIcon
|
||||
space
|
||||
roomType={room.getType()}
|
||||
size="50"
|
||||
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
|
||||
filled
|
||||
|
|
|
|||
34
src/app/hooks/useCallMemberships.ts
Normal file
34
src/app/hooks/useCallMemberships.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import {
|
||||
MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useCallMembers = (mx: MatrixClient, roomId: string): CallMembership[] => {
|
||||
const [memberships, setMemberships] = useState<CallMembership[]>([]);
|
||||
const room = mx.getRoom(roomId);
|
||||
useEffect(() => {
|
||||
if (!room) {
|
||||
setMemberships([]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mxr = mx.matrixRTC.getRoomSession(room);
|
||||
|
||||
const updateMemberships = () => {
|
||||
if (!room.isCallRoom()) return;
|
||||
setMemberships(MatrixRTCSession.callMembershipsForRoom(room));
|
||||
};
|
||||
|
||||
updateMemberships();
|
||||
|
||||
mxr.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
|
||||
return () => {
|
||||
mxr.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships);
|
||||
};
|
||||
}, [mx, room, roomId]);
|
||||
|
||||
return memberships;
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@ export type ClientConfig = {
|
|||
defaultHomeserver?: number;
|
||||
homeserverList?: string[];
|
||||
allowCustomHomeservers?: boolean;
|
||||
elementCallUrl?: string;
|
||||
|
||||
featuredCommunities?: {
|
||||
openAsDefault?: boolean;
|
||||
|
|
|
|||
112
src/app/hooks/useExtendedProfile.ts
Normal file
112
src/app/hooks/useExtendedProfile.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { useCallback } from 'react';
|
||||
import z from 'zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Capabilities } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useSpecVersions } from './useSpecVersions';
|
||||
import { IProfileFieldsCapability } from '../../types/matrix/common';
|
||||
|
||||
const extendedProfile = z.looseObject({
|
||||
displayname: z.string().optional(),
|
||||
avatar_url: z.string().optional(),
|
||||
'io.fsky.nyx.pronouns': z
|
||||
.object({
|
||||
language: z.string(),
|
||||
summary: z.string(),
|
||||
})
|
||||
.array()
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
'us.cloke.msc4175.tz': z.string().optional().catch(undefined),
|
||||
});
|
||||
|
||||
export type ExtendedProfile = z.infer<typeof extendedProfile>;
|
||||
|
||||
export function useExtendedProfileSupported(): boolean {
|
||||
const { versions, unstable_features: unstableFeatures } = useSpecVersions();
|
||||
|
||||
return unstableFeatures?.['uk.tcpip.msc4133'] || versions.includes('v1.15');
|
||||
}
|
||||
|
||||
/// Returns the user's MSC4133 extended profile, if our homeserver supports it.
|
||||
/// This will return `undefined` while the request is in flight and `null` if the HS lacks support.
|
||||
export function useExtendedProfile(
|
||||
userId: string
|
||||
): [ExtendedProfile | undefined | null, () => Promise<void>] {
|
||||
const mx = useMatrixClient();
|
||||
const extendedProfileSupported = useExtendedProfileSupported();
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['extended-profile', userId],
|
||||
queryFn: useCallback(async () => {
|
||||
if (extendedProfileSupported) {
|
||||
return extendedProfile.parse(await mx.getExtendedProfile(userId));
|
||||
}
|
||||
return null;
|
||||
}, [mx, userId, extendedProfileSupported]),
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
return [
|
||||
data,
|
||||
async () => {
|
||||
await refetch();
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const LEGACY_FIELDS = ['displayname', 'avatar_url'];
|
||||
|
||||
/// Returns whether the given profile field may be edited by the user.
|
||||
export function profileEditsAllowed(
|
||||
field: string,
|
||||
capabilities: Capabilities,
|
||||
extendedProfileSupported: boolean
|
||||
): boolean {
|
||||
if (LEGACY_FIELDS.includes(field)) {
|
||||
// this field might have a pre-msc4133 capability. check that first
|
||||
if (capabilities[`m.set_${field}`]?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!extendedProfileSupported) {
|
||||
// the homeserver only supports legacy fields
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (extendedProfileSupported) {
|
||||
// the homeserver has msc4133 support
|
||||
const extendedProfileCapability = capabilities[
|
||||
'uk.tcpip.msc4133.profile_fields'
|
||||
] as IProfileFieldsCapability;
|
||||
|
||||
if (extendedProfileCapability === undefined) {
|
||||
// the capability is missing, assume modification is allowed
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!extendedProfileCapability.enabled) {
|
||||
// the capability is set to disable profile modifications
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
extendedProfileCapability.allowed !== undefined &&
|
||||
!extendedProfileCapability.allowed.includes(field)
|
||||
) {
|
||||
// the capability includes an allowlist and `field` isn't in it
|
||||
return false;
|
||||
}
|
||||
|
||||
if (extendedProfileCapability.disallowed?.includes(field)) {
|
||||
// the capability includes an blocklist and `field` is in it
|
||||
return false;
|
||||
}
|
||||
|
||||
// the capability is enabled and `field` isn't blocked
|
||||
return true;
|
||||
}
|
||||
|
||||
// `field` is an extended profile key and the homeserver lacks msc4133 support
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1,28 +1,35 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useForceUpdate } from './useForceUpdate';
|
||||
import { useStateEventCallback } from './useStateEventCallback';
|
||||
import { getStateEvents } from '../utils/room';
|
||||
|
||||
export const useStateEvents = (room: Room, eventType: StateEvent) => {
|
||||
export const useStateEvents = (rooms: Room[], eventType: StateEvent): number => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [updateCount, forceUpdate] = useForceUpdate();
|
||||
|
||||
useStateEventCallback(
|
||||
room.client,
|
||||
useCallback(
|
||||
(event) => {
|
||||
if (event.getRoomId() === room.roomId && event.getType() === eventType) {
|
||||
forceUpdate();
|
||||
const relevantRoomIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
if (rooms && Array.isArray(rooms)) {
|
||||
rooms.forEach((room) => {
|
||||
if (room?.roomId) {
|
||||
ids.add(room.roomId);
|
||||
}
|
||||
},
|
||||
[room, eventType, forceUpdate]
|
||||
)
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => getStateEvents(room, eventType),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[room, eventType, updateCount]
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
}, [rooms]);
|
||||
const handleEventCallback = useCallback(
|
||||
(event: MatrixEvent) => {
|
||||
const eventRoomId = event.getRoomId();
|
||||
if (eventRoomId && event.getType() === eventType && relevantRoomIds.has(eventRoomId)) {
|
||||
forceUpdate();
|
||||
}
|
||||
},
|
||||
[eventType, relevantRoomIds, forceUpdate]
|
||||
);
|
||||
useStateEventCallback(mx, handleEventCallback);
|
||||
return updateCount;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ import { Create } from './client/create';
|
|||
import { CreateSpaceModalRenderer } from '../features/create-space';
|
||||
import { SearchModalRenderer } from '../features/search';
|
||||
import { getFallbackSession } from '../state/sessions';
|
||||
import { CallProvider } from './client/call/CallProvider';
|
||||
import { PersistentCallContainer } from './client/call/PersistentCallContainer';
|
||||
|
||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||
const { hashRouter } = clientConfig;
|
||||
|
|
@ -123,15 +125,19 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<ClientRoomsNotificationPreferences>
|
||||
<ClientBindAtoms>
|
||||
<ClientNonUIFeatures>
|
||||
<ClientLayout
|
||||
nav={
|
||||
<MobileFriendlyClientNav>
|
||||
<SidebarNav />
|
||||
</MobileFriendlyClientNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</ClientLayout>
|
||||
<CallProvider>
|
||||
<ClientLayout
|
||||
nav={
|
||||
<MobileFriendlyClientNav>
|
||||
<SidebarNav />
|
||||
</MobileFriendlyClientNav>
|
||||
}
|
||||
>
|
||||
<PersistentCallContainer>
|
||||
<Outlet />
|
||||
</PersistentCallContainer>
|
||||
</ClientLayout>
|
||||
</CallProvider>
|
||||
<SearchModalRenderer />
|
||||
<UserRoomProfileRenderer />
|
||||
<CreateRoomModalRenderer />
|
||||
|
|
|
|||
343
src/app/pages/client/call/CallProvider.tsx
Normal file
343
src/app/pages/client/call/CallProvider.tsx
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import React, {
|
||||
createContext,
|
||||
useState,
|
||||
useContext,
|
||||
useMemo,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import {
|
||||
WidgetApiToWidgetAction,
|
||||
WidgetApiAction,
|
||||
ClientWidgetApi,
|
||||
IWidgetApiRequestData,
|
||||
} from 'matrix-widget-api';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { SmallWidget } from '../../../features/call/SmallWidget';
|
||||
|
||||
interface MediaStatePayload {
|
||||
data?: {
|
||||
audio_enabled?: boolean;
|
||||
video_enabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const WIDGET_MEDIA_STATE_UPDATE_ACTION = 'io.element.device_mute';
|
||||
const WIDGET_HANGUP_ACTION = 'im.vector.hangup';
|
||||
const WIDGET_ON_SCREEN_ACTION = 'set_always_on_screen';
|
||||
const WIDGET_JOIN_ACTION = 'io.element.join';
|
||||
const WIDGET_TILE_UPDATE = 'io.element.tile_layout';
|
||||
|
||||
interface CallContextState {
|
||||
activeCallRoomId: string | null;
|
||||
setActiveCallRoomId: (roomId: string | null) => void;
|
||||
viewedCallRoomId: string | null;
|
||||
setViewedCallRoomId: (roomId: string | null) => void;
|
||||
hangUp: () => void;
|
||||
activeClientWidgetApi: ClientWidgetApi | null;
|
||||
activeClientWidget: SmallWidget | null;
|
||||
registerActiveClientWidgetApi: (
|
||||
roomId: string | null,
|
||||
clientWidgetApi: ClientWidgetApi | null,
|
||||
clientWidget: SmallWidget,
|
||||
activeClientIframeRef: HTMLIFrameElement
|
||||
) => void;
|
||||
sendWidgetAction: <T extends IWidgetApiRequestData = IWidgetApiRequestData>(
|
||||
action: WidgetApiToWidgetAction | string,
|
||||
data: T
|
||||
) => Promise<void>;
|
||||
isAudioEnabled: boolean;
|
||||
isVideoEnabled: boolean;
|
||||
isChatOpen: boolean;
|
||||
isActiveCallReady: boolean;
|
||||
toggleAudio: () => Promise<void>;
|
||||
toggleVideo: () => Promise<void>;
|
||||
toggleChat: () => Promise<void>;
|
||||
}
|
||||
|
||||
const CallContext = createContext<CallContextState | undefined>(undefined);
|
||||
|
||||
interface CallProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_ENABLED = true;
|
||||
const DEFAULT_VIDEO_ENABLED = false;
|
||||
const DEFAULT_CHAT_OPENED = false;
|
||||
|
||||
export function CallProvider({ children }: CallProviderProps) {
|
||||
const [activeCallRoomId, setActiveCallRoomIdState] = useState<string | null>(null);
|
||||
const [viewedCallRoomId, setViewedCallRoomIdState] = useState<string | null>(null);
|
||||
|
||||
const [activeClientWidgetApi, setActiveClientWidgetApiState] = useState<ClientWidgetApi | null>(
|
||||
null
|
||||
);
|
||||
const [activeClientWidget, setActiveClientWidget] = useState<SmallWidget | null>(null);
|
||||
const [activeClientWidgetApiRoomId, setActiveClientWidgetApiRoomId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [activeClientWidgetIframeRef, setActiveClientWidgetIframeRef] =
|
||||
useState<HTMLIFrameElement | null>(null);
|
||||
|
||||
const [isAudioEnabled, setIsAudioEnabledState] = useState<boolean>(DEFAULT_AUDIO_ENABLED);
|
||||
const [isVideoEnabled, setIsVideoEnabledState] = useState<boolean>(DEFAULT_VIDEO_ENABLED);
|
||||
const [isChatOpen, setIsChatOpenState] = useState<boolean>(DEFAULT_CHAT_OPENED);
|
||||
const [isActiveCallReady, setIsActiveCallReady] = useState<boolean>(false);
|
||||
|
||||
const { roomIdOrAlias: viewedRoomId } = useParams<{ roomIdOrAlias: string }>();
|
||||
|
||||
const setActiveCallRoomId = useCallback((roomId: string | null) => {
|
||||
setActiveCallRoomIdState(roomId);
|
||||
}, []);
|
||||
|
||||
const setViewedCallRoomId = useCallback(
|
||||
(roomId: string | null) => {
|
||||
setViewedCallRoomIdState(roomId);
|
||||
},
|
||||
[setViewedCallRoomIdState]
|
||||
);
|
||||
|
||||
const setActiveClientWidgetApi = useCallback(
|
||||
(
|
||||
clientWidgetApi: ClientWidgetApi | null,
|
||||
clientWidget: SmallWidget | null,
|
||||
roomId: string | null,
|
||||
clientWidgetIframeRef: HTMLIFrameElement | null
|
||||
) => {
|
||||
setActiveClientWidgetApiState(clientWidgetApi);
|
||||
setActiveClientWidget(clientWidget);
|
||||
setActiveClientWidgetApiRoomId(roomId);
|
||||
setActiveClientWidgetIframeRef(clientWidgetIframeRef);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const registerActiveClientWidgetApi = useCallback(
|
||||
(
|
||||
roomId: string | null,
|
||||
clientWidgetApi: ClientWidgetApi | null,
|
||||
clientWidget: SmallWidget | null,
|
||||
clientWidgetIframeRef: HTMLIFrameElement | null
|
||||
) => {
|
||||
if (roomId && clientWidgetApi) {
|
||||
setActiveClientWidgetApi(clientWidgetApi, clientWidget, roomId, clientWidgetIframeRef);
|
||||
} else if (roomId === activeClientWidgetApiRoomId || roomId === null) {
|
||||
setActiveClientWidgetApi(null, null, null, null);
|
||||
}
|
||||
},
|
||||
[activeClientWidgetApiRoomId, setActiveClientWidgetApi]
|
||||
);
|
||||
|
||||
const hangUp = useCallback(() => {
|
||||
setActiveClientWidgetApi(null, null, null, null);
|
||||
setActiveCallRoomIdState(null);
|
||||
activeClientWidgetApi?.transport.send(`${WIDGET_HANGUP_ACTION}`, {});
|
||||
setIsActiveCallReady(false);
|
||||
}, [activeClientWidgetApi?.transport, setActiveClientWidgetApi]);
|
||||
|
||||
const sendWidgetAction = useCallback(
|
||||
async <T extends IWidgetApiRequestData = IWidgetApiRequestData>(
|
||||
action: WidgetApiToWidgetAction | string,
|
||||
data: T
|
||||
): Promise<void> => {
|
||||
if (!activeClientWidgetApi) {
|
||||
return Promise.reject(new Error('No active call clientWidgetApi'));
|
||||
}
|
||||
if (!activeClientWidgetApiRoomId || activeClientWidgetApiRoomId !== activeCallRoomId) {
|
||||
return Promise.reject(new Error('Mismatched active call clientWidgetApi'));
|
||||
}
|
||||
|
||||
await activeClientWidgetApi.transport.send(action as WidgetApiAction, data);
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
[activeClientWidgetApi, activeCallRoomId, activeClientWidgetApiRoomId]
|
||||
);
|
||||
|
||||
const toggleAudio = useCallback(async () => {
|
||||
const newState = !isAudioEnabled;
|
||||
setIsAudioEnabledState(newState);
|
||||
|
||||
if (isActiveCallReady) {
|
||||
try {
|
||||
await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
|
||||
audio_enabled: newState,
|
||||
video_enabled: isVideoEnabled,
|
||||
});
|
||||
} catch (error) {
|
||||
setIsAudioEnabledState(!newState);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}, [isAudioEnabled, isVideoEnabled, sendWidgetAction, isActiveCallReady]);
|
||||
|
||||
const toggleVideo = useCallback(async () => {
|
||||
const newState = !isVideoEnabled;
|
||||
setIsVideoEnabledState(newState);
|
||||
|
||||
if (isActiveCallReady) {
|
||||
try {
|
||||
await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
|
||||
audio_enabled: isAudioEnabled,
|
||||
video_enabled: newState,
|
||||
});
|
||||
} catch (error) {
|
||||
setIsVideoEnabledState(!newState);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}, [isVideoEnabled, isAudioEnabled, sendWidgetAction, isActiveCallReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCallRoomId && !viewedCallRoomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeClientWidgetApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleHangup = (ev: CustomEvent) => {
|
||||
ev.preventDefault();
|
||||
if (isActiveCallReady && ev.detail.widgetId === activeClientWidgetApi.widget.id) {
|
||||
activeClientWidgetApi.transport.reply(ev.detail, {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaStateUpdate = (ev: CustomEvent<MediaStatePayload>) => {
|
||||
if (!isActiveCallReady) return;
|
||||
ev.preventDefault();
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
const { audio_enabled, video_enabled } = ev.detail.data ?? {};
|
||||
|
||||
if (typeof audio_enabled === 'boolean' && audio_enabled !== isAudioEnabled) {
|
||||
setIsAudioEnabledState(audio_enabled);
|
||||
}
|
||||
if (typeof video_enabled === 'boolean' && video_enabled !== isVideoEnabled) {
|
||||
setIsVideoEnabledState(video_enabled);
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
};
|
||||
|
||||
const handleOnScreenStateUpdate = (ev: CustomEvent) => {
|
||||
ev.preventDefault();
|
||||
activeClientWidgetApi.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
const handleOnTileLayout = (ev: CustomEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
activeClientWidgetApi.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
const handleJoin = (ev: CustomEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
activeClientWidgetApi.transport.reply(ev.detail, {});
|
||||
|
||||
const iframeDoc =
|
||||
activeClientWidgetIframeRef?.contentWindow?.document ||
|
||||
activeClientWidgetIframeRef?.contentDocument;
|
||||
|
||||
if (iframeDoc) {
|
||||
const observer = new MutationObserver(() => {
|
||||
const button = iframeDoc.querySelector('[data-testid="incall_leave"]');
|
||||
if (button) {
|
||||
button.addEventListener('click', () => {
|
||||
hangUp();
|
||||
});
|
||||
}
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(iframeDoc, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
setIsActiveCallReady(true);
|
||||
};
|
||||
|
||||
void sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, {
|
||||
audio_enabled: isAudioEnabled,
|
||||
video_enabled: isVideoEnabled,
|
||||
}).catch(() => {
|
||||
// Widget transport may reject while call/session setup is still in progress.
|
||||
});
|
||||
|
||||
activeClientWidgetApi.on(`action:${WIDGET_HANGUP_ACTION}`, handleHangup);
|
||||
activeClientWidgetApi.on(`action:${WIDGET_MEDIA_STATE_UPDATE_ACTION}`, handleMediaStateUpdate);
|
||||
activeClientWidgetApi.on(`action:${WIDGET_TILE_UPDATE}`, handleOnTileLayout);
|
||||
activeClientWidgetApi.on(`action:${WIDGET_ON_SCREEN_ACTION}`, handleOnScreenStateUpdate);
|
||||
activeClientWidgetApi.on(`action:${WIDGET_JOIN_ACTION}`, handleJoin);
|
||||
}, [
|
||||
activeClientWidgetIframeRef,
|
||||
activeClientWidgetApi,
|
||||
activeCallRoomId,
|
||||
activeClientWidgetApiRoomId,
|
||||
hangUp,
|
||||
isChatOpen,
|
||||
isAudioEnabled,
|
||||
isVideoEnabled,
|
||||
isActiveCallReady,
|
||||
viewedRoomId,
|
||||
viewedCallRoomId,
|
||||
setViewedCallRoomId,
|
||||
activeClientWidget?.iframe?.contentDocument,
|
||||
activeClientWidget?.iframe?.contentWindow?.document,
|
||||
sendWidgetAction,
|
||||
]);
|
||||
|
||||
const toggleChat = useCallback(async () => {
|
||||
const newState = !isChatOpen;
|
||||
setIsChatOpenState(newState);
|
||||
}, [isChatOpen]);
|
||||
|
||||
const contextValue = useMemo<CallContextState>(
|
||||
() => ({
|
||||
activeCallRoomId,
|
||||
setActiveCallRoomId,
|
||||
viewedCallRoomId,
|
||||
setViewedCallRoomId,
|
||||
hangUp,
|
||||
activeClientWidgetApi,
|
||||
registerActiveClientWidgetApi,
|
||||
activeClientWidget,
|
||||
sendWidgetAction,
|
||||
isChatOpen,
|
||||
isAudioEnabled,
|
||||
isVideoEnabled,
|
||||
isActiveCallReady,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
toggleChat,
|
||||
}),
|
||||
[
|
||||
activeCallRoomId,
|
||||
setActiveCallRoomId,
|
||||
viewedCallRoomId,
|
||||
setViewedCallRoomId,
|
||||
hangUp,
|
||||
activeClientWidgetApi,
|
||||
registerActiveClientWidgetApi,
|
||||
activeClientWidget,
|
||||
sendWidgetAction,
|
||||
isChatOpen,
|
||||
isAudioEnabled,
|
||||
isVideoEnabled,
|
||||
isActiveCallReady,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
toggleChat,
|
||||
]
|
||||
);
|
||||
|
||||
return <CallContext.Provider value={contextValue}>{children}</CallContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCallState(): CallContextState {
|
||||
const context = useContext(CallContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCallState must be used within a CallProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
186
src/app/pages/client/call/PersistentCallContainer.tsx
Normal file
186
src/app/pages/client/call/PersistentCallContainer.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import React, { createContext, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ClientWidgetApi } from 'matrix-widget-api';
|
||||
import { Box } from 'folds';
|
||||
import { useCallState } from './CallProvider';
|
||||
import {
|
||||
createVirtualWidget,
|
||||
SmallWidget,
|
||||
getWidgetData,
|
||||
getWidgetUrl,
|
||||
} from '../../../features/call/SmallWidget';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { ThemeKind, useTheme } from '../../../hooks/useTheme';
|
||||
|
||||
interface PersistentCallContainerProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const CallRefContext =
|
||||
createContext<React.MutableRefObject<HTMLIFrameElement | null> | null>(null);
|
||||
|
||||
export function PersistentCallContainer({ children }: PersistentCallContainerProps) {
|
||||
const callIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const callWidgetApiRef = useRef<ClientWidgetApi | null>(null);
|
||||
const callSmallWidgetRef = useRef<SmallWidget | null>(null);
|
||||
|
||||
const {
|
||||
activeCallRoomId,
|
||||
viewedCallRoomId,
|
||||
isChatOpen,
|
||||
isActiveCallReady,
|
||||
registerActiveClientWidgetApi,
|
||||
activeClientWidget,
|
||||
} = useCallState();
|
||||
const mx = useMatrixClient();
|
||||
const clientConfig = useClientConfig();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const theme = useTheme();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
const setupWidget = useCallback(
|
||||
(
|
||||
widgetApiRef: React.MutableRefObject<ClientWidgetApi | null>,
|
||||
smallWidgetRef: React.MutableRefObject<SmallWidget | null>,
|
||||
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
|
||||
skipLobby: boolean,
|
||||
themeKind: ThemeKind | null
|
||||
) => {
|
||||
if (mx?.getUserId()) {
|
||||
if (activeCallRoomId && !isActiveCallReady) {
|
||||
const roomIdToSet = activeCallRoomId;
|
||||
|
||||
const widgetId = `element-call-${roomIdToSet}-${Date.now()}`;
|
||||
const newUrl = getWidgetUrl(
|
||||
mx,
|
||||
roomIdToSet,
|
||||
clientConfig.elementCallUrl ?? '',
|
||||
widgetId,
|
||||
{
|
||||
skipLobby: skipLobby.toString(),
|
||||
returnToLobby: 'true',
|
||||
perParticipantE2EE: 'true',
|
||||
theme: themeKind,
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
callSmallWidgetRef.current?.roomId &&
|
||||
activeClientWidget?.roomId &&
|
||||
activeClientWidget.roomId === callSmallWidgetRef.current?.roomId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
iframeRef.current &&
|
||||
(!iframeRef.current.src || iframeRef.current.src !== newUrl.toString())
|
||||
) {
|
||||
iframeRef.current.src = newUrl.toString();
|
||||
}
|
||||
|
||||
const iframeElement = iframeRef.current;
|
||||
if (!iframeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = mx.getUserId() ?? '';
|
||||
const app = createVirtualWidget(
|
||||
mx,
|
||||
widgetId,
|
||||
userId,
|
||||
'Element Call',
|
||||
'm.call',
|
||||
newUrl,
|
||||
true,
|
||||
getWidgetData(mx, roomIdToSet, {}, { skipLobby: true }),
|
||||
roomIdToSet
|
||||
);
|
||||
|
||||
const smallWidget = new SmallWidget(app);
|
||||
smallWidgetRef.current = smallWidget;
|
||||
|
||||
const widgetApiInstance = smallWidget.startMessaging(iframeElement);
|
||||
widgetApiRef.current = widgetApiInstance;
|
||||
registerActiveClientWidgetApi(
|
||||
roomIdToSet,
|
||||
widgetApiRef.current,
|
||||
smallWidget,
|
||||
iframeElement
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
mx,
|
||||
activeCallRoomId,
|
||||
isActiveCallReady,
|
||||
clientConfig.elementCallUrl,
|
||||
activeClientWidget,
|
||||
registerActiveClientWidgetApi,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCallRoomId) {
|
||||
setupWidget(callWidgetApiRef, callSmallWidgetRef, callIframeRef, true, theme.kind);
|
||||
}
|
||||
}, [
|
||||
theme,
|
||||
setupWidget,
|
||||
callWidgetApiRef,
|
||||
callSmallWidgetRef,
|
||||
callIframeRef,
|
||||
registerActiveClientWidgetApi,
|
||||
activeCallRoomId,
|
||||
viewedCallRoomId,
|
||||
isActiveCallReady,
|
||||
]);
|
||||
|
||||
const memoizedIframeRef = useMemo(() => callIframeRef, [callIframeRef]);
|
||||
|
||||
return (
|
||||
<CallRefContext.Provider value={memoizedIframeRef}>
|
||||
<Box grow="No">
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
display: isMobile && isChatOpen ? 'none' : 'flex',
|
||||
width: isMobile && isChatOpen ? '0%' : '100%',
|
||||
height: isMobile && isChatOpen ? '0%' : '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
grow="Yes"
|
||||
style={{
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
ref={callIframeRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
title="Persistent Element Call"
|
||||
sandbox="allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads"
|
||||
allow="microphone; camera; display-capture; autoplay; clipboard-write;"
|
||||
src="about:blank"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{children}
|
||||
</CallRefContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ import {
|
|||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
||||
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
|
||||
|
||||
type DirectMenuProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -275,6 +276,7 @@ export function Direct() {
|
|||
</Box>
|
||||
</PageNavContent>
|
||||
)}
|
||||
<CallNavStatus />
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ import {
|
|||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||
import { _RoomSearchParams } from '../../paths';
|
||||
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
|
||||
|
||||
type HomeMenuProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -357,6 +358,7 @@ export function Home() {
|
|||
</Box>
|
||||
</PageNavContent>
|
||||
)}
|
||||
<CallNavStatus />
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Text } from 'folds';
|
||||
import { Icon, Icons } from 'folds';
|
||||
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { Settings } from '../../../features/settings';
|
||||
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||
|
|
@ -13,12 +12,11 @@ import { Modal500 } from '../../../components/Modal500';
|
|||
export function SettingsTab() {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const userId = mx.getUserId()!;
|
||||
const userId = mx.getUserId() as string;
|
||||
const profile = useUserProfile(userId);
|
||||
|
||||
const [settings, setSettings] = useState(false);
|
||||
|
||||
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarUrl = profile.avatarUrl
|
||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
|
@ -34,7 +32,7 @@ export function SettingsTab() {
|
|||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
|
||||
renderFallback={() => <Icon size="400" src={Icons.User} filled />}
|
||||
/>
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ import { ContainerColor } from '../../../styles/ContainerColor.css';
|
|||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
|
||||
import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus';
|
||||
import { useCallState } from '../call/CallProvider';
|
||||
|
||||
type SpaceMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -296,7 +298,7 @@ function SpaceHeader() {
|
|||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
|
||||
{space && <SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />}
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
|
|
@ -387,15 +389,15 @@ export function Space() {
|
|||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
|
||||
const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone);
|
||||
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
|
||||
const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
|
||||
const { isActiveCallReady, activeCallRoomId } = useCallState();
|
||||
|
||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||
|
||||
const getRoom = useCallback(
|
||||
(rId: string) => {
|
||||
(rId: string): Room | undefined => {
|
||||
if (allJoinedRooms.has(rId)) {
|
||||
return mx.getRoom(rId) ?? undefined;
|
||||
}
|
||||
|
|
@ -412,11 +414,20 @@ export function Space() {
|
|||
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
|
||||
return false;
|
||||
}
|
||||
const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId;
|
||||
if (showRoom) return false;
|
||||
return true;
|
||||
const showRoomAnyway =
|
||||
roomToUnread.has(roomId) ||
|
||||
roomId === selectedRoomId ||
|
||||
(isActiveCallReady && activeCallRoomId === roomId);
|
||||
return !showRoomAnyway;
|
||||
},
|
||||
[space.roomId, closedCategories, roomToUnread, selectedRoomId]
|
||||
[
|
||||
space.roomId,
|
||||
closedCategories,
|
||||
roomToUnread,
|
||||
selectedRoomId,
|
||||
activeCallRoomId,
|
||||
isActiveCallReady,
|
||||
]
|
||||
),
|
||||
useCallback(
|
||||
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
|
||||
|
|
@ -427,7 +438,7 @@ export function Space() {
|
|||
const virtualizer = useVirtualizer({
|
||||
count: hierarchy.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 0,
|
||||
estimateSize: () => 32,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
|
|
@ -534,6 +545,7 @@ export function Space() {
|
|||
</NavCategory>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
<CallNavStatus />
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,7 +160,8 @@ export const getOrphanParents = (roomToParents: RoomToParents, roomId: string):
|
|||
};
|
||||
|
||||
export const isMutedRule = (rule: IPushRule) =>
|
||||
rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match';
|
||||
// Check for empty actions (new spec) or dont_notify (deprecated)
|
||||
(rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && rule.conditions?.[0]?.kind === 'event_match';
|
||||
|
||||
export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
|
||||
overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule));
|
||||
|
|
@ -256,24 +257,60 @@ export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
|
|||
return unreadInfos;
|
||||
};
|
||||
|
||||
export const joinRuleToIconSrc = (
|
||||
export const getRoomIconSrc = (
|
||||
icons: Record<IconName, IconSrc>,
|
||||
joinRule: JoinRule,
|
||||
space: boolean
|
||||
): IconSrc | undefined => {
|
||||
if (joinRule === JoinRule.Restricted) {
|
||||
return space ? icons.Space : icons.Hash;
|
||||
roomType?: string,
|
||||
joinRule?: JoinRule,
|
||||
locked?: boolean
|
||||
): IconSrc => {
|
||||
type RoomIcons = {
|
||||
base: IconSrc;
|
||||
locked: IconSrc;
|
||||
public: IconSrc;
|
||||
};
|
||||
|
||||
const roomTypeIcons: Record<string, RoomIcons> = {
|
||||
[RoomType.Call]: {
|
||||
base: icons.VolumeHigh,
|
||||
locked: icons.Lock,
|
||||
public: icons.VolumeHigh,
|
||||
},
|
||||
[RoomType.Space]: {
|
||||
base: icons.Space,
|
||||
locked: icons.SpaceLock,
|
||||
public: icons.SpaceGlobe,
|
||||
},
|
||||
default: {
|
||||
base: icons.Hash,
|
||||
locked: icons.HashLock,
|
||||
public: icons.HashGlobe,
|
||||
},
|
||||
};
|
||||
|
||||
const roomIcons = roomTypeIcons[roomType ?? 'default'] ?? roomTypeIcons.default;
|
||||
|
||||
let roomIcon = roomIcons.base;
|
||||
|
||||
if (locked) {
|
||||
roomIcon = roomIcons.locked;
|
||||
} else {
|
||||
switch (joinRule) {
|
||||
case JoinRule.Invite:
|
||||
case JoinRule.Knock:
|
||||
roomIcon = roomIcons.locked;
|
||||
break;
|
||||
case JoinRule.Restricted:
|
||||
roomIcon = roomIcons.base;
|
||||
break;
|
||||
case JoinRule.Public:
|
||||
roomIcon = roomIcons.public;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (joinRule === JoinRule.Knock) {
|
||||
return space ? icons.SpaceLock : icons.HashLock;
|
||||
}
|
||||
if (joinRule === JoinRule.Invite) {
|
||||
return space ? icons.SpaceLock : icons.HashLock;
|
||||
}
|
||||
if (joinRule === JoinRule.Public) {
|
||||
return space ? icons.SpaceGlobe : icons.HashGlobe;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
return roomIcon;
|
||||
};
|
||||
|
||||
export const getRoomAvatarUrl = (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { MsgType } from 'matrix-js-sdk';
|
|||
|
||||
export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
|
||||
export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
|
||||
export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason';
|
||||
export const MATRIX_SPOILER_REASON_PROPERTY_NAME =
|
||||
'page.codeberg.everypizza.msc4193.spoiler.reason';
|
||||
|
||||
export type IImageInfo = {
|
||||
w?: number;
|
||||
|
|
@ -88,3 +89,9 @@ export type ILocationContent = {
|
|||
geo_uri?: string;
|
||||
info?: IThumbnailContent;
|
||||
};
|
||||
|
||||
export type IProfileFieldsCapability = {
|
||||
enabled?: boolean;
|
||||
allowed?: string[];
|
||||
disallowed?: string[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export enum StateEvent {
|
|||
RoomGuestAccess = 'm.room.guest_access',
|
||||
RoomServerAcl = 'm.room.server_acl',
|
||||
RoomTombstone = 'm.room.tombstone',
|
||||
GroupCallPrefix = 'org.matrix.msc3401.call',
|
||||
GroupCallMemberPrefix = 'org.matrix.msc3401.call.member',
|
||||
|
||||
SpaceChild = 'm.space.child',
|
||||
SpaceParent = 'm.space.parent',
|
||||
|
|
@ -50,6 +52,7 @@ export enum MessageEvent {
|
|||
|
||||
export enum RoomType {
|
||||
Space = 'm.space',
|
||||
Call = 'org.matrix.msc3417.call',
|
||||
}
|
||||
|
||||
export type MSpaceChildContent = {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
export type WithRequiredProp<Type extends object, Key extends keyof Type> = Type & {
|
||||
[Property in Key]-?: Type[Property];
|
||||
};
|
||||
|
||||
// Represents a subset of T containing only the keys whose values extend V
|
||||
export type FilterByValues<T extends object, V> = {
|
||||
[Property in keyof T as T[Property] extends V ? Property : never]: T[Property];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import buildConfig from './build.config';
|
|||
|
||||
const copyFiles = {
|
||||
targets: [
|
||||
{
|
||||
src: 'node_modules/@element-hq/element-call-embedded/dist/*',
|
||||
dest: 'public/element-call',
|
||||
},
|
||||
{
|
||||
src: 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
dest: '',
|
||||
|
|
@ -47,7 +51,10 @@ function serverMatrixSdkCryptoWasm(wasmFilePath) {
|
|||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url === wasmFilePath) {
|
||||
const resolvedPath = path.join(path.resolve(), "/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm");
|
||||
const resolvedPath = path.join(
|
||||
path.resolve(),
|
||||
'/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm'
|
||||
);
|
||||
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
res.setHeader('Content-Type', 'application/wasm');
|
||||
|
|
@ -102,8 +109,8 @@ export default defineConfig({
|
|||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module'
|
||||
}
|
||||
type: 'module',
|
||||
},
|
||||
}),
|
||||
],
|
||||
optimizeDeps: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue